Wikipedia
testwiki
https://test.wikipedia.org/wiki/Main_Page
MediaWiki 1.46.0-wmf.24
first-letter
Media
Special
Talk
User
User talk
Wikipedia
Wikipedia talk
File
File talk
MediaWiki
MediaWiki talk
Template
Template talk
Help
Help talk
Category
Category talk
Thread
Thread talk
Summary
Summary talk
Test namespace 1
Test namespace 1 talk
Test namespace 2
Test namespace 2 talk
Draft
Draft talk
Campaign
Campaign talk
TimedText
TimedText talk
Module
Module talk
SecurePoll
SecurePoll talk
CNBanner
CNBanner talk
Translations
Translations talk
Event
Event talk
Topic
Newsletter
Newsletter talk
Wikipedia:Requests/Permissions
4
32559
739238
737328
2026-04-24T12:11:06Z
Wooze
54732
/* Requests for user rights */add
739238
wikitext
text/x-wiki
<noinclude>{{Shortcut|WP:R/P|WP:RfP|WP:RfA|WP:PERM|WP:RFR|WP:RFPERM}}</noinclude>
{{Wikipedia:Requests/Top}}
== Requests for user rights ==
* Subpages: [[Wikipedia:Requests/Permissions/All|All (current and archived)]]
* Request:
<inputbox>
type=create
prefix=Wikipedia:Requests/Permissions/
preload=Template:PA2
buttonlabel=Requests for user rights
placeholder=Enter your username
</inputbox>
After creating the subpage, come back here and transclude the page below (<code><nowiki>{{Wikipedia:Requests/Permissions/Example}}</nowiki></code>).
<!-- Please transclude your requests below this line, LATEST AT THE TOP, in the form {{Wikipedia:Requests/Permissions/USERNAME}} -->
{{Wikipedia:Requests/Permissions/Wooze}}
<!-- NEW ENTRIES AT THE TOP, NOT HERE -->
a4fsnspb2sgbv8u6xy0nijkzrm076sm
Bug 33151
0
54430
739258
733878
2026-04-24T13:14:46Z
~2026-23062-17
73557
showcaptcha
739258
wikitext
text/x-wiki
{{subst:No footnotes/auto}}{{subst:Unreferenced/auto}}'''a''b'''c''
teste
this is an edit
0i2rxvga6k1dd86k6a3huljrlpvl8jm
Letter-small reclam talk:Q0
0
78506
739240
217971
2026-04-24T12:14:42Z
~2026-23062-17
73557
739240
wikitext
text/x-wiki
<!--please use this page only for test-->
this is an edit
blah blah
2wb8s5841a4xz5y6p46w41igbi974s6
739241
739240
2026-04-24T12:17:13Z
~2026-23062-17
73557
739241
wikitext
text/x-wiki
<!--please use this page only for test-->
this is an edit
continue editing
blah blah blah
n7doqyxxy6n5jehkd95ymqgxwx38c5w
739242
739241
2026-04-24T12:17:15Z
AutoModeratorTest
61468
Reverted edits by [[Special:Contributions/~2026-23062-17|~2026-23062-17]] ([[User talk:~2026-23062-17|talk]]) to last revision by [[User:Dicto23456|Dicto23456]]
217971
wikitext
text/x-wiki
<!--please use this page only for test-->
qmxhi1speatknx5oxiw60vgvtp22lah
Alınca, Ovacık
0
82654
739233
688373
2026-04-24T12:04:19Z
~2026-23062-17
73557
showcaptcha
739233
wikitext
text/x-wiki
<!--- DİKKAT! BU ŞABLONU KAYNAK GÖSTERMEDEN GÜNCELLEMEYİNİZ! --->
{{Türkiye köy bilgi kutusu
|ad = Alınca
|harita =
|harita boyut =
|harita açıklama =
|harita1 =
|harita1 boyut =
|harita1 açıklama =
|harita2 = Karabük in Turkey.svg
|harita2 boyut = 250px
|harita2 açıklama = Karabük
| latd = 41 | latm = 3 | lats = 49 | latNS = N
| longd = 32 | longm = 52 | longs = 56 | longEW = E
|il = Karabük
|ilçe = Ovacık
|bölge = Karadeniz
|muhtar = Satılmış Karadağ
|yüzölçümü =
|yüzölçümü_ref =
|rakım = 1310
|rakım_ref = <ref></ref>
|nüfus = 26
|nüfus yoğunluğu =
|nüfus_ref = <ref name="YERELNET"></ref>
|nüfus_yıl = 2012
|alan kodu = 0370
|plaka kodu =
|posta kodu = 78300
|website = [http://www.yerelnet.org.tr/koyler/koy.php?koyid=267193]
}}
'''Alınca''', [[Karabük (il)|Karabük]] ilinin [[Ovacık, Karabük|Ovacık]] ilçesine bağlı bir [[köy]]dür.
this is an edit
== Tarihçe ==
== Kültür ==
== Coğrafya ==
== İklim ==
Köyün iklimi, [[Türkiye'de Karadeniz İklimi|Karadeniz iklimi]] etki alanı içerisindedir.
== Nüfus ==
{| class="wikitable" width=200
! colspan=2 |Yıllara göre köy nüfus verileri
|-
| align="center" | [[2007]]
| align="right" | 23
|-
| align="center" | [[2000]]
| align="right" | 29
|-
| align="center" | [[1997]]
| align="right" | 43
|}
== Ekonomi ==
Köyün ekonomisi [[tarım]] ve [[hayvancılık|hayvancılığa]] dayalıdır.
== Altyapı bilgileri ==
Köyde [[ilköğretim]] okulu yoktur. Köyde, [[içme suyu şebekesi]] ve [[kanalizasyon]] şebekesi vardır. [[PTT]] şubesi ve PTT acentesi yoktur. [[Sağlık ocağı]] ve sağlık evi de yoktur. Köye ulaşımı sağlayan yol [[asfalt]] olup köyde [[elektrik]] ve sabit [[telefon]] vardır.köyümüz muhtar Satılmış karadağnın öncülüğü ile orköy tarafından hazırlanan proje ile güneş enerji sisteminede 2008 yılında kavuşmuştur
== Dış bağlantılar ==
{{portal|Türkiye}}
[[Kategori:Ovacık (Karabük) belde ve köyleri]]
test
tjcdx1bv2vo2tivwpj45c9bh87dtazj
739234
739233
2026-04-24T12:05:09Z
~2026-23062-17
73557
showcaptcha
739234
wikitext
text/x-wiki
<!--- DİKKAT! BU ŞABLONU KAYNAK GÖSTERMEDEN GÜNCELLEMEYİNİZ! --->
{{Türkiye köy bilgi kutusu
|ad = Alınca
|harita =
|harita boyut =
|harita açıklama =
|harita1 =
|harita1 boyut =
|harita1 açıklama =
|harita2 = Karabük in Turkey.svg
|harita2 boyut = 250px
|harita2 açıklama = Karabük
| latd = 41 | latm = 3 | lats = 49 | latNS = N
| longd = 32 | longm = 52 | longs = 56 | longEW = E
|il = Karabük
|ilçe = Ovacık
|bölge = Karadeniz
|muhtar = Satılmış Karadağ
|yüzölçümü =
|yüzölçümü_ref =
|rakım = 1310
|rakım_ref = <ref></ref>
|nüfus = 26
|nüfus yoğunluğu =
|nüfus_ref = <ref name="YERELNET"></ref>
|nüfus_yıl = 2012
|alan kodu = 0370
|plaka kodu =
|posta kodu = 78300
|website = [http://www.yerelnet.org.tr/koyler/koy.php?koyid=267193]
}}
'''Alınca''', [[Karabük (il)|Karabük]] ilinin [[Ovacık, Karabük|Ovacık]] ilçesine bağlı bir [[köy]]dür.
== Tarihçe ==
== Kültür ==
== Coğrafya ==
== İklim ==
Köyün iklimi, [[Türkiye'de Karadeniz İklimi|Karadeniz iklimi]] etki alanı içerisindedir.
== Nüfus ==
{| class="wikitable" width=200
! colspan=2 |Yıllara göre köy nüfus verileri
|-
| align="center" | [[2007]]
| align="right" | 23
|-
| align="center" | [[2000]]
| align="right" | 29
|-
| align="center" | [[1997]]
| align="right" | 43
|}
== Ekonomi ==
Köyün ekonomisi [[tarım]] ve [[hayvancılık|hayvancılığa]] dayalıdır.
== Altyapı bilgileri ==
Köyde [[ilköğretim]] okulu yoktur. Köyde, [[içme suyu şebekesi]] ve [[kanalizasyon]] şebekesi vardır. [[PTT]] şubesi ve PTT acentesi yoktur. [[Sağlık ocağı]] ve sağlık evi de yoktur. Köye ulaşımı sağlayan yol [[asfalt]] olup köyde [[elektrik]] ve sabit [[telefon]] vardır.köyümüz muhtar Satılmış karadağnın öncülüğü ile orköy tarafından hazırlanan proje ile güneş enerji sisteminede 2008 yılında kavuşmuştur
== Dış bağlantılar ==
{{portal|Türkiye}}
[[Kategori:Ovacık (Karabük) belde ve köyleri]]
test
m4y7uc0r5653e4ymzj0z61mtkm7ku80
Test patrol 4
0
91804
739259
728062
2026-04-24T13:18:46Z
~2026-23062-17
73557
showcaptcha
739259
wikitext
text/x-wiki
One more time...testing
this is an edit
tvofz94d9qp4gdl5tzqmgoxue79jhzu
Collapsible
0
97136
739249
667609
2026-04-24T12:34:08Z
~2026-23062-17
73557
739249
wikitext
text/x-wiki
blank
this is an edit
f1423px2jfxkewegx7cnauw9eo001pz
739256
739249
2026-04-24T13:08:05Z
Wooze
54732
Reverted edit by [[Special:Contributions/~2026-23062-17|~2026-23062-17]] ([[User talk:~2026-23062-17|talk]]) to last revision by [[User:~2025-30384-3|~2025-30384-3]]
667609
wikitext
text/x-wiki
blank
be349kjqdpuoc3b9ab5hvqfdz93txpb
739257
739256
2026-04-24T13:08:19Z
Wooze
54732
Reverted edit by [[Special:Contributions/Wooze|Wooze]] ([[User talk:Wooze|talk]]) to last revision by [[User:~2026-23062-17|~2026-23062-17]]
739249
wikitext
text/x-wiki
blank
this is an edit
f1423px2jfxkewegx7cnauw9eo001pz
User:Sam Sailor/test.js
2
98186
739280
739074
2026-04-24T18:40:30Z
Sam Sailor
26820
Test
739280
javascript
text/javascript
//<nowiki>
(function() {
mw.util.addCSS(`
.ambox-Orphan { display: block !important; }
.comrade-container {
box-shadow: 0 1px 3px rgba(0,0,0,0.1); border-radius: 2px;
padding: 10px 15px; margin: 10px 0; display: flex;
flex-direction: column; align-items: flex-start; gap: 8px;
border: 1px solid #a2a9b1; background: #fcfcfc; line-height: 1.5;
}
.comrade-header { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.comrade-content { display: flex; flex-direction: column; gap: 5px; width: 100%; }
.comrade-success { background-color: #d5f5e3 !important; border-left: 5px solid #27ae60 !important; color: #1b5e20 !important; }
.comrade-nudge { background: #fff9ea; border-left: 5px solid #f0ad4e; }
.comrade-info { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-status { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-container button:not(.comrade-dismiss) {
background: #f8f9fa; border: 1px solid #a2a9b1; border-radius: 2px;
padding: 2px 10px; cursor: pointer; font-weight: bold; font-size: 0.95em; margin-left: 10px;
}
.comrade-container button:not(.comrade-dismiss):hover { border-color: #36c; color: #36c; background: #fff; }
.comrade-dismiss {
background: transparent; border: none; color: #d33; font-weight: bold;
cursor: pointer; font-size: 0.9em;
}
.comrade-dismiss:hover { text-decoration: underline; }
.comrade-code-block { background: #f1f1f1; padding: 4px 8px; border-radius: 3px; font-family: monospace; display: inline-block; margin-top: 4px; }
`);
function appendDismiss($el) {
$('<button>').addClass('comrade-dismiss').text('Dismiss').on('click', function() {
$(this).closest('.comrade-container').fadeOut();
}).appendTo($el.find('.comrade-header'));
$("#siteSub").after($el);
}
async function checkAndRenderRedirect(redirTitle, targetTitle, rCategory, labelText, customWikitext) {
const api = new mw.Api();
const res = await api.get({
action: 'query',
titles: redirTitle,
formatversion: 2
});
if (res.query.pages[0].missing) {
const safeId = "comrade-redir-" + redirTitle.replace(/[^a-z0-9]/gi, '-');
const $container = $('<div>').addClass('comrade-container comrade-info').attr('id', safeId);
const $header = $('<div>').addClass('comrade-header');
const summary = `Created redirect from ${labelText.toLowerCase()} to [[${targetTitle}]]`;
const $btn = $('<button>').text('Create redirect').on('click', () => createModernRedirect(redirTitle, targetTitle, rCategory, summary, safeId, customWikitext));
$header.append($('<div>').append($('<strong>').text(`${labelText}: `), `Create redirect from `, $('<code>').text(redirTitle), $btn));
$container.append($header);
appendDismiss($container);
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
if (qid) {
try {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
});
const res = await fetch(url).then(r => r.json());
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
}
} catch (e) {
console.warn("Comrade: Wikidata API call failed.");
}
}
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
if (domain.includes(".") && url.pathname === "/") {
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
try {
const response = await fetch(citationURL);
if (response.ok) {
await checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
}
} catch (e) {
console.warn("Comrade: Citation API check failed.");
}
}
} catch (e) {
console.warn("Comrade: Error parsing URL object.");
}
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan, resolvedTitle) {
const api = new mw.Api();
const candidates = resolvedTitle ? [resolvedTitle] : [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
redirects: 0
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const foundTitle = page.title;
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) break;
const $container = $('<div>').addClass('comrade-container');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (isOrphan) {
$container.addClass('comrade-nudge');
$header.append($('<strong>').text('Comrade nudge:'));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
}
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(foundTitle),
target: '_blank'
}).text(foundTitle), "; should it be?"));
const snippet = '* {{anbl|' + currentTitle + '}}';
const $snippetWrapper = $('<div>').css({
'display': 'flex',
'align-items': 'center',
'gap': '10px',
'margin-top': '4px'
});
const $instructionSpan = $('<span>').append('If the Short description is good, consider adding ', $('<code>').text(snippet).css({
'background-color': '#f8f9fa',
'border': '1px solid #eaecf0',
'border-radius': '2px',
'padding': '1px 4px'
}));
const $copyBtn = $('<button>').text('Copy').css('margin-left', '0').on('click', function() {
navigator.clipboard.writeText(snippet).then(() => {
const $this = $(this);
const originalText = $this.text();
$this.text('Copied!').prop('disabled', true);
setTimeout(() => {
$this.text(originalText).prop('disabled', false);
}, 1500);
});
});
$snippetWrapper.append($instructionSpan, $copyBtn);
$content.append($snippetWrapper);
if (!hasShortDesc) {
$content.append($('<span>').css({
'color': '#d33',
'font-weight': 'bold',
'margin-top': '5px'
}).text('⚠️ Missing short description: Please add a concise one before listing.'));
}
$container.append($header, $content);
appendDismiss($container);
break;
}
}
}
}
async function createModernRedirect(redirTitle, targetTitle, rCategory, customSummary, elementId, customWikitext) {
const api = new mw.Api();
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
try {
await api.postWithEditToken({
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
});
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
} catch (e) {
mw.notify("Failed to create redirect.", {
type: 'error'
});
}
}
function getSmartSortKey(fullName, entity) {
const countryIds = entity.claims?.P27?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const locationIds = entity.claims?.P17?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const placeIds = [...(entity.claims?.P19 || []), ...(entity.claims?.P119 || [])].map(c => c.mainsnak?.datavalue?.value?.id).filter(Boolean);
const allRelevantIds = [...countryIds, ...locationIds, ...placeIds];
const birthYear = parseInt(entity.claims?.P569?.[0]?.mainsnak?.datavalue?.value?.time?.match(/[+-](\d{4})/)?.[1]) || null;
if (fullName.startsWith("Saint ")) return fullName.replace("Saint ", "");
const arabicParticles = /\b(Abu|Abd|Abdel|Abdul|ben|bin|bint)\b/i;
if (fullName.match(arabicParticles)) {
if (birthYear && birthYear > 1900) {
const parts = fullName.split(' ');
const binIndex = parts.findIndex(p => p.toLowerCase() === 'bin' || p.toLowerCase() === 'ben');
if (binIndex > 0) return parts.slice(binIndex).join(' ') + ', ' + parts.slice(0, binIndex).join(' ');
} else {
return fullName;
}
}
const eastAsianQids = ["Q148", "Q184", "Q884", "Q424", "Q881", "Q17"];
if (allRelevantIds.some(id => eastAsianQids.includes(id))) {
if (allRelevantIds.includes("Q17") && birthYear && birthYear > 1885) return westernSort(fullName);
const parts = fullName.split(' ');
if (parts.length > 1) return `${parts[0]}, ${parts.slice(1).join(' ')}`;
}
return westernSort(fullName);
}
function westernSort(name) {
let cleanName = name;
if (name.startsWith("O'")) cleanName = name.replace("O'", "O");
const parts = cleanName.split(' ');
if (parts.length < 2) return cleanName;
const surname = parts.pop();
if (["Jr.", "Jr", "III", "II", "Sr.", "Sr"].includes(surname)) {
const actualSurname = parts.pop();
return `${actualSurname}, ${parts.join(' ')} ${surname}`;
}
return `${surname}, ${parts.join(' ')}`;
}
async function performDeorphan() {
const api = new mw.Api();
try {
await api.edit(mw.config.get('wgPageName'), function(rev) {
let text = rev.content;
const summary = 'Article has backlinks; removed [[Template:Orphan|{{Orphan}}]]';
text = text.replace(/\{\{Orphan\s*(?:\|[^}]*)?\}\}\s*/gi, '');
text = text.replace(/(\{\{Multiple[ _]issues\s*)\|\s*\|/gi, '$1|');
text = replaceMI(text);
text = text.replace(/\n{3,}/g, '\n\n').trim();
return {
text,
summary,
minor: true
};
});
mw.notify('Orphan tag removed!', {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
setTimeout(() => location.reload(), 700);
} catch (e) {
mw.notify('Failed to remove orphan tag.', {
type: 'error'
});
}
}
function replaceMI(text) {
const miRegex = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miRegex.exec(text)) !== null) {
const start = match.index;
result += text.slice(lastIndex, start);
let depth = 0;
let i = start;
while (i < text.length) {
if (text[i] === '{' && text[i + 1] === '{') {
depth++;
i += 2;
} else if (text[i] === '}' && text[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
const end = i;
const full = text.slice(start, end);
const pipeIdx = full.indexOf('|');
const inner = full.slice(pipeIdx + 1, full.length - 2).trim();
const topLevel = extractTopLevelTemplates(inner);
if (topLevel.length === 0) {
result += '';
} else if (topLevel.length === 1) {
result += topLevel[0].trim();
} else {
result += full;
}
lastIndex = end;
}
result += text.slice(lastIndex);
return result;
}
function extractTopLevelTemplates(inner) {
const templates = [];
let i = 0;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
let depth = 0;
const start = i;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
depth++;
i += 2;
} else if (inner[i] === '}' && inner[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
templates.push(inner.slice(start, i));
} else {
i++;
}
}
return templates;
}
async function performSortCorrection(newName, type) {
const api = new mw.Api();
const title = mw.config.get("wgPageName");
try {
const data = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2
});
let content = data.query.pages[0].revisions[0].content;
const dsRegex = /\{\{(?:DEFAULTSORT|Defaultsort):[^}]+\}\}/i;
const newTag = `{{DEFAULTSORT:${newName}}}`;
let actionDesc = type === "add" ? "added" : "corrected";
if (dsRegex.test(content)) {
content = content.replace(dsRegex, newTag);
} else {
const catRegex = /\[\[Category:/i;
if (catRegex.test(content)) {
content = content.replace(catRegex, `${newTag}\n[[Category:`);
} else {
content += `\n\n${newTag}`;
}
}
await api.postWithEditToken({
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName}`,
nocreate: true
});
mw.notify(`DEFAULTSORT ${actionDesc} to ${newName}!`, {
title: 'Comrade Success',
type: 'success'
});
setTimeout(() => location.reload(), 700);
} catch (err) {
mw.notify('Failed to save DEFAULTSORT.', {
type: 'error'
});
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
function renderSortNudge(target, reason) {
const $dsNudge = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Correct to: ${target}`).on('click', () => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
async function fetchWikidataNameLabels(entity) {
const familyIds = entity.claims?.P734?.map(c => c.mainsnak.datavalue.value.id) || [];
const givenIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
const allNameIds = [...new Set([...familyIds, ...givenIds])];
if (allNameIds.length === 0) return {
givens: [],
surnames: []
};
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: allNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
});
const nameData = await fetch(url).then(r => r.json());
return {
givens: givenIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean),
surnames: familyIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean)
};
}
function fixNameCasing(part, referenceName) {
return part.split(' ').map(word => {
const match = referenceName.match(new RegExp(`\\b${word}\\b`, 'i'));
if (match) return match[0];
return referenceName.split(' ').find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase())) || word;
}).join(' ');
}
function getLongNameFromWikitext(wikitext) {
const firstLine = wikitext.split('\n').find(l => l.includes("'''"));
if (!firstLine) return null;
const boldMatch = firstLine.match(/'''(.+?)'''/);
if (!boldMatch) return null;
return boldMatch[1].replace(/\s*([“"«《„‹「『'].+?[”"»》”›」』']|\([^)\n]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
}
async function init() {
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") return;
const api = new mw.Api();
const qid = mw.config.get("wgWikibaseItemId");
try {
const [pageData, backlinkData] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
api.get({
action: 'query',
list: 'backlinks',
blfilterredir: 'nonredirects',
bllimit: 50,
blnamespace: 0,
bltitle: mw.config.get("wgPageName"),
formatversion: 2
})
]);
const page = pageData.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const currentTitle = mw.config.get("wgTitle");
const cleanTitle = stripDiacritics(currentTitle);
const isOrphanTagged = /\{\{\s*(Orphan|Do-attempt|Lonely|Orp|Orphaned article)/i.test(wikitext) || /\| *orphan *=/i.test(wikitext);
const backLinkCount = backlinkData.query.backlinks.length;
if (isOrphanTagged && backLinkCount >= 1) {
const $container = $('<div>').addClass('comrade-container comrade-status');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text('Remove orphan tag').on('click', () => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) await checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
await checkDomainRedirect(qid, currentTitle);
if (qid) {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
});
const wikiData = await fetch(url).then(r => r.json());
const entity = wikiData.entities[qid];
const isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const nameParts = tClean.split(' ');
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
const isOrphan = isOrphanTagged && backLinkCount < 1;
const primarySurname = hasFamilyNameHatnote ? nameParts[0] : (getSmartSortKey(tClean, entity).split(',')[0] || nameParts[nameParts.length - 1]);
let targetSortName = "";
if (isHuman) {
const {
givens,
surnames
} = await fetchWikidataNameLabels(entity);
const refName = entity.labels?.en?.value || currentTitle;
if (dsMatch) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixNameCasing(lastPart, refName)}, ${fixNameCasing(firstPart, refName)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field.");
} else if (givens.length > 0 && lastPart.split(' ').length > 1) {
const misplacedGiven = lastPart.split(' ').find(part => givens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastPart.split(' ').filter(p => p !== misplacedGiven).join(' ');
targetSortName = `${fixNameCasing(newSurname, refName)}, ${fixNameCasing(misplacedGiven + ' ' + firstPart, refName)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
}
}
} else {
targetSortName = hasFamilyNameHatnote ? `${nameParts[0]}, ${nameParts.slice(1).join(' ')}` : getSmartSortKey(tClean, entity);
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).on('click', () => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
const longName = getLongNameFromWikitext(wikitext);
if (longName && longName.length > tClean.length) {
let sortKey = hasFamilyNameHatnote ? `${longName.split(' ')[0]}, ${longName.split(' ').slice(1).join(' ')}` : getSmartSortKey(longName, entity);
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
await checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
if (targetSortName) {
let targetPage = currentTitle.includes(' (') ? currentTitle.split(' (')[0] : currentTitle;
let rCat = targetPage !== currentTitle ? "R from ambiguous sort name" : "R from sort name";
if (targetSortName.includes(',')) {
const p = targetSortName.split(',');
rCat += `|${p[0].trim().charAt(0).toUpperCase()}|${p[1].trim().charAt(0).toUpperCase()}`;
}
await checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name");
}
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
surnames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
givens.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) finalGivenNames.add(gn);
});
if (finalFamilyNames.size === 0 && nameParts.length >= 2) finalFamilyNames.add(nameParts[nameParts.length - 1]);
if (finalGivenNames.size === 0 && nameParts.length >= 2) finalGivenNames.add(nameParts[0]);
const getPriorityTarget = async (name, type) => {
const titles = type === 'surname' ? [`List of people with surname ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`] : [`List of people with given name ${name}`, `${name} (given name)`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
return titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
};
for (const name of finalGivenNames) {
if (name.toLowerCase() === primarySurname.toLowerCase()) continue;
const target = await getPriorityTarget(name, 'given');
if (target) await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
for (const name of finalFamilyNames) {
const target = await getPriorityTarget(name, 'surname');
if (target) await checkNameList(name, 'surname', currentTitle, hasShortDesc, isOrphan, target);
}
}
}
} catch (err) {
console.error("Comrade error:", err);
}
}
init();
})();
(function() {
if (window.IllWill) return;
const STRIKEOUT = true;
const HIDE = 'hide';
const IllWill = {
Version: "1.1.1",
Attribution: 'created by <a target="_blank" href="/wiki/User:Cobaltcigs">cobaltcigs</a>',
Summary: "Added interlanguage links using IllWill.js",
DataApi: "https://www.wikidata.org/w/api.php",
LocalLang: mw.config.get("wgPageContentLanguage"),
Disambiguator: / \(.*\)$/,
SiteLinks: {},
Config: {
MaxLanguages: 5,
IgnoreScientific: STRIKEOUT,
AutoDiff: true
},
css: `
#illwill { clear: both; margin-bottom: 1.5em; border: 1px solid #a2a9b1; padding: 15px; background: #f8f9fa; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.ill-sitelinks a { display: inline-block; margin-bottom: 3px; font-size: 0.9em; color: #36c; }
.ill-size-tag { font-size: 0.8em; color: #72777d; margin-left: 6px; white-space: nowrap; }
.illwill-ignore { text-decoration: line-through; color: #72777d; font-size: 0.85em; }
.ill-x { width: 35px; text-align: center; }
.ill-input, .ill-output { font-family: 'Courier New', monospace; white-space: pre-wrap; font-size: 0.9em; background: #fff; border: 1px solid #eaecf0; padding: 4px; border-radius: 2px; }
#ill-buttons { text-align: right; padding: 10px; border-top: 1px solid #a2a9b1; margin-top: 10px; }
#ill-buttons button { margin-left: 10px; cursor: pointer; padding: 6px 16px; border-radius: 2px; border: 1px solid #a2a9b1; transition: all 0.2s; }
#ill-ok { background: #36c; color: #fff; border-color: #36c !important; font-weight: bold; }
#ill-ok:hover { background: #447ff5; }
#ill-ok:disabled { background: #eaecf0; color: #72777d; border-color: #c8ccd1 !important; cursor: not-allowed; opacity: 0.7; }
#ill-table { width: 100%; border-collapse: collapse; margin-top: 10px; background: white; }
#ill-table th { background: #f2f2f2; text-align: left; padding: 8px; border: 1px solid #a2a9b1; }
#ill-table td { padding: 8px; border: 1px solid #a2a9b1; vertical-align: top; }
#ill-table caption { background: #eaffea; font-weight: bold; padding: 10px; border: 1px solid #a2a9b1; border-bottom: none; font-size: 1.1em; }
#ill-help { font-size: 0.85em; color: #54595d; padding: 10px; line-height: 1.4; }
.ill-loading-inline { font-style: italic; color: #72777d; font-size: 0.9em; display: block; margin: 5px 0; }
`,
setup() {
if (!["edit", "submit"].includes(mw.config.get('wgAction'))) return;
mw.loader.addStyleTag(this.css);
const link = mw.util.addPortletLink('p-cactions', '#', 'IllWill', 'ca-illwill', "Add interlanguage links via Wikidata");
if (link) {
link.addEventListener('click', (e) => {
e.preventDefault();
this.makePanel();
});
}
},
norm(s) {
if (!s) return "";
const t = s.trim().replace(/[\s_]+/g, " ");
return t.charAt(0).toUpperCase() + t.slice(1);
},
async getRedLinks($textbox) {
const txt = $textbox.textSelection('getContents') || "";
const wl = txt.match(/\[\[[^[\]\n]+\]\]/g);
if (!wl) return {
reds: [],
txt
};
const api = new mw.Api();
const textToParse = wl.join("").replace(/\|[^\]]+/g, "");
try {
const data = await api.post({
action: 'parse',
text: textToParse,
contentmodel: 'wikitext',
formatversion: 2
});
const tempDiv = document.createElement('div');
tempDiv.innerHTML = data.parse.text;
const reds = Array.from(tempDiv.querySelectorAll("a.new")).map(x => {
const urlParams = new URLSearchParams(x.href.split('?')[1]);
return urlParams.get('title')?.replace(/_/g, " ");
}).filter(Boolean);
return {
reds: [...new Set(reds)],
txt
};
} catch (e) {
return {
reds: [],
txt
};
}
},
async makePanel() {
const $editform = $('#editform');
const $textbox = $('#wpTextbox1');
$('#illwill').remove();
$editform.hide();
const $wrapper = $('<div id="illwill">Loading redlinks...</div>').insertBefore($editform);
const {
reds,
txt
} = await this.getRedLinks($textbox);
const wtLinks = [];
reds.forEach(title => {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s/g, "[\\s_]+");
const re = new RegExp("\\[\\[\\s*" + escaped + "\\s*(?:\\|[^\\[\\]]+)?\\]\\]", "ig");
const match = txt.match(re);
if (match && !wtLinks.some(z => z.title === title)) {
wtLinks.push({
link: match[0],
title
});
}
});
if (!wtLinks.length) {
const $back = $('<button>Quit</button>').on('click', () => {
$wrapper.remove();
$editform.show();
});
$wrapper.empty().append($('<strong>No valid redlinks found in text.</strong> '), $back);
return;
}
const $table = $(`
<table id="ill-table" class="wikitable">
<caption>IllWill.js (v${this.Version})</caption>
<thead>
<tr>
<th class="ill-x"><input type="checkbox" id="ill-select-all" disabled></th>
<th>Input link</th>
<th>Possible Wikidata matches</th>
<th>Sitelinks (top ${this.Config.MaxLanguages} by size)</th>
<th>Resulting wikitext</th>
</tr>
</thead>
<tbody></tbody>
<tfoot>
<tr>
<td colspan="3" id="ill-help">Carefully examine possible Wikidata matches.</td>
<td colspan="2" id="ill-buttons">
<button id="ill-cancel">Cancel</button>
<button id="ill-ok" disabled>Show changes</button>
</td>
</tr>
</tfoot>
</table>
`);
const $tbody = $table.find('tbody');
wtLinks.forEach((item, i) => {
$tbody.append(`
<tr id="ill-row-${i}" data-index="${i}">
<td class="ill-x"><input type="checkbox" class="ill-row-check" disabled></td>
<td class="ill-input">${this.escapeHTML(item.link)}</td>
<td class="ill-wd-item"><span class="ill-loading-inline">Searching...</span></td>
<td class="ill-sitelinks"></td>
<td class="ill-output"></td>
</tr>
`);
});
$wrapper.empty().append($table);
const chunkSize = 5;
for (let i = 0; i < wtLinks.length; i += chunkSize) {
const chunk = wtLinks.slice(i, i + chunkSize);
await Promise.all(chunk.map((item, idx) => this.fetchWikidataOptimized(item.title, i + idx)));
}
$table.on('click', '#ill-cancel', () => {
$editform.show();
$wrapper.remove();
});
$table.on('click', '#ill-ok', () => this.onOkButton($editform, $wrapper, $textbox));
$table.on('change', '#ill-select-all', (e) => {
const isChecked = $(e.target).is(':checked');
$table.find('.ill-row-check:not(:disabled)').each(function() {
$(this).prop('checked', isChecked).trigger('change');
});
});
$table.on('change', '.ill-row-check', (e) => {
this.onCheckbox(e);
this.updateOkButtonState($table);
});
$table.on('change', 'input[type="radio"]', (e) => {
this.showSiteLinks(e);
});
},
async fetchWikidataOptimized(title, index) {
const query = title.replace(this.Disambiguator, "");
const url = `${this.DataApi}?action=wbsearchentities&search=${encodeURIComponent(query)}&language=${this.LocalLang}&limit=5&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
const results = data.search;
const $cell = $(`#ill-row-${index} .ill-wd-item`);
if (!results || !results.length) {
$cell.html('<span class="ill-na">(no results)</span>');
return;
}
const entityIds = results.map(r => r.id);
await this.fetchEntities(entityIds, index, $cell);
} catch (e) {
$(`#ill-row-${index} .ill-wd-item`).text("Search failed.");
}
},
async fetchEntities(ids, rowIndex, $cell) {
const url = `${this.DataApi}?action=wbgetentities&ids=${ids.join('|')}&props=labels|descriptions|claims|sitelinks&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
$cell.empty();
ids.forEach(qNum => {
const entity = data.entities[qNum];
if (!entity) return;
const isScientific = entity.claims?.P31?.some(s => s.mainsnak?.datavalue?.value?.id === "Q13442814");
if (isScientific && this.Config.IgnoreScientific === HIDE) return;
const desc = entity.descriptions?.[this.LocalLang]?.value || entity.descriptions?.en?.value || "";
const label = entity.labels?.[this.LocalLang]?.value || entity.labels?.en?.value || qNum;
const isStrikeout = isScientific && this.Config.IgnoreScientific === STRIKEOUT;
const $div = $(`
<div class="${isStrikeout ? 'illwill-ignore' : ''}">
<input type="radio" name="selection-${rowIndex}" value="${qNum}" id="radio-${qNum}" ${isStrikeout ? 'disabled' : ''}>
<label for="radio-${qNum}">
<a href="https://wikidata.org/wiki/${qNum}" target="_blank">${this.escapeHTML(label)}</a>:
<span class="ill-desc">${desc ? this.escapeHTML(desc) : '<i>no description</i>'}</span>
</label>
</div>
`);
$cell.append($div);
this.SiteLinks[qNum] = entity.sitelinks || {};
});
} catch (e) {
$cell.text("Detail fetch failed.");
}
},
updateOkButtonState($table) {
const $rows = $table.find('.ill-row-check:not(:disabled)');
const $checkedRows = $rows.filter(':checked');
const anyEnabled = $rows.length > 0;
$table.find('#ill-ok').prop('disabled', $checkedRows.length === 0);
const $master = $table.find('#ill-select-all');
$master.prop('disabled', !anyEnabled);
if (anyEnabled && $rows.length === $checkedRows.length) {
$master.prop('checked', true);
} else {
$master.prop('checked', false);
}
},
async showSiteLinks(e) {
const isManualToggling = e.fromCheckbox || false;
const qNum = e.target.value;
const $row = $(e.target).closest('tr');
const sitelinks = this.SiteLinks[qNum];
const $checkbox = $row.find('.ill-row-check');
const $sitelinkCell = $row.find('.ill-sitelinks');
const $outputCell = $row.find('.ill-output');
const $table = $('#ill-table');
const effectiveMax = Math.min(Math.max(this.Config.MaxLanguages, 3), 6);
const validKeys = Object.keys(sitelinks).filter(k => k.endsWith('wiki') && !k.includes('commons'));
$sitelinkCell.html('<span class="ill-loading-inline">Measuring articles...</span>');
$outputCell.empty();
if (!isManualToggling) {
$checkbox.prop('disabled', true).prop('checked', false);
}
const candidates = validKeys.slice(0, 25);
const sizeMap = [];
await Promise.all(candidates.map(async (key) => {
const lang = key.replace('wiki', '').replace(/_/g, '-');
const title = sitelinks[key].title;
const endpoint = `https://${lang}.wikipedia.org/w/api.php?action=query&titles=${encodeURIComponent(title)}&prop=info&format=json&origin=*`;
try {
const res = await fetch(endpoint).then(r => r.json());
const page = Object.values(res.query.pages)[0];
sizeMap.push({
lang,
title,
length: page.length || 0
});
} catch (err) {
sizeMap.push({
lang,
title,
length: 0
});
}
}));
sizeMap.sort((a, b) => b.length - a.length);
const topLinks = sizeMap.slice(0, effectiveMax);
$sitelinkCell.html(topLinks.map(item => {
const kbValue = Math.round(item.length / 1024);
return `<div><a href="https://${item.lang}.wikipedia.org/wiki/${encodeURIComponent(item.title)}" target="_blank">${item.lang}:${this.escapeHTML(item.title)}</a> <span class="ill-size-tag">(${kbValue} kB)</span></div>`;
}).join(""));
const inputWikitext = $row.find('.ill-input').text();
const match = inputWikitext.match(/\[\[(.*?)\]\]/);
if (match) {
const parts = match[1].split('|');
const targetPage = parts[0].trim();
const surfaceName = (parts[1] || parts[0]).trim();
const illLangs = topLinks.map(item => {
const isSameTitle = this.norm(item.title) === this.norm(targetPage);
return `|${item.lang}|${isSameTitle ? '' : item.title}`;
}).join("");
const ltParam = (this.norm(surfaceName) === this.norm(targetPage)) ? "" : `|lt=${surfaceName}`;
$outputCell.text(`{{ill|${targetPage}${ltParam}${illLangs}}}`);
}
$checkbox.prop('disabled', !topLinks.length);
if (!isManualToggling) {
$checkbox.prop('checked', !!topLinks.length);
}
this.updateOkButtonState($table);
},
onCheckbox(e) {
const $row = $(e.target).closest('tr');
const $radio = $row.find('input[type="radio"]:checked');
const isChecked = $(e.target).is(':checked');
if (isChecked) {
if ($radio.length) {
this.showSiteLinks({
target: $radio[0],
fromCheckbox: true
});
}
} else {
$row.find('.ill-sitelinks, .ill-output').empty();
}
},
onOkButton($editform, $wrapper, $textbox) {
let text = $textbox.textSelection('getContents');
let changed = false;
$wrapper.find('.ill-row-check:checked').each(function() {
const $row = $(this).closest('tr');
const input = $row.find('.ill-input').text();
const output = $row.find('.ill-output').text();
if (output) {
const escapedInput = input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
text = text.replace(new RegExp(escapedInput, "gi"), output);
changed = true;
}
});
if (changed) {
$('#wpSummary').val((i, v) => (v ? v + "; " : "") + this.Summary);
$textbox.textSelection('setContents', text);
$wrapper.remove();
$editform.show();
if (this.Config.AutoDiff) $('#wpDiff').click();
}
},
escapeHTML(str) {
return str.replace(/[&<>"']/g, m => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
})[m]);
}
};
window.IllWill = IllWill;
mw.loader.using(['mediawiki.util', 'mediawiki.api', 'jquery.textSelection'], () => {
$(() => IllWill.setup());
});
})();
(function() {
'use strict';
const config = {
enableNS14: true,
rootWhitelists: ['Materials'],
userWhitelist: ""
};
const categoryDataCache = {};
let scanProgress = 0;
let totalToScan = 0;
async function verifyTreeMembership() {
if (!config.enableNS14) return false;
const api = new mw.Api();
const currentTitle = mw.config.get("wgPageName").replace(/_/g, ' ');
const fullRoots = config.rootWhitelists.concat(config.userWhitelist.split(',').map(s => s.trim()).filter(s => s)).map(cat => (cat.startsWith('Category:') ? cat : `Category:${cat}`));
if (fullRoots.includes(currentTitle)) return true;
let categoriesToTable = mw.config.get('wgCategories') || [];
let checked = new Set();
while (categoriesToTable.length > 0) {
if (categoriesToTable.some(cat => fullRoots.includes('Category:' + cat))) {
return true;
}
categoriesToTable.forEach(cat => checked.add(cat));
try {
const res = await api.get({
formatversion: 2,
prop: 'categories',
titles: categoriesToTable.map(c => 'Category:' + c),
cllimit: 'max'
});
let nextBatch = [];
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.categories) {
page.categories.forEach(parent => {
const parentName = parent.title.substring('Category:'.length);
if (!checked.has(parentName)) {
nextBatch.push(parentName);
}
});
}
});
}
categoriesToTable = [...new Set(nextBatch)];
} catch (e) {
return false;
}
}
return false;
}
function getChemboxBlock(wikitext) {
const chemboxRegex = /\{\{\s*Chembox[\s_]*(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const match = wikitext.match(chemboxRegex);
return match ? match[0] : null;
}
function getFormulaFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const formulaMatch = chembox.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
let rawFormula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
rawFormula = rawFormula.split(/\s+/)[0];
return rawFormula.split(',')[0].trim();
}
const propRegex = /\{\{\s*Chembox[\s_]+Properties(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const propSection = chembox.match(propRegex);
if (propSection) {
const content = propSection[0];
const allElements = ['Ac', 'Ag', 'Al', 'Am', 'Ar', 'As', 'At', 'Au', 'B', 'Ba', 'Be', 'Bh', 'Bi', 'Bk', 'Br', 'C', 'Ca', 'Cd', 'Ce', 'Cf', 'Cl', 'Cm', 'Cn', 'Co', 'Cr', 'Cs', 'Cu', 'D', 'Db', 'Ds', 'Dy', 'Er', 'Es', 'Eu', 'F', 'Fe', 'Fl', 'Fm', 'Fr', 'Ga', 'Gd', 'Ge', 'H', 'He', 'Hf', 'Hg', 'Ho', 'Hs', 'I', 'In', 'Ir', 'K', 'Kr', 'La', 'Li', 'Lr', 'Lu', 'Lv', 'Mc', 'Md', 'Mg', 'Mn', 'Mo', 'Mt', 'N', 'Na', 'Nb', 'Nd', 'Ne', 'Nh', 'Ni', 'No', 'Np', 'O', 'Og', 'Os', 'P', 'Pa', 'Pb', 'Pd', 'Pm', 'Po', 'Pr', 'Pt', 'Pu', 'Ra', 'Rb', 'Re', 'Rf', 'Rg', 'Rh', 'Rn', 'Ru', 'S', 'Sb', 'Sc', 'Se', 'Sg', 'Si', 'Sm', 'Sn', 'Sr', 'Ta', 'Tb', 'Tc', 'Te', 'Th', 'Ti', 'Tl', 'Tm', 'Ts', 'U', 'V', 'W', 'Xe', 'Y', 'Yb', 'Zn', 'Zr'];
let data = {};
allElements.forEach(el => {
const elRegex = new RegExp(`\\|\\s*${el}\\s*=\\s*(\\d*\\.?\\d+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch && elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
});
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
elementsPresent.filter(el => !(hasC && (el === "C" || el === "H"))).sort().forEach(el => {
formulaArray.push(el + data[el]);
});
return formulaArray.join('');
}
}
return null;
}
function getCASFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const idRegex = /\{\{\s*Chembox[\s_]+Identifiers(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const idSection = chembox.match(idRegex);
if (!idSection) return null;
const content = idSection[0];
const nbsp = String.fromCharCode(160);
const casMatch = content.match(/\|\s*CASNo\d*\b(?!\s*_)\s*=\s*([^|}\n]+)/i);
if (casMatch) {
let val = casMatch[1].replace(/\{\{[^}]+\}\}/g, "").replace(/\u00AD/g, "").split(" ").join("");
while (val.indexOf(nbsp) !== -1) val = val.replace(nbsp, "");
while (val.indexOf(" ") !== -1) val = val.replace(" ", "");
const cleanedCas = val.trim();
return /^\d{2,7}-\d{2}-\d$/.test(cleanedCas) ? cleanedCas : null;
}
return null;
}
async function getWikidataValue() {
const qid = mw.config.get('wgWikidataItemId');
if (!qid) return null;
const api = new mw.ForeignApi('https://www.wikidata.org/w/api.php');
const res = await api.get({
action: 'wbgetentities',
ids: qid,
props: 'claims',
format: 'json',
formatversion: 2
});
const claims = res.entities[qid].claims;
const data = {};
if (claims.P274) data.formula = claims.P274[0].mainsnak.datavalue.value;
if (claims.P231) data.cas = claims.P231[0].mainsnak.datavalue.value;
return data;
}
async function startBackgroundCategoryScan() {
const api = new mw.Api();
const titles = $('#mw-pages li a').map((i, el) => $(el).attr('title') || $(el).text()).get();
totalToScan = titles.length;
if (totalToScan === 0) return;
const chunks = arrayChunk(titles, 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: chunk,
rvprop: 'content',
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const f = getFormulaFromWikitext(wikitext);
const c = getCASFromWikitext(wikitext);
if (f || c) categoryDataCache[page.title] = {
formula: f,
cas: c
};
});
}
scanProgress += chunk.length;
$('#caschemassist-cat-btn').text(`Scanning... ${scanProgress}/${totalToScan}`);
}
$('#caschemassist-cat-btn').text('Show missing CAS/Chem').prop('disabled', false);
}
async function renderCategoryAudit() {
const api = new mw.Api();
const missingCheckTitles = [];
Object.values(categoryDataCache).forEach(data => {
if (data.formula) missingCheckTitles.push(data.formula);
if (data.cas) missingCheckTitles.push(data.cas);
});
if (missingCheckTitles.length === 0) {
mw.notify("No valid Chembox data found in this category.");
return;
}
const existenceMap = {};
const chunks = arrayChunk([...new Set(missingCheckTitles)], 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
titles: chunk,
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(p => {
existenceMap[p.title] = !p.missing;
});
}
}
$('#mw-pages li').each(function() {
const title = $(this).find('a').attr('title') || $(this).find('a').text().trim();
const data = categoryDataCache[title];
if (!data) return;
if (data.formula && !existenceMap[data.formula]) {
$(this).append($('<span>').css({
'color': 'red',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [${data.formula}]`));
}
if (data.cas && !existenceMap[data.cas]) {
$(this).append($('<span>').css({
'color': 'darkred',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [CAS: ${data.cas}]`));
}
});
$('#caschemassist-cat-btn').text('Audit complete').prop('disabled', true);
}
async function handleSingleArticle() {
const api = new mw.Api();
const currentTitle = mw.config.get("wgTitle");
const [wikiRes, wikidata] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
getWikidataValue()
]);
const page = wikiRes.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const formula = getFormulaFromWikitext(wikitext);
const cas = getCASFromWikitext(wikitext);
if (wikidata) {
let mismatches = [];
if (formula && wikidata.formula && formula !== wikidata.formula) {
mismatches.push(`Formula mismatch: Chembox "${formula}" vs Wikidata "${wikidata.formula}"`);
}
if (cas && wikidata.cas && cas !== wikidata.cas) {
mismatches.push(`CAS mismatch: Chembox "${cas}" vs Wikidata "${wikidata.cas}"`);
}
if (mismatches.length > 0) {
const $warning = $('<div>').css({
'background': '#fff2f2',
'border': '2px solid #d33',
'padding': '10px',
'margin-bottom': '1em',
'color': '#b32424'
}).append($('<strong>').text('⚠️ Data Mismatch Detected: '), mismatches.join(' | '));
$('#mw-content-text').prepend($warning);
}
}
async function checkAndRender(target, label, customWikitext) {
const check = await api.get({
action: 'query',
titles: target,
formatversion: 2
});
if (check.query.pages[0].missing) {
const $container = $('<div>').css({
'background': '#f8f9fa',
'border': '1px solid #a2a9b1',
'padding': '10px',
'margin-bottom': '1em'
});
const $btn = $('<button>').text(`Create redirect: ${target}`).click(async function() {
await api.postWithEditToken({
action: 'edit',
title: target,
text: customWikitext,
summary: `Creating redirect to [[${currentTitle}]] (${label})`,
createonly: true
});
mw.notify(`Created redirect: ${target}`);
$container.fadeOut(400, function() {
$(this).remove();
});
});
$container.append($('<strong>').text(`${label}: `), `Missing redirect. `, $btn);
$('#mw-content-text').prepend($container);
}
}
if (formula && formula !== currentTitle) {
const formulaWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from chemical formula}}\n}}`;
await checkAndRender(formula, "Chemical formula", formulaWikitext);
}
if (cas && cas !== currentTitle) {
const casWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from CAS Registry Number}}\n{{R unprintworthy}}\n}}`;
await checkAndRender(cas, "CAS number", casWikitext);
}
}
function arrayChunk(arr, size) {
const result = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
$(document).ready(async function() {
const ns = mw.config.get("wgNamespaceNumber");
if (ns === 14) {
if (await verifyTreeMembership()) {
const $catContainer = $('#mw-pages');
if ($catContainer.length) {
const $btn = $('<button>').text('Initializing scan...').attr('id', 'caschemassist-cat-btn').addClass('mw-ui-button').css({
'margin': '0 10px 10px 0',
'float': 'right',
'position': 'relative',
'z-index': '100'
}).prop('disabled', true).click(renderCategoryAudit);
$catContainer.prepend($btn);
startBackgroundCategoryScan();
}
}
} else if (ns === 0 && mw.config.get("wgAction") === "view") {
handleSingleArticle();
}
});
})();
//</nowiki>
383jyoyj5uguua8kmmfn04f35w67353
739281
739280
2026-04-24T18:49:59Z
Sam Sailor
26820
Test
739281
javascript
text/javascript
//<nowiki>
(function() {
mw.util.addCSS(`
.ambox-Orphan { display: block !important; }
.comrade-container {
box-shadow: 0 1px 3px rgba(0,0,0,0.1); border-radius: 2px;
padding: 10px 15px; margin: 10px 0; display: flex;
flex-direction: column; align-items: flex-start; gap: 8px;
border: 1px solid #a2a9b1; background: #fcfcfc; line-height: 1.5;
}
.comrade-header { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.comrade-content { display: flex; flex-direction: column; gap: 5px; width: 100%; }
.comrade-success { background-color: #d5f5e3 !important; border-left: 5px solid #27ae60 !important; color: #1b5e20 !important; }
.comrade-nudge { background: #fff9ea; border-left: 5px solid #f0ad4e; }
.comrade-info { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-status { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-container button:not(.comrade-dismiss) {
background: #f8f9fa; border: 1px solid #a2a9b1; border-radius: 2px;
padding: 2px 10px; cursor: pointer; font-weight: bold; font-size: 0.95em; margin-left: 10px;
}
.comrade-container button:not(.comrade-dismiss):hover { border-color: #36c; color: #36c; background: #fff; }
.comrade-dismiss {
background: transparent; border: none; color: #d33; font-weight: bold;
cursor: pointer; font-size: 0.9em;
}
.comrade-dismiss:hover { text-decoration: underline; }
.comrade-code-block { background: #f1f1f1; padding: 4px 8px; border-radius: 3px; font-family: monospace; display: inline-block; margin-top: 4px; }
`);
function appendDismiss($el) {
$('<button>').addClass('comrade-dismiss').text('Dismiss').on('click', function() {
$(this).closest('.comrade-container').fadeOut();
}).appendTo($el.find('.comrade-header'));
$("#siteSub").after($el);
}
async function checkAndRenderRedirect(redirTitle, targetTitle, rCategory, labelText, customWikitext) {
const api = new mw.Api();
const res = await api.get({
action: 'query',
titles: redirTitle,
formatversion: 2
});
if (res.query.pages[0].missing) {
const safeId = "comrade-redir-" + redirTitle.replace(/[^a-z0-9]/gi, '-');
const $container = $('<div>').addClass('comrade-container comrade-info').attr('id', safeId);
const $header = $('<div>').addClass('comrade-header');
const summary = `Created redirect from ${labelText.toLowerCase()} to [[${targetTitle}]]`;
const $btn = $('<button>').text('Create redirect').on('click', () => createModernRedirect(redirTitle, targetTitle, rCategory, summary, safeId, customWikitext));
$header.append($('<div>').append($('<strong>').text(`${labelText}: `), `Create redirect from `, $('<code>').text(redirTitle), $btn));
$container.append($header);
appendDismiss($container);
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
if (qid) {
try {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
});
const res = await fetch(url).then(r => r.json());
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
}
} catch (e) {
console.warn("Comrade: Wikidata API call failed.");
}
}
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
if (domain.includes(".") && url.pathname === "/") {
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
try {
const response = await fetch(citationURL);
if (response.ok) {
await checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
}
} catch (e) {
console.warn("Comrade: Citation API check failed.");
}
}
} catch (e) {
console.warn("Comrade: Error parsing URL object.");
}
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan, resolvedTitle) {
const api = new mw.Api();
const candidates = resolvedTitle ? [resolvedTitle] : [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
redirects: 0
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const foundTitle = page.title;
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) break;
const $container = $('<div>').addClass('comrade-container');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (isOrphan) {
$container.addClass('comrade-nudge');
$header.append($('<strong>').text('Comrade nudge:'));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
}
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(foundTitle),
target: '_blank'
}).text(foundTitle), "; should it be?"));
const snippet = '* {{anbl|' + currentTitle + '}}';
const $snippetWrapper = $('<div>').css({
'display': 'flex',
'align-items': 'center',
'gap': '10px',
'margin-top': '4px'
});
const $instructionSpan = $('<span>').append('If the Short description is good, consider adding ', $('<code>').text(snippet).css({
'background-color': '#f8f9fa',
'border': '1px solid #eaecf0',
'border-radius': '2px',
'padding': '1px 4px'
}));
const $copyBtn = $('<button>').text('Copy').css('margin-left', '0').on('click', function() {
navigator.clipboard.writeText(snippet).then(() => {
const $this = $(this);
const originalText = $this.text();
$this.text('Copied!').prop('disabled', true);
setTimeout(() => {
$this.text(originalText).prop('disabled', false);
}, 1500);
});
});
$snippetWrapper.append($instructionSpan, $copyBtn);
$content.append($snippetWrapper);
if (!hasShortDesc) {
$content.append($('<span>').css({
'color': '#d33',
'font-weight': 'bold',
'margin-top': '5px'
}).text('⚠️ Missing short description: Please add a concise one before listing.'));
}
$container.append($header, $content);
appendDismiss($container);
break;
}
}
}
}
async function createModernRedirect(redirTitle, targetTitle, rCategory, customSummary, elementId, customWikitext) {
const api = new mw.Api();
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
try {
await api.postWithEditToken({
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
});
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
} catch (e) {
mw.notify("Failed to create redirect.", {
type: 'error'
});
}
}
function getSmartSortKey(fullName, entity) {
const countryIds = entity.claims?.P27?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const locationIds = entity.claims?.P17?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const placeIds = [...(entity.claims?.P19 || []), ...(entity.claims?.P119 || [])].map(c => c.mainsnak?.datavalue?.value?.id).filter(Boolean);
const allRelevantIds = [...countryIds, ...locationIds, ...placeIds];
const birthYear = parseInt(entity.claims?.P569?.[0]?.mainsnak?.datavalue?.value?.time?.match(/[+-](\d{4})/)?.[1]) || null;
if (fullName.startsWith("Saint ")) return fullName.replace("Saint ", "");
const arabicParticles = /\b(Abu|Abd|Abdel|Abdul|ben|bin|bint)\b/i;
if (fullName.match(arabicParticles)) {
if (birthYear && birthYear > 1900) {
const parts = fullName.split(' ');
const binIndex = parts.findIndex(p => p.toLowerCase() === 'bin' || p.toLowerCase() === 'ben');
if (binIndex > 0) return parts.slice(binIndex).join(' ') + ', ' + parts.slice(0, binIndex).join(' ');
} else {
return fullName;
}
}
const eastAsianQids = ["Q148", "Q184", "Q884", "Q424", "Q881", "Q17"];
if (allRelevantIds.some(id => eastAsianQids.includes(id))) {
if (allRelevantIds.includes("Q17") && birthYear && birthYear > 1885) return westernSort(fullName);
const parts = fullName.split(' ');
if (parts.length > 1) return `${parts[0]}, ${parts.slice(1).join(' ')}`;
}
return westernSort(fullName);
}
function westernSort(name) {
let cleanName = name;
if (name.startsWith("O'")) cleanName = name.replace("O'", "O");
const parts = cleanName.split(' ');
if (parts.length < 2) return cleanName;
const surname = parts.pop();
if (["Jr.", "Jr", "III", "II", "Sr.", "Sr"].includes(surname)) {
const actualSurname = parts.pop();
return `${actualSurname}, ${parts.join(' ')} ${surname}`;
}
return `${surname}, ${parts.join(' ')}`;
}
async function performDeorphan() {
const api = new mw.Api();
try {
await api.edit(mw.config.get('wgPageName'), function(rev) {
let text = rev.content;
const summary = 'Article has backlinks; removed [[Template:Orphan|{{Orphan}}]]';
text = text.replace(/\{\{Orphan\s*(?:\|[^}]*)?\}\}\s*/gi, '');
text = text.replace(/(\{\{Multiple[ _]issues\s*)\|\s*\|/gi, '$1|');
text = replaceMI(text);
text = text.replace(/\n{3,}/g, '\n\n').trim();
return {
text,
summary,
minor: true
};
});
mw.notify('Orphan tag removed!', {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
setTimeout(() => location.reload(), 700);
} catch (e) {
mw.notify('Failed to remove orphan tag.', {
type: 'error'
});
}
}
function replaceMI(text) {
const miRegex = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miRegex.exec(text)) !== null) {
const start = match.index;
result += text.slice(lastIndex, start);
let depth = 0;
let i = start;
while (i < text.length) {
if (text[i] === '{' && text[i + 1] === '{') {
depth++;
i += 2;
} else if (text[i] === '}' && text[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
const end = i;
const full = text.slice(start, end);
const pipeIdx = full.indexOf('|');
const inner = full.slice(pipeIdx + 1, full.length - 2).trim();
const topLevel = extractTopLevelTemplates(inner);
if (topLevel.length === 0) {
result += '';
} else if (topLevel.length === 1) {
result += topLevel[0].trim();
} else {
result += full;
}
lastIndex = end;
}
result += text.slice(lastIndex);
return result;
}
function extractTopLevelTemplates(inner) {
const templates = [];
let i = 0;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
let depth = 0;
const start = i;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
depth++;
i += 2;
} else if (inner[i] === '}' && inner[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
templates.push(inner.slice(start, i));
} else {
i++;
}
}
return templates;
}
async function performSortCorrection(newName, type) {
const api = new mw.Api();
const title = mw.config.get("wgPageName");
try {
const data = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2
});
let content = data.query.pages[0].revisions[0].content;
const dsRegex = /\{\{(?:DEFAULTSORT|Defaultsort):[^}]+\}\}/i;
const newTag = `{{DEFAULTSORT:${newName}}}`;
let actionDesc = type === "add" ? "added" : "corrected";
if (dsRegex.test(content)) {
content = content.replace(dsRegex, newTag);
} else {
const catRegex = /\[\[Category:/i;
if (catRegex.test(content)) {
content = content.replace(catRegex, `${newTag}\n[[Category:`);
} else {
content += `\n\n${newTag}`;
}
}
await api.postWithEditToken({
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName}`,
nocreate: true
});
mw.notify(`DEFAULTSORT ${actionDesc} to ${newName}!`, {
title: 'Comrade Success',
type: 'success'
});
setTimeout(() => location.reload(), 700);
} catch (err) {
mw.notify('Failed to save DEFAULTSORT.', {
type: 'error'
});
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
function renderSortNudge(target, reason) {
const $dsNudge = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Correct to: ${target}`).on('click', () => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
async function fetchWikidataNameLabels(entity) {
const familyIds = entity.claims?.P734?.map(c => c.mainsnak.datavalue.value.id) || [];
const givenIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
const allNameIds = [...new Set([...familyIds, ...givenIds])];
if (allNameIds.length === 0) return {
givens: [],
surnames: []
};
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: allNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
});
const nameData = await fetch(url).then(r => r.json());
return {
givens: givenIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean),
surnames: familyIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean)
};
}
function fixNameCasing(part, referenceName) {
return part.split(' ').map(word => {
const match = referenceName.match(new RegExp(`\\b${word}\\b`, 'i'));
if (match) return match[0];
return referenceName.split(' ').find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase())) || word;
}).join(' ');
}
function getLongNameFromWikitext(wikitext) {
const firstLine = wikitext.split('\n').find(l => l.includes("'''"));
if (!firstLine) return null;
const boldMatch = firstLine.match(/'''(.+?)'''/);
if (!boldMatch) return null;
return boldMatch[1].replace(/\s*([“"«《„‹「『'].+?[”"»》”›」』']|\([^)\n]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
}
async function init() {
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") return;
const api = new mw.Api();
const qid = mw.config.get("wgWikibaseItemId");
try {
const [pageData, backlinkData] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
api.get({
action: 'query',
list: 'backlinks',
blfilterredir: 'nonredirects',
bllimit: 50,
blnamespace: 0,
bltitle: mw.config.get("wgPageName"),
formatversion: 2
})
]);
const page = pageData.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const currentTitle = mw.config.get("wgTitle");
const cleanTitle = stripDiacritics(currentTitle);
const isOrphanTagged = /\{\{\s*(Orphan|Do-attempt|Lonely|Orp|Orphaned article)/i.test(wikitext) || /\| *orphan *=/i.test(wikitext);
const backLinkCount = backlinkData.query.backlinks.length;
if (isOrphanTagged && backLinkCount >= 1) {
const $container = $('<div>').addClass('comrade-container comrade-status');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text('Remove orphan tag').on('click', () => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) await checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
await checkDomainRedirect(qid, currentTitle);
if (qid) {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
});
const wikiData = await fetch(url).then(r => r.json());
const entity = wikiData.entities[qid];
const isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const nameParts = tClean.split(' ');
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
const isOrphan = isOrphanTagged && backLinkCount < 1;
const primarySurname = hasFamilyNameHatnote ? nameParts[0] : (getSmartSortKey(tClean, entity).split(',')[0] || nameParts[nameParts.length - 1]);
let targetSortName = "";
if (isHuman) {
const {
givens,
surnames
} = await fetchWikidataNameLabels(entity);
const refName = entity.labels?.en?.value || currentTitle;
if (dsMatch) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixNameCasing(lastPart, refName)}, ${fixNameCasing(firstPart, refName)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field.");
} else if (givens.length > 0 && lastPart.split(' ').length > 1) {
const misplacedGiven = lastPart.split(' ').find(part => givens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastPart.split(' ').filter(p => p !== misplacedGiven).join(' ');
targetSortName = `${fixNameCasing(newSurname, refName)}, ${fixNameCasing(misplacedGiven + ' ' + firstPart, refName)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
}
}
} else {
targetSortName = hasFamilyNameHatnote ? `${nameParts[0]}, ${nameParts.slice(1).join(' ')}` : getSmartSortKey(tClean, entity);
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).on('click', () => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
const longName = getLongNameFromWikitext(wikitext);
if (longName && longName.length > tClean.length) {
let sortKey = hasFamilyNameHatnote ? `${longName.split(' ')[0]}, ${longName.split(' ').slice(1).join(' ')}` : getSmartSortKey(longName, entity);
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
await checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
if (targetSortName) {
let targetPage = currentTitle.includes(' (') ? currentTitle.split(' (')[0] : currentTitle;
let rCat = targetPage !== currentTitle ? "R from ambiguous sort name" : "R from sort name";
if (targetSortName.includes(',')) {
const p = targetSortName.split(',');
rCat += `|${p[0].trim().charAt(0).toUpperCase()}|${p[1].trim().charAt(0).toUpperCase()}`;
}
await checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name");
}
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
surnames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
givens.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) finalGivenNames.add(gn);
});
if (finalFamilyNames.size === 0 && nameParts.length >= 2) finalFamilyNames.add(nameParts[nameParts.length - 1]);
if (finalGivenNames.size === 0 && nameParts.length >= 2) finalGivenNames.add(nameParts[0]);
const getPriorityTarget = async (name, type) => {
const titles = type === 'surname' ? [`List of people with surname ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`] : [`List of people with given name ${name}`, `${name} (given name)`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
return titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
};
for (const name of finalGivenNames) {
if (name.toLowerCase() === primarySurname.toLowerCase()) continue;
const target = await getPriorityTarget(name, 'given');
if (target) await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
for (const name of finalFamilyNames) {
const titles = [name, `${name} (surname)`, `List of people with surname ${name}`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
const target = titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
if (target) await checkNameList(name, 'surname', currentTitle, hasShortDesc, isOrphan, target);
}
}
}
} catch (err) {
console.error("Comrade error:", err);
}
}
init();
})();
(function() {
if (window.IllWill) return;
const STRIKEOUT = true;
const HIDE = 'hide';
const IllWill = {
Version: "1.1.1",
Attribution: 'created by <a target="_blank" href="/wiki/User:Cobaltcigs">cobaltcigs</a>',
Summary: "Added interlanguage links using IllWill.js",
DataApi: "https://www.wikidata.org/w/api.php",
LocalLang: mw.config.get("wgPageContentLanguage"),
Disambiguator: / \(.*\)$/,
SiteLinks: {},
Config: {
MaxLanguages: 5,
IgnoreScientific: STRIKEOUT,
AutoDiff: true
},
css: `
#illwill { clear: both; margin-bottom: 1.5em; border: 1px solid #a2a9b1; padding: 15px; background: #f8f9fa; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.ill-sitelinks a { display: inline-block; margin-bottom: 3px; font-size: 0.9em; color: #36c; }
.ill-size-tag { font-size: 0.8em; color: #72777d; margin-left: 6px; white-space: nowrap; }
.illwill-ignore { text-decoration: line-through; color: #72777d; font-size: 0.85em; }
.ill-x { width: 35px; text-align: center; }
.ill-input, .ill-output { font-family: 'Courier New', monospace; white-space: pre-wrap; font-size: 0.9em; background: #fff; border: 1px solid #eaecf0; padding: 4px; border-radius: 2px; }
#ill-buttons { text-align: right; padding: 10px; border-top: 1px solid #a2a9b1; margin-top: 10px; }
#ill-buttons button { margin-left: 10px; cursor: pointer; padding: 6px 16px; border-radius: 2px; border: 1px solid #a2a9b1; transition: all 0.2s; }
#ill-ok { background: #36c; color: #fff; border-color: #36c !important; font-weight: bold; }
#ill-ok:hover { background: #447ff5; }
#ill-ok:disabled { background: #eaecf0; color: #72777d; border-color: #c8ccd1 !important; cursor: not-allowed; opacity: 0.7; }
#ill-table { width: 100%; border-collapse: collapse; margin-top: 10px; background: white; }
#ill-table th { background: #f2f2f2; text-align: left; padding: 8px; border: 1px solid #a2a9b1; }
#ill-table td { padding: 8px; border: 1px solid #a2a9b1; vertical-align: top; }
#ill-table caption { background: #eaffea; font-weight: bold; padding: 10px; border: 1px solid #a2a9b1; border-bottom: none; font-size: 1.1em; }
#ill-help { font-size: 0.85em; color: #54595d; padding: 10px; line-height: 1.4; }
.ill-loading-inline { font-style: italic; color: #72777d; font-size: 0.9em; display: block; margin: 5px 0; }
`,
setup() {
if (!["edit", "submit"].includes(mw.config.get('wgAction'))) return;
mw.loader.addStyleTag(this.css);
const link = mw.util.addPortletLink('p-cactions', '#', 'IllWill', 'ca-illwill', "Add interlanguage links via Wikidata");
if (link) {
link.addEventListener('click', (e) => {
e.preventDefault();
this.makePanel();
});
}
},
norm(s) {
if (!s) return "";
const t = s.trim().replace(/[\s_]+/g, " ");
return t.charAt(0).toUpperCase() + t.slice(1);
},
async getRedLinks($textbox) {
const txt = $textbox.textSelection('getContents') || "";
const wl = txt.match(/\[\[[^[\]\n]+\]\]/g);
if (!wl) return {
reds: [],
txt
};
const api = new mw.Api();
const textToParse = wl.join("").replace(/\|[^\]]+/g, "");
try {
const data = await api.post({
action: 'parse',
text: textToParse,
contentmodel: 'wikitext',
formatversion: 2
});
const tempDiv = document.createElement('div');
tempDiv.innerHTML = data.parse.text;
const reds = Array.from(tempDiv.querySelectorAll("a.new")).map(x => {
const urlParams = new URLSearchParams(x.href.split('?')[1]);
return urlParams.get('title')?.replace(/_/g, " ");
}).filter(Boolean);
return {
reds: [...new Set(reds)],
txt
};
} catch (e) {
return {
reds: [],
txt
};
}
},
async makePanel() {
const $editform = $('#editform');
const $textbox = $('#wpTextbox1');
$('#illwill').remove();
$editform.hide();
const $wrapper = $('<div id="illwill">Loading redlinks...</div>').insertBefore($editform);
const {
reds,
txt
} = await this.getRedLinks($textbox);
const wtLinks = [];
reds.forEach(title => {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s/g, "[\\s_]+");
const re = new RegExp("\\[\\[\\s*" + escaped + "\\s*(?:\\|[^\\[\\]]+)?\\]\\]", "ig");
const match = txt.match(re);
if (match && !wtLinks.some(z => z.title === title)) {
wtLinks.push({
link: match[0],
title
});
}
});
if (!wtLinks.length) {
const $back = $('<button>Quit</button>').on('click', () => {
$wrapper.remove();
$editform.show();
});
$wrapper.empty().append($('<strong>No valid redlinks found in text.</strong> '), $back);
return;
}
const $table = $(`
<table id="ill-table" class="wikitable">
<caption>IllWill.js (v${this.Version})</caption>
<thead>
<tr>
<th class="ill-x"><input type="checkbox" id="ill-select-all" disabled></th>
<th>Input link</th>
<th>Possible Wikidata matches</th>
<th>Sitelinks (top ${this.Config.MaxLanguages} by size)</th>
<th>Resulting wikitext</th>
</tr>
</thead>
<tbody></tbody>
<tfoot>
<tr>
<td colspan="3" id="ill-help">Carefully examine possible Wikidata matches.</td>
<td colspan="2" id="ill-buttons">
<button id="ill-cancel">Cancel</button>
<button id="ill-ok" disabled>Show changes</button>
</td>
</tr>
</tfoot>
</table>
`);
const $tbody = $table.find('tbody');
wtLinks.forEach((item, i) => {
$tbody.append(`
<tr id="ill-row-${i}" data-index="${i}">
<td class="ill-x"><input type="checkbox" class="ill-row-check" disabled></td>
<td class="ill-input">${this.escapeHTML(item.link)}</td>
<td class="ill-wd-item"><span class="ill-loading-inline">Searching...</span></td>
<td class="ill-sitelinks"></td>
<td class="ill-output"></td>
</tr>
`);
});
$wrapper.empty().append($table);
const chunkSize = 5;
for (let i = 0; i < wtLinks.length; i += chunkSize) {
const chunk = wtLinks.slice(i, i + chunkSize);
await Promise.all(chunk.map((item, idx) => this.fetchWikidataOptimized(item.title, i + idx)));
}
$table.on('click', '#ill-cancel', () => {
$editform.show();
$wrapper.remove();
});
$table.on('click', '#ill-ok', () => this.onOkButton($editform, $wrapper, $textbox));
$table.on('change', '#ill-select-all', (e) => {
const isChecked = $(e.target).is(':checked');
$table.find('.ill-row-check:not(:disabled)').each(function() {
$(this).prop('checked', isChecked).trigger('change');
});
});
$table.on('change', '.ill-row-check', (e) => {
this.onCheckbox(e);
this.updateOkButtonState($table);
});
$table.on('change', 'input[type="radio"]', (e) => {
this.showSiteLinks(e);
});
},
async fetchWikidataOptimized(title, index) {
const query = title.replace(this.Disambiguator, "");
const url = `${this.DataApi}?action=wbsearchentities&search=${encodeURIComponent(query)}&language=${this.LocalLang}&limit=5&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
const results = data.search;
const $cell = $(`#ill-row-${index} .ill-wd-item`);
if (!results || !results.length) {
$cell.html('<span class="ill-na">(no results)</span>');
return;
}
const entityIds = results.map(r => r.id);
await this.fetchEntities(entityIds, index, $cell);
} catch (e) {
$(`#ill-row-${index} .ill-wd-item`).text("Search failed.");
}
},
async fetchEntities(ids, rowIndex, $cell) {
const url = `${this.DataApi}?action=wbgetentities&ids=${ids.join('|')}&props=labels|descriptions|claims|sitelinks&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
$cell.empty();
ids.forEach(qNum => {
const entity = data.entities[qNum];
if (!entity) return;
const isScientific = entity.claims?.P31?.some(s => s.mainsnak?.datavalue?.value?.id === "Q13442814");
if (isScientific && this.Config.IgnoreScientific === HIDE) return;
const desc = entity.descriptions?.[this.LocalLang]?.value || entity.descriptions?.en?.value || "";
const label = entity.labels?.[this.LocalLang]?.value || entity.labels?.en?.value || qNum;
const isStrikeout = isScientific && this.Config.IgnoreScientific === STRIKEOUT;
const $div = $(`
<div class="${isStrikeout ? 'illwill-ignore' : ''}">
<input type="radio" name="selection-${rowIndex}" value="${qNum}" id="radio-${qNum}" ${isStrikeout ? 'disabled' : ''}>
<label for="radio-${qNum}">
<a href="https://wikidata.org/wiki/${qNum}" target="_blank">${this.escapeHTML(label)}</a>:
<span class="ill-desc">${desc ? this.escapeHTML(desc) : '<i>no description</i>'}</span>
</label>
</div>
`);
$cell.append($div);
this.SiteLinks[qNum] = entity.sitelinks || {};
});
} catch (e) {
$cell.text("Detail fetch failed.");
}
},
updateOkButtonState($table) {
const $rows = $table.find('.ill-row-check:not(:disabled)');
const $checkedRows = $rows.filter(':checked');
const anyEnabled = $rows.length > 0;
$table.find('#ill-ok').prop('disabled', $checkedRows.length === 0);
const $master = $table.find('#ill-select-all');
$master.prop('disabled', !anyEnabled);
if (anyEnabled && $rows.length === $checkedRows.length) {
$master.prop('checked', true);
} else {
$master.prop('checked', false);
}
},
async showSiteLinks(e) {
const isManualToggling = e.fromCheckbox || false;
const qNum = e.target.value;
const $row = $(e.target).closest('tr');
const sitelinks = this.SiteLinks[qNum];
const $checkbox = $row.find('.ill-row-check');
const $sitelinkCell = $row.find('.ill-sitelinks');
const $outputCell = $row.find('.ill-output');
const $table = $('#ill-table');
const effectiveMax = Math.min(Math.max(this.Config.MaxLanguages, 3), 6);
const validKeys = Object.keys(sitelinks).filter(k => k.endsWith('wiki') && !k.includes('commons'));
$sitelinkCell.html('<span class="ill-loading-inline">Measuring articles...</span>');
$outputCell.empty();
if (!isManualToggling) {
$checkbox.prop('disabled', true).prop('checked', false);
}
const candidates = validKeys.slice(0, 25);
const sizeMap = [];
await Promise.all(candidates.map(async (key) => {
const lang = key.replace('wiki', '').replace(/_/g, '-');
const title = sitelinks[key].title;
const endpoint = `https://${lang}.wikipedia.org/w/api.php?action=query&titles=${encodeURIComponent(title)}&prop=info&format=json&origin=*`;
try {
const res = await fetch(endpoint).then(r => r.json());
const page = Object.values(res.query.pages)[0];
sizeMap.push({
lang,
title,
length: page.length || 0
});
} catch (err) {
sizeMap.push({
lang,
title,
length: 0
});
}
}));
sizeMap.sort((a, b) => b.length - a.length);
const topLinks = sizeMap.slice(0, effectiveMax);
$sitelinkCell.html(topLinks.map(item => {
const kbValue = Math.round(item.length / 1024);
return `<div><a href="https://${item.lang}.wikipedia.org/wiki/${encodeURIComponent(item.title)}" target="_blank">${item.lang}:${this.escapeHTML(item.title)}</a> <span class="ill-size-tag">(${kbValue} kB)</span></div>`;
}).join(""));
const inputWikitext = $row.find('.ill-input').text();
const match = inputWikitext.match(/\[\[(.*?)\]\]/);
if (match) {
const parts = match[1].split('|');
const targetPage = parts[0].trim();
const surfaceName = (parts[1] || parts[0]).trim();
const illLangs = topLinks.map(item => {
const isSameTitle = this.norm(item.title) === this.norm(targetPage);
return `|${item.lang}|${isSameTitle ? '' : item.title}`;
}).join("");
const ltParam = (this.norm(surfaceName) === this.norm(targetPage)) ? "" : `|lt=${surfaceName}`;
$outputCell.text(`{{ill|${targetPage}${ltParam}${illLangs}}}`);
}
$checkbox.prop('disabled', !topLinks.length);
if (!isManualToggling) {
$checkbox.prop('checked', !!topLinks.length);
}
this.updateOkButtonState($table);
},
onCheckbox(e) {
const $row = $(e.target).closest('tr');
const $radio = $row.find('input[type="radio"]:checked');
const isChecked = $(e.target).is(':checked');
if (isChecked) {
if ($radio.length) {
this.showSiteLinks({
target: $radio[0],
fromCheckbox: true
});
}
} else {
$row.find('.ill-sitelinks, .ill-output').empty();
}
},
onOkButton($editform, $wrapper, $textbox) {
let text = $textbox.textSelection('getContents');
let changed = false;
$wrapper.find('.ill-row-check:checked').each(function() {
const $row = $(this).closest('tr');
const input = $row.find('.ill-input').text();
const output = $row.find('.ill-output').text();
if (output) {
const escapedInput = input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
text = text.replace(new RegExp(escapedInput, "gi"), output);
changed = true;
}
});
if (changed) {
$('#wpSummary').val((i, v) => (v ? v + "; " : "") + this.Summary);
$textbox.textSelection('setContents', text);
$wrapper.remove();
$editform.show();
if (this.Config.AutoDiff) $('#wpDiff').click();
}
},
escapeHTML(str) {
return str.replace(/[&<>"']/g, m => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
})[m]);
}
};
window.IllWill = IllWill;
mw.loader.using(['mediawiki.util', 'mediawiki.api', 'jquery.textSelection'], () => {
$(() => IllWill.setup());
});
})();
(function() {
'use strict';
const config = {
enableNS14: true,
rootWhitelists: ['Materials'],
userWhitelist: ""
};
const categoryDataCache = {};
let scanProgress = 0;
let totalToScan = 0;
async function verifyTreeMembership() {
if (!config.enableNS14) return false;
const api = new mw.Api();
const currentTitle = mw.config.get("wgPageName").replace(/_/g, ' ');
const fullRoots = config.rootWhitelists.concat(config.userWhitelist.split(',').map(s => s.trim()).filter(s => s)).map(cat => (cat.startsWith('Category:') ? cat : `Category:${cat}`));
if (fullRoots.includes(currentTitle)) return true;
let categoriesToTable = mw.config.get('wgCategories') || [];
let checked = new Set();
while (categoriesToTable.length > 0) {
if (categoriesToTable.some(cat => fullRoots.includes('Category:' + cat))) {
return true;
}
categoriesToTable.forEach(cat => checked.add(cat));
try {
const res = await api.get({
formatversion: 2,
prop: 'categories',
titles: categoriesToTable.map(c => 'Category:' + c),
cllimit: 'max'
});
let nextBatch = [];
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.categories) {
page.categories.forEach(parent => {
const parentName = parent.title.substring('Category:'.length);
if (!checked.has(parentName)) {
nextBatch.push(parentName);
}
});
}
});
}
categoriesToTable = [...new Set(nextBatch)];
} catch (e) {
return false;
}
}
return false;
}
function getChemboxBlock(wikitext) {
const chemboxRegex = /\{\{\s*Chembox[\s_]*(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const match = wikitext.match(chemboxRegex);
return match ? match[0] : null;
}
function getFormulaFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const formulaMatch = chembox.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
let rawFormula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
rawFormula = rawFormula.split(/\s+/)[0];
return rawFormula.split(',')[0].trim();
}
const propRegex = /\{\{\s*Chembox[\s_]+Properties(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const propSection = chembox.match(propRegex);
if (propSection) {
const content = propSection[0];
const allElements = ['Ac', 'Ag', 'Al', 'Am', 'Ar', 'As', 'At', 'Au', 'B', 'Ba', 'Be', 'Bh', 'Bi', 'Bk', 'Br', 'C', 'Ca', 'Cd', 'Ce', 'Cf', 'Cl', 'Cm', 'Cn', 'Co', 'Cr', 'Cs', 'Cu', 'D', 'Db', 'Ds', 'Dy', 'Er', 'Es', 'Eu', 'F', 'Fe', 'Fl', 'Fm', 'Fr', 'Ga', 'Gd', 'Ge', 'H', 'He', 'Hf', 'Hg', 'Ho', 'Hs', 'I', 'In', 'Ir', 'K', 'Kr', 'La', 'Li', 'Lr', 'Lu', 'Lv', 'Mc', 'Md', 'Mg', 'Mn', 'Mo', 'Mt', 'N', 'Na', 'Nb', 'Nd', 'Ne', 'Nh', 'Ni', 'No', 'Np', 'O', 'Og', 'Os', 'P', 'Pa', 'Pb', 'Pd', 'Pm', 'Po', 'Pr', 'Pt', 'Pu', 'Ra', 'Rb', 'Re', 'Rf', 'Rg', 'Rh', 'Rn', 'Ru', 'S', 'Sb', 'Sc', 'Se', 'Sg', 'Si', 'Sm', 'Sn', 'Sr', 'Ta', 'Tb', 'Tc', 'Te', 'Th', 'Ti', 'Tl', 'Tm', 'Ts', 'U', 'V', 'W', 'Xe', 'Y', 'Yb', 'Zn', 'Zr'];
let data = {};
allElements.forEach(el => {
const elRegex = new RegExp(`\\|\\s*${el}\\s*=\\s*(\\d*\\.?\\d+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch && elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
});
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
elementsPresent.filter(el => !(hasC && (el === "C" || el === "H"))).sort().forEach(el => {
formulaArray.push(el + data[el]);
});
return formulaArray.join('');
}
}
return null;
}
function getCASFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const idRegex = /\{\{\s*Chembox[\s_]+Identifiers(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const idSection = chembox.match(idRegex);
if (!idSection) return null;
const content = idSection[0];
const nbsp = String.fromCharCode(160);
const casMatch = content.match(/\|\s*CASNo\d*\b(?!\s*_)\s*=\s*([^|}\n]+)/i);
if (casMatch) {
let val = casMatch[1].replace(/\{\{[^}]+\}\}/g, "").replace(/\u00AD/g, "").split(" ").join("");
while (val.indexOf(nbsp) !== -1) val = val.replace(nbsp, "");
while (val.indexOf(" ") !== -1) val = val.replace(" ", "");
const cleanedCas = val.trim();
return /^\d{2,7}-\d{2}-\d$/.test(cleanedCas) ? cleanedCas : null;
}
return null;
}
async function getWikidataValue() {
const qid = mw.config.get('wgWikidataItemId');
if (!qid) return null;
const api = new mw.ForeignApi('https://www.wikidata.org/w/api.php');
const res = await api.get({
action: 'wbgetentities',
ids: qid,
props: 'claims',
format: 'json',
formatversion: 2
});
const claims = res.entities[qid].claims;
const data = {};
if (claims.P274) data.formula = claims.P274[0].mainsnak.datavalue.value;
if (claims.P231) data.cas = claims.P231[0].mainsnak.datavalue.value;
return data;
}
async function startBackgroundCategoryScan() {
const api = new mw.Api();
const titles = $('#mw-pages li a').map((i, el) => $(el).attr('title') || $(el).text()).get();
totalToScan = titles.length;
if (totalToScan === 0) return;
const chunks = arrayChunk(titles, 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: chunk,
rvprop: 'content',
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const f = getFormulaFromWikitext(wikitext);
const c = getCASFromWikitext(wikitext);
if (f || c) categoryDataCache[page.title] = {
formula: f,
cas: c
};
});
}
scanProgress += chunk.length;
$('#caschemassist-cat-btn').text(`Scanning... ${scanProgress}/${totalToScan}`);
}
$('#caschemassist-cat-btn').text('Show missing CAS/Chem').prop('disabled', false);
}
async function renderCategoryAudit() {
const api = new mw.Api();
const missingCheckTitles = [];
Object.values(categoryDataCache).forEach(data => {
if (data.formula) missingCheckTitles.push(data.formula);
if (data.cas) missingCheckTitles.push(data.cas);
});
if (missingCheckTitles.length === 0) {
mw.notify("No valid Chembox data found in this category.");
return;
}
const existenceMap = {};
const chunks = arrayChunk([...new Set(missingCheckTitles)], 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
titles: chunk,
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(p => {
existenceMap[p.title] = !p.missing;
});
}
}
$('#mw-pages li').each(function() {
const title = $(this).find('a').attr('title') || $(this).find('a').text().trim();
const data = categoryDataCache[title];
if (!data) return;
if (data.formula && !existenceMap[data.formula]) {
$(this).append($('<span>').css({
'color': 'red',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [${data.formula}]`));
}
if (data.cas && !existenceMap[data.cas]) {
$(this).append($('<span>').css({
'color': 'darkred',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [CAS: ${data.cas}]`));
}
});
$('#caschemassist-cat-btn').text('Audit complete').prop('disabled', true);
}
async function handleSingleArticle() {
const api = new mw.Api();
const currentTitle = mw.config.get("wgTitle");
const [wikiRes, wikidata] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
getWikidataValue()
]);
const page = wikiRes.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const formula = getFormulaFromWikitext(wikitext);
const cas = getCASFromWikitext(wikitext);
if (wikidata) {
let mismatches = [];
if (formula && wikidata.formula && formula !== wikidata.formula) {
mismatches.push(`Formula mismatch: Chembox "${formula}" vs Wikidata "${wikidata.formula}"`);
}
if (cas && wikidata.cas && cas !== wikidata.cas) {
mismatches.push(`CAS mismatch: Chembox "${cas}" vs Wikidata "${wikidata.cas}"`);
}
if (mismatches.length > 0) {
const $warning = $('<div>').css({
'background': '#fff2f2',
'border': '2px solid #d33',
'padding': '10px',
'margin-bottom': '1em',
'color': '#b32424'
}).append($('<strong>').text('⚠️ Data Mismatch Detected: '), mismatches.join(' | '));
$('#mw-content-text').prepend($warning);
}
}
async function checkAndRender(target, label, customWikitext) {
const check = await api.get({
action: 'query',
titles: target,
formatversion: 2
});
if (check.query.pages[0].missing) {
const $container = $('<div>').css({
'background': '#f8f9fa',
'border': '1px solid #a2a9b1',
'padding': '10px',
'margin-bottom': '1em'
});
const $btn = $('<button>').text(`Create redirect: ${target}`).click(async function() {
await api.postWithEditToken({
action: 'edit',
title: target,
text: customWikitext,
summary: `Creating redirect to [[${currentTitle}]] (${label})`,
createonly: true
});
mw.notify(`Created redirect: ${target}`);
$container.fadeOut(400, function() {
$(this).remove();
});
});
$container.append($('<strong>').text(`${label}: `), `Missing redirect. `, $btn);
$('#mw-content-text').prepend($container);
}
}
if (formula && formula !== currentTitle) {
const formulaWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from chemical formula}}\n}}`;
await checkAndRender(formula, "Chemical formula", formulaWikitext);
}
if (cas && cas !== currentTitle) {
const casWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from CAS Registry Number}}\n{{R unprintworthy}}\n}}`;
await checkAndRender(cas, "CAS number", casWikitext);
}
}
function arrayChunk(arr, size) {
const result = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
$(document).ready(async function() {
const ns = mw.config.get("wgNamespaceNumber");
if (ns === 14) {
if (await verifyTreeMembership()) {
const $catContainer = $('#mw-pages');
if ($catContainer.length) {
const $btn = $('<button>').text('Initializing scan...').attr('id', 'caschemassist-cat-btn').addClass('mw-ui-button').css({
'margin': '0 10px 10px 0',
'float': 'right',
'position': 'relative',
'z-index': '100'
}).prop('disabled', true).click(renderCategoryAudit);
$catContainer.prepend($btn);
startBackgroundCategoryScan();
}
}
} else if (ns === 0 && mw.config.get("wgAction") === "view") {
handleSingleArticle();
}
});
})();
//</nowiki>
ezpz0sq4loq9566kpdlvgdxgnf92akh
739284
739281
2026-04-24T18:58:34Z
Sam Sailor
26820
Test
739284
javascript
text/javascript
//<nowiki>
(function() {
mw.util.addCSS(`
.ambox-Orphan { display: block !important; }
.comrade-container {
box-shadow: 0 1px 3px rgba(0,0,0,0.1); border-radius: 2px;
padding: 10px 15px; margin: 10px 0; display: flex;
flex-direction: column; align-items: flex-start; gap: 8px;
border: 1px solid #a2a9b1; background: #fcfcfc; line-height: 1.5;
}
.comrade-header { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.comrade-content { display: flex; flex-direction: column; gap: 5px; width: 100%; }
.comrade-success { background-color: #d5f5e3 !important; border-left: 5px solid #27ae60 !important; color: #1b5e20 !important; }
.comrade-nudge { background: #fff9ea; border-left: 5px solid #f0ad4e; }
.comrade-info { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-status { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-container button:not(.comrade-dismiss) {
background: #f8f9fa; border: 1px solid #a2a9b1; border-radius: 2px;
padding: 2px 10px; cursor: pointer; font-weight: bold; font-size: 0.95em; margin-left: 10px;
}
.comrade-container button:not(.comrade-dismiss):hover { border-color: #36c; color: #36c; background: #fff; }
.comrade-dismiss {
background: transparent; border: none; color: #d33; font-weight: bold;
cursor: pointer; font-size: 0.9em;
}
.comrade-dismiss:hover { text-decoration: underline; }
.comrade-code-block { background: #f1f1f1; padding: 4px 8px; border-radius: 3px; font-family: monospace; display: inline-block; margin-top: 4px; }
`);
function appendDismiss($el) {
$('<button>').addClass('comrade-dismiss').text('Dismiss').on('click', function() {
$(this).closest('.comrade-container').fadeOut();
}).appendTo($el.find('.comrade-header'));
$("#siteSub").after($el);
}
async function checkAndRenderRedirect(redirTitle, targetTitle, rCategory, labelText, customWikitext) {
const api = new mw.Api();
const res = await api.get({
action: 'query',
titles: redirTitle,
formatversion: 2
});
if (res.query.pages[0].missing) {
const safeId = "comrade-redir-" + redirTitle.replace(/[^a-z0-9]/gi, '-');
const $container = $('<div>').addClass('comrade-container comrade-info').attr('id', safeId);
const $header = $('<div>').addClass('comrade-header');
const summary = `Created redirect from ${labelText.toLowerCase()} to [[${targetTitle}]]`;
const $btn = $('<button>').text('Create redirect').on('click', () => createModernRedirect(redirTitle, targetTitle, rCategory, summary, safeId, customWikitext));
$header.append($('<div>').append($('<strong>').text(`${labelText}: `), `Create redirect from `, $('<code>').text(redirTitle), $btn));
$container.append($header);
appendDismiss($container);
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
if (qid) {
try {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
});
const res = await fetch(url).then(r => r.json());
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
}
} catch (e) {
console.warn("Comrade: Wikidata API call failed.");
}
}
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
if (domain.includes(".") && url.pathname === "/") {
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
try {
const response = await fetch(citationURL);
if (response.ok) {
await checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
}
} catch (e) {
console.warn("Comrade: Citation API check failed.");
}
}
} catch (e) {
console.warn("Comrade: Error parsing URL object.");
}
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan, resolvedTitle) {
const api = new mw.Api();
const candidates = resolvedTitle ? [resolvedTitle] : [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
redirects: 0
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const foundTitle = page.title;
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) break;
const $container = $('<div>').addClass('comrade-container');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (isOrphan) {
$container.addClass('comrade-nudge');
$header.append($('<strong>').text('Comrade nudge:'));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
}
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(foundTitle),
target: '_blank'
}).text(foundTitle), "; should it be?"));
const snippet = '* {{anbl|' + currentTitle + '}}';
const $snippetWrapper = $('<div>').css({
'display': 'flex',
'align-items': 'center',
'gap': '10px',
'margin-top': '4px'
});
const $instructionSpan = $('<span>').append('If the Short description is good, consider adding ', $('<code>').text(snippet).css({
'background-color': '#f8f9fa',
'border': '1px solid #eaecf0',
'border-radius': '2px',
'padding': '1px 4px'
}));
const $copyBtn = $('<button>').text('Copy & insert').css('margin-left', '0').on('click', function() {
navigator.clipboard.writeText(snippet).then(() => {
const $this = $(this);
const originalText = $this.text();
$this.text('Copied!').prop('disabled', true);
const editUrl = mw.util.getUrl(foundTitle, {
action: 'edit',
summary: snippet
});
window.open(editUrl, '_blank');
setTimeout(() => {
$this.text(originalText).prop('disabled', false);
}, 1500);
});
});
$snippetWrapper.append($instructionSpan, $copyBtn);
$content.append($snippetWrapper);
if (!hasShortDesc) {
$content.append($('<span>').css({
'color': '#d33',
'font-weight': 'bold',
'margin-top': '5px'
}).text('⚠️ Missing short description: Please add a concise one before listing.'));
}
$container.append($header, $content);
appendDismiss($container);
break;
}
}
}
}
async function createModernRedirect(redirTitle, targetTitle, rCategory, customSummary, elementId, customWikitext) {
const api = new mw.Api();
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
try {
await api.postWithEditToken({
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
});
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
} catch (e) {
mw.notify("Failed to create redirect.", {
type: 'error'
});
}
}
function getSmartSortKey(fullName, entity) {
const countryIds = entity.claims?.P27?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const locationIds = entity.claims?.P17?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const placeIds = [...(entity.claims?.P19 || []), ...(entity.claims?.P119 || [])].map(c => c.mainsnak?.datavalue?.value?.id).filter(Boolean);
const allRelevantIds = [...countryIds, ...locationIds, ...placeIds];
const birthYear = parseInt(entity.claims?.P569?.[0]?.mainsnak?.datavalue?.value?.time?.match(/[+-](\d{4})/)?.[1]) || null;
if (fullName.startsWith("Saint ")) return fullName.replace("Saint ", "");
const arabicParticles = /\b(Abu|Abd|Abdel|Abdul|ben|bin|bint)\b/i;
if (fullName.match(arabicParticles)) {
if (birthYear && birthYear > 1900) {
const parts = fullName.split(' ');
const binIndex = parts.findIndex(p => p.toLowerCase() === 'bin' || p.toLowerCase() === 'ben');
if (binIndex > 0) return parts.slice(binIndex).join(' ') + ', ' + parts.slice(0, binIndex).join(' ');
} else {
return fullName;
}
}
const eastAsianQids = ["Q148", "Q184", "Q884", "Q424", "Q881", "Q17"];
if (allRelevantIds.some(id => eastAsianQids.includes(id))) {
if (allRelevantIds.includes("Q17") && birthYear && birthYear > 1885) return westernSort(fullName);
const parts = fullName.split(' ');
if (parts.length > 1) return `${parts[0]}, ${parts.slice(1).join(' ')}`;
}
return westernSort(fullName);
}
function westernSort(name) {
let cleanName = name;
if (name.startsWith("O'")) cleanName = name.replace("O'", "O");
const parts = cleanName.split(' ');
if (parts.length < 2) return cleanName;
const surname = parts.pop();
if (["Jr.", "Jr", "III", "II", "Sr.", "Sr"].includes(surname)) {
const actualSurname = parts.pop();
return `${actualSurname}, ${parts.join(' ')} ${surname}`;
}
return `${surname}, ${parts.join(' ')}`;
}
async function performDeorphan() {
const api = new mw.Api();
try {
await api.edit(mw.config.get('wgPageName'), function(rev) {
let text = rev.content;
const summary = 'Article has backlinks; removed [[Template:Orphan|{{Orphan}}]]';
text = text.replace(/\{\{Orphan\s*(?:\|[^}]*)?\}\}\s*/gi, '');
text = text.replace(/(\{\{Multiple[ _]issues\s*)\|\s*\|/gi, '$1|');
text = replaceMI(text);
text = text.replace(/\n{3,}/g, '\n\n').trim();
return {
text,
summary,
minor: true
};
});
mw.notify('Orphan tag removed!', {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
setTimeout(() => location.reload(), 700);
} catch (e) {
mw.notify('Failed to remove orphan tag.', {
type: 'error'
});
}
}
function replaceMI(text) {
const miRegex = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miRegex.exec(text)) !== null) {
const start = match.index;
result += text.slice(lastIndex, start);
let depth = 0;
let i = start;
while (i < text.length) {
if (text[i] === '{' && text[i + 1] === '{') {
depth++;
i += 2;
} else if (text[i] === '}' && text[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
const end = i;
const full = text.slice(start, end);
const pipeIdx = full.indexOf('|');
const inner = full.slice(pipeIdx + 1, full.length - 2).trim();
const topLevel = extractTopLevelTemplates(inner);
if (topLevel.length === 0) {
result += '';
} else if (topLevel.length === 1) {
result += topLevel[0].trim();
} else {
result += full;
}
lastIndex = end;
}
result += text.slice(lastIndex);
return result;
}
function extractTopLevelTemplates(inner) {
const templates = [];
let i = 0;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
let depth = 0;
const start = i;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
depth++;
i += 2;
} else if (inner[i] === '}' && inner[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
templates.push(inner.slice(start, i));
} else {
i++;
}
}
return templates;
}
async function performSortCorrection(newName, type) {
const api = new mw.Api();
const title = mw.config.get("wgPageName");
try {
const data = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2
});
let content = data.query.pages[0].revisions[0].content;
const dsRegex = /\{\{(?:DEFAULTSORT|Defaultsort):[^}]+\}\}/i;
const newTag = `{{DEFAULTSORT:${newName}}}`;
let actionDesc = type === "add" ? "added" : "corrected";
if (dsRegex.test(content)) {
content = content.replace(dsRegex, newTag);
} else {
const catRegex = /\[\[Category:/i;
if (catRegex.test(content)) {
content = content.replace(catRegex, `${newTag}\n[[Category:`);
} else {
content += `\n\n${newTag}`;
}
}
await api.postWithEditToken({
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName}`,
nocreate: true
});
mw.notify(`DEFAULTSORT ${actionDesc} to ${newName}!`, {
title: 'Comrade Success',
type: 'success'
});
setTimeout(() => location.reload(), 700);
} catch (err) {
mw.notify('Failed to save DEFAULTSORT.', {
type: 'error'
});
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
function renderSortNudge(target, reason) {
const $dsNudge = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Correct to: ${target}`).on('click', () => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
async function fetchWikidataNameLabels(entity) {
const familyIds = entity.claims?.P734?.map(c => c.mainsnak.datavalue.value.id) || [];
const givenIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
const allNameIds = [...new Set([...familyIds, ...givenIds])];
if (allNameIds.length === 0) return {
givens: [],
surnames: []
};
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: allNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
});
const nameData = await fetch(url).then(r => r.json());
return {
givens: givenIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean),
surnames: familyIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean)
};
}
function fixNameCasing(part, referenceName) {
return part.split(' ').map(word => {
const match = referenceName.match(new RegExp(`\\b${word}\\b`, 'i'));
if (match) return match[0];
return referenceName.split(' ').find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase())) || word;
}).join(' ');
}
function getLongNameFromWikitext(wikitext) {
const firstLine = wikitext.split('\n').find(l => l.includes("'''"));
if (!firstLine) return null;
const boldMatch = firstLine.match(/'''(.+?)'''/);
if (!boldMatch) return null;
return boldMatch[1].replace(/\s*([“"«《„‹「『'].+?[”"»》”›」』']|\([^)\n]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
}
async function init() {
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") return;
const api = new mw.Api();
const qid = mw.config.get("wgWikibaseItemId");
try {
const [pageData, backlinkData] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
api.get({
action: 'query',
list: 'backlinks',
blfilterredir: 'nonredirects',
bllimit: 50,
blnamespace: 0,
bltitle: mw.config.get("wgPageName"),
formatversion: 2
})
]);
const page = pageData.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const currentTitle = mw.config.get("wgTitle");
const cleanTitle = stripDiacritics(currentTitle);
const isOrphanTagged = /\{\{\s*(Orphan|Do-attempt|Lonely|Orp|Orphaned article)/i.test(wikitext) || /\| *orphan *=/i.test(wikitext);
const backLinkCount = backlinkData.query.backlinks.length;
if (isOrphanTagged && backLinkCount >= 1) {
const $container = $('<div>').addClass('comrade-container comrade-status');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text('Remove orphan tag').on('click', () => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) await checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
await checkDomainRedirect(qid, currentTitle);
if (qid) {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
});
const wikiData = await fetch(url).then(r => r.json());
const entity = wikiData.entities[qid];
const isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const nameParts = tClean.split(' ');
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
const isOrphan = isOrphanTagged && backLinkCount < 1;
const primarySurname = hasFamilyNameHatnote ? nameParts[0] : (getSmartSortKey(tClean, entity).split(',')[0] || nameParts[nameParts.length - 1]);
let targetSortName = "";
if (isHuman) {
const {
givens,
surnames
} = await fetchWikidataNameLabels(entity);
const refName = entity.labels?.en?.value || currentTitle;
if (dsMatch) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixNameCasing(lastPart, refName)}, ${fixNameCasing(firstPart, refName)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field.");
} else if (givens.length > 0 && lastPart.split(' ').length > 1) {
const misplacedGiven = lastPart.split(' ').find(part => givens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastPart.split(' ').filter(p => p !== misplacedGiven).join(' ');
targetSortName = `${fixNameCasing(newSurname, refName)}, ${fixNameCasing(misplacedGiven + ' ' + firstPart, refName)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
}
}
} else {
targetSortName = hasFamilyNameHatnote ? `${nameParts[0]}, ${nameParts.slice(1).join(' ')}` : getSmartSortKey(tClean, entity);
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).on('click', () => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
const longName = getLongNameFromWikitext(wikitext);
if (longName && longName.length > tClean.length) {
let sortKey = hasFamilyNameHatnote ? `${longName.split(' ')[0]}, ${longName.split(' ').slice(1).join(' ')}` : getSmartSortKey(longName, entity);
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
await checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
if (targetSortName) {
let targetPage = currentTitle.includes(' (') ? currentTitle.split(' (')[0] : currentTitle;
let rCat = targetPage !== currentTitle ? "R from ambiguous sort name" : "R from sort name";
if (targetSortName.includes(',')) {
const p = targetSortName.split(',');
rCat += `|${p[0].trim().charAt(0).toUpperCase()}|${p[1].trim().charAt(0).toUpperCase()}`;
}
await checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name");
}
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
surnames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
givens.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) finalGivenNames.add(gn);
});
if (finalFamilyNames.size === 0 && nameParts.length >= 2) finalFamilyNames.add(nameParts[nameParts.length - 1]);
if (finalGivenNames.size === 0 && nameParts.length >= 2) finalGivenNames.add(nameParts[0]);
const getPriorityTarget = async (name, type) => {
const titles = type === 'surname' ? [`List of people with surname ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`] : [`List of people with given name ${name}`, `${name} (given name)`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
return titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
};
for (const name of finalGivenNames) {
if (name.toLowerCase() === primarySurname.toLowerCase()) continue;
const target = await getPriorityTarget(name, 'given');
if (target) await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
for (const name of finalFamilyNames) {
const titles = [name, `${name} (surname)`, `List of people with surname ${name}`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
const target = titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
if (target) await checkNameList(name, 'surname', currentTitle, hasShortDesc, isOrphan, target);
}
}
}
} catch (err) {
console.error("Comrade error:", err);
}
}
init();
})();
(function() {
if (window.IllWill) return;
const STRIKEOUT = true;
const HIDE = 'hide';
const IllWill = {
Version: "1.1.1",
Attribution: 'created by <a target="_blank" href="/wiki/User:Cobaltcigs">cobaltcigs</a>',
Summary: "Added interlanguage links using IllWill.js",
DataApi: "https://www.wikidata.org/w/api.php",
LocalLang: mw.config.get("wgPageContentLanguage"),
Disambiguator: / \(.*\)$/,
SiteLinks: {},
Config: {
MaxLanguages: 5,
IgnoreScientific: STRIKEOUT,
AutoDiff: true
},
css: `
#illwill { clear: both; margin-bottom: 1.5em; border: 1px solid #a2a9b1; padding: 15px; background: #f8f9fa; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.ill-sitelinks a { display: inline-block; margin-bottom: 3px; font-size: 0.9em; color: #36c; }
.ill-size-tag { font-size: 0.8em; color: #72777d; margin-left: 6px; white-space: nowrap; }
.illwill-ignore { text-decoration: line-through; color: #72777d; font-size: 0.85em; }
.ill-x { width: 35px; text-align: center; }
.ill-input, .ill-output { font-family: 'Courier New', monospace; white-space: pre-wrap; font-size: 0.9em; background: #fff; border: 1px solid #eaecf0; padding: 4px; border-radius: 2px; }
#ill-buttons { text-align: right; padding: 10px; border-top: 1px solid #a2a9b1; margin-top: 10px; }
#ill-buttons button { margin-left: 10px; cursor: pointer; padding: 6px 16px; border-radius: 2px; border: 1px solid #a2a9b1; transition: all 0.2s; }
#ill-ok { background: #36c; color: #fff; border-color: #36c !important; font-weight: bold; }
#ill-ok:hover { background: #447ff5; }
#ill-ok:disabled { background: #eaecf0; color: #72777d; border-color: #c8ccd1 !important; cursor: not-allowed; opacity: 0.7; }
#ill-table { width: 100%; border-collapse: collapse; margin-top: 10px; background: white; }
#ill-table th { background: #f2f2f2; text-align: left; padding: 8px; border: 1px solid #a2a9b1; }
#ill-table td { padding: 8px; border: 1px solid #a2a9b1; vertical-align: top; }
#ill-table caption { background: #eaffea; font-weight: bold; padding: 10px; border: 1px solid #a2a9b1; border-bottom: none; font-size: 1.1em; }
#ill-help { font-size: 0.85em; color: #54595d; padding: 10px; line-height: 1.4; }
.ill-loading-inline { font-style: italic; color: #72777d; font-size: 0.9em; display: block; margin: 5px 0; }
`,
setup() {
if (!["edit", "submit"].includes(mw.config.get('wgAction'))) return;
mw.loader.addStyleTag(this.css);
const link = mw.util.addPortletLink('p-cactions', '#', 'IllWill', 'ca-illwill', "Add interlanguage links via Wikidata");
if (link) {
link.addEventListener('click', (e) => {
e.preventDefault();
this.makePanel();
});
}
},
norm(s) {
if (!s) return "";
const t = s.trim().replace(/[\s_]+/g, " ");
return t.charAt(0).toUpperCase() + t.slice(1);
},
async getRedLinks($textbox) {
const txt = $textbox.textSelection('getContents') || "";
const wl = txt.match(/\[\[[^[\]\n]+\]\]/g);
if (!wl) return {
reds: [],
txt
};
const api = new mw.Api();
const textToParse = wl.join("").replace(/\|[^\]]+/g, "");
try {
const data = await api.post({
action: 'parse',
text: textToParse,
contentmodel: 'wikitext',
formatversion: 2
});
const tempDiv = document.createElement('div');
tempDiv.innerHTML = data.parse.text;
const reds = Array.from(tempDiv.querySelectorAll("a.new")).map(x => {
const urlParams = new URLSearchParams(x.href.split('?')[1]);
return urlParams.get('title')?.replace(/_/g, " ");
}).filter(Boolean);
return {
reds: [...new Set(reds)],
txt
};
} catch (e) {
return {
reds: [],
txt
};
}
},
async makePanel() {
const $editform = $('#editform');
const $textbox = $('#wpTextbox1');
$('#illwill').remove();
$editform.hide();
const $wrapper = $('<div id="illwill">Loading redlinks...</div>').insertBefore($editform);
const {
reds,
txt
} = await this.getRedLinks($textbox);
const wtLinks = [];
reds.forEach(title => {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s/g, "[\\s_]+");
const re = new RegExp("\\[\\[\\s*" + escaped + "\\s*(?:\\|[^\\[\\]]+)?\\]\\]", "ig");
const match = txt.match(re);
if (match && !wtLinks.some(z => z.title === title)) {
wtLinks.push({
link: match[0],
title
});
}
});
if (!wtLinks.length) {
const $back = $('<button>Quit</button>').on('click', () => {
$wrapper.remove();
$editform.show();
});
$wrapper.empty().append($('<strong>No valid redlinks found in text.</strong> '), $back);
return;
}
const $table = $(`
<table id="ill-table" class="wikitable">
<caption>IllWill.js (v${this.Version})</caption>
<thead>
<tr>
<th class="ill-x"><input type="checkbox" id="ill-select-all" disabled></th>
<th>Input link</th>
<th>Possible Wikidata matches</th>
<th>Sitelinks (top ${this.Config.MaxLanguages} by size)</th>
<th>Resulting wikitext</th>
</tr>
</thead>
<tbody></tbody>
<tfoot>
<tr>
<td colspan="3" id="ill-help">Carefully examine possible Wikidata matches.</td>
<td colspan="2" id="ill-buttons">
<button id="ill-cancel">Cancel</button>
<button id="ill-ok" disabled>Show changes</button>
</td>
</tr>
</tfoot>
</table>
`);
const $tbody = $table.find('tbody');
wtLinks.forEach((item, i) => {
$tbody.append(`
<tr id="ill-row-${i}" data-index="${i}">
<td class="ill-x"><input type="checkbox" class="ill-row-check" disabled></td>
<td class="ill-input">${this.escapeHTML(item.link)}</td>
<td class="ill-wd-item"><span class="ill-loading-inline">Searching...</span></td>
<td class="ill-sitelinks"></td>
<td class="ill-output"></td>
</tr>
`);
});
$wrapper.empty().append($table);
const chunkSize = 5;
for (let i = 0; i < wtLinks.length; i += chunkSize) {
const chunk = wtLinks.slice(i, i + chunkSize);
await Promise.all(chunk.map((item, idx) => this.fetchWikidataOptimized(item.title, i + idx)));
}
$table.on('click', '#ill-cancel', () => {
$editform.show();
$wrapper.remove();
});
$table.on('click', '#ill-ok', () => this.onOkButton($editform, $wrapper, $textbox));
$table.on('change', '#ill-select-all', (e) => {
const isChecked = $(e.target).is(':checked');
$table.find('.ill-row-check:not(:disabled)').each(function() {
$(this).prop('checked', isChecked).trigger('change');
});
});
$table.on('change', '.ill-row-check', (e) => {
this.onCheckbox(e);
this.updateOkButtonState($table);
});
$table.on('change', 'input[type="radio"]', (e) => {
this.showSiteLinks(e);
});
},
async fetchWikidataOptimized(title, index) {
const query = title.replace(this.Disambiguator, "");
const url = `${this.DataApi}?action=wbsearchentities&search=${encodeURIComponent(query)}&language=${this.LocalLang}&limit=5&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
const results = data.search;
const $cell = $(`#ill-row-${index} .ill-wd-item`);
if (!results || !results.length) {
$cell.html('<span class="ill-na">(no results)</span>');
return;
}
const entityIds = results.map(r => r.id);
await this.fetchEntities(entityIds, index, $cell);
} catch (e) {
$(`#ill-row-${index} .ill-wd-item`).text("Search failed.");
}
},
async fetchEntities(ids, rowIndex, $cell) {
const url = `${this.DataApi}?action=wbgetentities&ids=${ids.join('|')}&props=labels|descriptions|claims|sitelinks&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
$cell.empty();
ids.forEach(qNum => {
const entity = data.entities[qNum];
if (!entity) return;
const isScientific = entity.claims?.P31?.some(s => s.mainsnak?.datavalue?.value?.id === "Q13442814");
if (isScientific && this.Config.IgnoreScientific === HIDE) return;
const desc = entity.descriptions?.[this.LocalLang]?.value || entity.descriptions?.en?.value || "";
const label = entity.labels?.[this.LocalLang]?.value || entity.labels?.en?.value || qNum;
const isStrikeout = isScientific && this.Config.IgnoreScientific === STRIKEOUT;
const $div = $(`
<div class="${isStrikeout ? 'illwill-ignore' : ''}">
<input type="radio" name="selection-${rowIndex}" value="${qNum}" id="radio-${qNum}" ${isStrikeout ? 'disabled' : ''}>
<label for="radio-${qNum}">
<a href="https://wikidata.org/wiki/${qNum}" target="_blank">${this.escapeHTML(label)}</a>:
<span class="ill-desc">${desc ? this.escapeHTML(desc) : '<i>no description</i>'}</span>
</label>
</div>
`);
$cell.append($div);
this.SiteLinks[qNum] = entity.sitelinks || {};
});
} catch (e) {
$cell.text("Detail fetch failed.");
}
},
updateOkButtonState($table) {
const $rows = $table.find('.ill-row-check:not(:disabled)');
const $checkedRows = $rows.filter(':checked');
const anyEnabled = $rows.length > 0;
$table.find('#ill-ok').prop('disabled', $checkedRows.length === 0);
const $master = $table.find('#ill-select-all');
$master.prop('disabled', !anyEnabled);
if (anyEnabled && $rows.length === $checkedRows.length) {
$master.prop('checked', true);
} else {
$master.prop('checked', false);
}
},
async showSiteLinks(e) {
const isManualToggling = e.fromCheckbox || false;
const qNum = e.target.value;
const $row = $(e.target).closest('tr');
const sitelinks = this.SiteLinks[qNum];
const $checkbox = $row.find('.ill-row-check');
const $sitelinkCell = $row.find('.ill-sitelinks');
const $outputCell = $row.find('.ill-output');
const $table = $('#ill-table');
const effectiveMax = Math.min(Math.max(this.Config.MaxLanguages, 3), 6);
const validKeys = Object.keys(sitelinks).filter(k => k.endsWith('wiki') && !k.includes('commons'));
$sitelinkCell.html('<span class="ill-loading-inline">Measuring articles...</span>');
$outputCell.empty();
if (!isManualToggling) {
$checkbox.prop('disabled', true).prop('checked', false);
}
const candidates = validKeys.slice(0, 25);
const sizeMap = [];
await Promise.all(candidates.map(async (key) => {
const lang = key.replace('wiki', '').replace(/_/g, '-');
const title = sitelinks[key].title;
const endpoint = `https://${lang}.wikipedia.org/w/api.php?action=query&titles=${encodeURIComponent(title)}&prop=info&format=json&origin=*`;
try {
const res = await fetch(endpoint).then(r => r.json());
const page = Object.values(res.query.pages)[0];
sizeMap.push({
lang,
title,
length: page.length || 0
});
} catch (err) {
sizeMap.push({
lang,
title,
length: 0
});
}
}));
sizeMap.sort((a, b) => b.length - a.length);
const topLinks = sizeMap.slice(0, effectiveMax);
$sitelinkCell.html(topLinks.map(item => {
const kbValue = Math.round(item.length / 1024);
return `<div><a href="https://${item.lang}.wikipedia.org/wiki/${encodeURIComponent(item.title)}" target="_blank">${item.lang}:${this.escapeHTML(item.title)}</a> <span class="ill-size-tag">(${kbValue} kB)</span></div>`;
}).join(""));
const inputWikitext = $row.find('.ill-input').text();
const match = inputWikitext.match(/\[\[(.*?)\]\]/);
if (match) {
const parts = match[1].split('|');
const targetPage = parts[0].trim();
const surfaceName = (parts[1] || parts[0]).trim();
const illLangs = topLinks.map(item => {
const isSameTitle = this.norm(item.title) === this.norm(targetPage);
return `|${item.lang}|${isSameTitle ? '' : item.title}`;
}).join("");
const ltParam = (this.norm(surfaceName) === this.norm(targetPage)) ? "" : `|lt=${surfaceName}`;
$outputCell.text(`{{ill|${targetPage}${ltParam}${illLangs}}}`);
}
$checkbox.prop('disabled', !topLinks.length);
if (!isManualToggling) {
$checkbox.prop('checked', !!topLinks.length);
}
this.updateOkButtonState($table);
},
onCheckbox(e) {
const $row = $(e.target).closest('tr');
const $radio = $row.find('input[type="radio"]:checked');
const isChecked = $(e.target).is(':checked');
if (isChecked) {
if ($radio.length) {
this.showSiteLinks({
target: $radio[0],
fromCheckbox: true
});
}
} else {
$row.find('.ill-sitelinks, .ill-output').empty();
}
},
onOkButton($editform, $wrapper, $textbox) {
let text = $textbox.textSelection('getContents');
let changed = false;
$wrapper.find('.ill-row-check:checked').each(function() {
const $row = $(this).closest('tr');
const input = $row.find('.ill-input').text();
const output = $row.find('.ill-output').text();
if (output) {
const escapedInput = input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
text = text.replace(new RegExp(escapedInput, "gi"), output);
changed = true;
}
});
if (changed) {
$('#wpSummary').val((i, v) => (v ? v + "; " : "") + this.Summary);
$textbox.textSelection('setContents', text);
$wrapper.remove();
$editform.show();
if (this.Config.AutoDiff) $('#wpDiff').click();
}
},
escapeHTML(str) {
return str.replace(/[&<>"']/g, m => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
})[m]);
}
};
window.IllWill = IllWill;
mw.loader.using(['mediawiki.util', 'mediawiki.api', 'jquery.textSelection'], () => {
$(() => IllWill.setup());
});
})();
(function() {
'use strict';
const config = {
enableNS14: true,
rootWhitelists: ['Materials'],
userWhitelist: ""
};
const categoryDataCache = {};
let scanProgress = 0;
let totalToScan = 0;
async function verifyTreeMembership() {
if (!config.enableNS14) return false;
const api = new mw.Api();
const currentTitle = mw.config.get("wgPageName").replace(/_/g, ' ');
const fullRoots = config.rootWhitelists.concat(config.userWhitelist.split(',').map(s => s.trim()).filter(s => s)).map(cat => (cat.startsWith('Category:') ? cat : `Category:${cat}`));
if (fullRoots.includes(currentTitle)) return true;
let categoriesToTable = mw.config.get('wgCategories') || [];
let checked = new Set();
while (categoriesToTable.length > 0) {
if (categoriesToTable.some(cat => fullRoots.includes('Category:' + cat))) {
return true;
}
categoriesToTable.forEach(cat => checked.add(cat));
try {
const res = await api.get({
formatversion: 2,
prop: 'categories',
titles: categoriesToTable.map(c => 'Category:' + c),
cllimit: 'max'
});
let nextBatch = [];
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.categories) {
page.categories.forEach(parent => {
const parentName = parent.title.substring('Category:'.length);
if (!checked.has(parentName)) {
nextBatch.push(parentName);
}
});
}
});
}
categoriesToTable = [...new Set(nextBatch)];
} catch (e) {
return false;
}
}
return false;
}
function getChemboxBlock(wikitext) {
const chemboxRegex = /\{\{\s*Chembox[\s_]*(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const match = wikitext.match(chemboxRegex);
return match ? match[0] : null;
}
function getFormulaFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const formulaMatch = chembox.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
let rawFormula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
rawFormula = rawFormula.split(/\s+/)[0];
return rawFormula.split(',')[0].trim();
}
const propRegex = /\{\{\s*Chembox[\s_]+Properties(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const propSection = chembox.match(propRegex);
if (propSection) {
const content = propSection[0];
const allElements = ['Ac', 'Ag', 'Al', 'Am', 'Ar', 'As', 'At', 'Au', 'B', 'Ba', 'Be', 'Bh', 'Bi', 'Bk', 'Br', 'C', 'Ca', 'Cd', 'Ce', 'Cf', 'Cl', 'Cm', 'Cn', 'Co', 'Cr', 'Cs', 'Cu', 'D', 'Db', 'Ds', 'Dy', 'Er', 'Es', 'Eu', 'F', 'Fe', 'Fl', 'Fm', 'Fr', 'Ga', 'Gd', 'Ge', 'H', 'He', 'Hf', 'Hg', 'Ho', 'Hs', 'I', 'In', 'Ir', 'K', 'Kr', 'La', 'Li', 'Lr', 'Lu', 'Lv', 'Mc', 'Md', 'Mg', 'Mn', 'Mo', 'Mt', 'N', 'Na', 'Nb', 'Nd', 'Ne', 'Nh', 'Ni', 'No', 'Np', 'O', 'Og', 'Os', 'P', 'Pa', 'Pb', 'Pd', 'Pm', 'Po', 'Pr', 'Pt', 'Pu', 'Ra', 'Rb', 'Re', 'Rf', 'Rg', 'Rh', 'Rn', 'Ru', 'S', 'Sb', 'Sc', 'Se', 'Sg', 'Si', 'Sm', 'Sn', 'Sr', 'Ta', 'Tb', 'Tc', 'Te', 'Th', 'Ti', 'Tl', 'Tm', 'Ts', 'U', 'V', 'W', 'Xe', 'Y', 'Yb', 'Zn', 'Zr'];
let data = {};
allElements.forEach(el => {
const elRegex = new RegExp(`\\|\\s*${el}\\s*=\\s*(\\d*\\.?\\d+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch && elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
});
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
elementsPresent.filter(el => !(hasC && (el === "C" || el === "H"))).sort().forEach(el => {
formulaArray.push(el + data[el]);
});
return formulaArray.join('');
}
}
return null;
}
function getCASFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const idRegex = /\{\{\s*Chembox[\s_]+Identifiers(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const idSection = chembox.match(idRegex);
if (!idSection) return null;
const content = idSection[0];
const nbsp = String.fromCharCode(160);
const casMatch = content.match(/\|\s*CASNo\d*\b(?!\s*_)\s*=\s*([^|}\n]+)/i);
if (casMatch) {
let val = casMatch[1].replace(/\{\{[^}]+\}\}/g, "").replace(/\u00AD/g, "").split(" ").join("");
while (val.indexOf(nbsp) !== -1) val = val.replace(nbsp, "");
while (val.indexOf(" ") !== -1) val = val.replace(" ", "");
const cleanedCas = val.trim();
return /^\d{2,7}-\d{2}-\d$/.test(cleanedCas) ? cleanedCas : null;
}
return null;
}
async function getWikidataValue() {
const qid = mw.config.get('wgWikidataItemId');
if (!qid) return null;
const api = new mw.ForeignApi('https://www.wikidata.org/w/api.php');
const res = await api.get({
action: 'wbgetentities',
ids: qid,
props: 'claims',
format: 'json',
formatversion: 2
});
const claims = res.entities[qid].claims;
const data = {};
if (claims.P274) data.formula = claims.P274[0].mainsnak.datavalue.value;
if (claims.P231) data.cas = claims.P231[0].mainsnak.datavalue.value;
return data;
}
async function startBackgroundCategoryScan() {
const api = new mw.Api();
const titles = $('#mw-pages li a').map((i, el) => $(el).attr('title') || $(el).text()).get();
totalToScan = titles.length;
if (totalToScan === 0) return;
const chunks = arrayChunk(titles, 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: chunk,
rvprop: 'content',
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const f = getFormulaFromWikitext(wikitext);
const c = getCASFromWikitext(wikitext);
if (f || c) categoryDataCache[page.title] = {
formula: f,
cas: c
};
});
}
scanProgress += chunk.length;
$('#caschemassist-cat-btn').text(`Scanning... ${scanProgress}/${totalToScan}`);
}
$('#caschemassist-cat-btn').text('Show missing CAS/Chem').prop('disabled', false);
}
async function renderCategoryAudit() {
const api = new mw.Api();
const missingCheckTitles = [];
Object.values(categoryDataCache).forEach(data => {
if (data.formula) missingCheckTitles.push(data.formula);
if (data.cas) missingCheckTitles.push(data.cas);
});
if (missingCheckTitles.length === 0) {
mw.notify("No valid Chembox data found in this category.");
return;
}
const existenceMap = {};
const chunks = arrayChunk([...new Set(missingCheckTitles)], 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
titles: chunk,
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(p => {
existenceMap[p.title] = !p.missing;
});
}
}
$('#mw-pages li').each(function() {
const title = $(this).find('a').attr('title') || $(this).find('a').text().trim();
const data = categoryDataCache[title];
if (!data) return;
if (data.formula && !existenceMap[data.formula]) {
$(this).append($('<span>').css({
'color': 'red',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [${data.formula}]`));
}
if (data.cas && !existenceMap[data.cas]) {
$(this).append($('<span>').css({
'color': 'darkred',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [CAS: ${data.cas}]`));
}
});
$('#caschemassist-cat-btn').text('Audit complete').prop('disabled', true);
}
async function handleSingleArticle() {
const api = new mw.Api();
const currentTitle = mw.config.get("wgTitle");
const [wikiRes, wikidata] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
getWikidataValue()
]);
const page = wikiRes.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const formula = getFormulaFromWikitext(wikitext);
const cas = getCASFromWikitext(wikitext);
if (wikidata) {
let mismatches = [];
if (formula && wikidata.formula && formula !== wikidata.formula) {
mismatches.push(`Formula mismatch: Chembox "${formula}" vs Wikidata "${wikidata.formula}"`);
}
if (cas && wikidata.cas && cas !== wikidata.cas) {
mismatches.push(`CAS mismatch: Chembox "${cas}" vs Wikidata "${wikidata.cas}"`);
}
if (mismatches.length > 0) {
const $warning = $('<div>').css({
'background': '#fff2f2',
'border': '2px solid #d33',
'padding': '10px',
'margin-bottom': '1em',
'color': '#b32424'
}).append($('<strong>').text('⚠️ Data Mismatch Detected: '), mismatches.join(' | '));
$('#mw-content-text').prepend($warning);
}
}
async function checkAndRender(target, label, customWikitext) {
const check = await api.get({
action: 'query',
titles: target,
formatversion: 2
});
if (check.query.pages[0].missing) {
const $container = $('<div>').css({
'background': '#f8f9fa',
'border': '1px solid #a2a9b1',
'padding': '10px',
'margin-bottom': '1em'
});
const $btn = $('<button>').text(`Create redirect: ${target}`).click(async function() {
await api.postWithEditToken({
action: 'edit',
title: target,
text: customWikitext,
summary: `Creating redirect to [[${currentTitle}]] (${label})`,
createonly: true
});
mw.notify(`Created redirect: ${target}`);
$container.fadeOut(400, function() {
$(this).remove();
});
});
$container.append($('<strong>').text(`${label}: `), `Missing redirect. `, $btn);
$('#mw-content-text').prepend($container);
}
}
if (formula && formula !== currentTitle) {
const formulaWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from chemical formula}}\n}}`;
await checkAndRender(formula, "Chemical formula", formulaWikitext);
}
if (cas && cas !== currentTitle) {
const casWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from CAS Registry Number}}\n{{R unprintworthy}}\n}}`;
await checkAndRender(cas, "CAS number", casWikitext);
}
}
function arrayChunk(arr, size) {
const result = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
$(document).ready(async function() {
const ns = mw.config.get("wgNamespaceNumber");
if (ns === 14) {
if (await verifyTreeMembership()) {
const $catContainer = $('#mw-pages');
if ($catContainer.length) {
const $btn = $('<button>').text('Initializing scan...').attr('id', 'caschemassist-cat-btn').addClass('mw-ui-button').css({
'margin': '0 10px 10px 0',
'float': 'right',
'position': 'relative',
'z-index': '100'
}).prop('disabled', true).click(renderCategoryAudit);
$catContainer.prepend($btn);
startBackgroundCategoryScan();
}
}
} else if (ns === 0 && mw.config.get("wgAction") === "view") {
handleSingleArticle();
}
});
})();
//</nowiki>
g9n1eyu0b0l8f8r9ufavj4j295uox65
739288
739284
2026-04-24T21:13:49Z
Sam Sailor
26820
Test
739288
javascript
text/javascript
//<nowiki>
(function() {
mw.util.addCSS(`
.ambox-Orphan { display: block !important; }
.comrade-container {
box-shadow: 0 1px 3px rgba(0,0,0,0.1); border-radius: 2px;
padding: 10px 15px; margin: 10px 0; display: flex;
flex-direction: column; align-items: flex-start; gap: 8px;
border: 1px solid #a2a9b1; background: #fcfcfc; line-height: 1.5;
}
.comrade-header { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.comrade-content { display: flex; flex-direction: column; gap: 5px; width: 100%; }
.comrade-success { background-color: #d5f5e3 !important; border-left: 5px solid #27ae60 !important; color: #1b5e20 !important; }
.comrade-nudge { background: #fff9ea; border-left: 5px solid #f0ad4e; }
.comrade-info { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-status { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-container button:not(.comrade-dismiss) {
background: #f8f9fa; border: 1px solid #a2a9b1; border-radius: 2px;
padding: 2px 10px; cursor: pointer; font-weight: bold; font-size: 0.95em; margin-left: 10px;
}
.comrade-container button:not(.comrade-dismiss):hover { border-color: #36c; color: #36c; background: #fff; }
.comrade-dismiss {
background: transparent; border: none; color: #d33; font-weight: bold;
cursor: pointer; font-size: 0.9em;
}
.comrade-dismiss:hover { text-decoration: underline; }
.comrade-code-block { background: #f1f1f1; padding: 4px 8px; border-radius: 3px; font-family: monospace; display: inline-block; margin-top: 4px; }
`);
function appendDismiss($el) {
$('<button>').addClass('comrade-dismiss').text('Dismiss').on('click', function() {
$(this).closest('.comrade-container').fadeOut();
}).appendTo($el.find('.comrade-header'));
$("#siteSub").after($el);
}
async function checkNamingPrecision(currentTitle, isHuman) {
const disambigMatch = currentTitle.match(/^(.+?)(?:, | \()/);
if (isHuman || !disambigMatch) return;
const baseTitle = disambigMatch[1].trim();
const api = new mw.Api();
try {
const baseRes = await api.get({
action: 'query',
titles: baseTitle,
redirects: 0,
formatversion: 2
});
const basePage = baseRes.query.pages[0];
const baseExists = !basePage.missing;
const isRedirectToSelf = baseExists && basePage.redirect && basePage.title === baseTitle;
const searchRes = await api.get({
action: 'query',
list: 'allpages',
apprefix: baseTitle,
aplimit: 50,
formatversion: 2
});
const competitors = searchRes.query.allpages.filter(p => {
const t = p.title;
return t !== currentTitle && (t.startsWith(baseTitle + ', ') || t.startsWith(baseTitle + ' ('));
});
const $container = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (!baseExists || isRedirectToSelf) {
if (competitors.length === 0) {
$header.append($('<strong>').text('Precision:'));
$content.append($('<span>').append(`Base name `, $('<code>').text(baseTitle), ` is free. This disambiguation may be unnecessary.`));
const moveUrl = mw.util.getUrl('Special:MovePage/' + currentTitle, {
wpNewTitle: baseTitle,
wpReason: 'Unnecessary disambiguation; base name is free per [[WP:PRECISION]]'
});
$content.append($('<button>').text(`Move to ${baseTitle}`).on('click', () => window.open(moveUrl, '_blank')));
} else {
$header.append($('<strong>').text('Observation:'));
$content.append($('<span>').append(`Base name `, $('<code>').text(baseTitle), ` is a redlink, but I found ${competitors.length} potential competitors (e.g., `, $('<i>').text(competitors[0].title), `).`));
$content.append($('<span>').text('Consider if a disambiguation page or primary topic move is needed.'));
}
$container.append($header, $content);
appendDismiss($container);
} else if (basePage.redirect) {
// Scenario: Base redirects elsewhere
$header.append($('<strong>').text('Conflict:'));
$content.append($('<span>').append(`Base name `, $('<code>').text(baseTitle), ` redirects to `, $('<i>').text(basePage.title), `. Verify primary topic status.`));
$container.append($header, $content);
appendDismiss($container);
}
} catch (e) {
console.warn("Comrade: Precision check failed", e);
}
}
async function checkAndRenderRedirect(redirTitle, targetTitle, rCategory, labelText, customWikitext) {
const api = new mw.Api();
const res = await api.get({
action: 'query',
titles: redirTitle,
formatversion: 2
});
if (res.query.pages[0].missing) {
const safeId = "comrade-redir-" + redirTitle.replace(/[^a-z0-9]/gi, '-');
const $container = $('<div>').addClass('comrade-container comrade-info').attr('id', safeId);
const $header = $('<div>').addClass('comrade-header');
const summary = `Created redirect from ${labelText.toLowerCase()} to [[${targetTitle}]]`;
const $btn = $('<button>').text('Create redirect').on('click', () => createModernRedirect(redirTitle, targetTitle, rCategory, summary, safeId, customWikitext));
$header.append($('<div>').append($('<strong>').text(`${labelText}: `), `Create redirect from `, $('<code>').text(redirTitle), $btn));
$container.append($header);
appendDismiss($container);
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
if (qid) {
try {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
});
const res = await fetch(url).then(r => r.json());
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
}
} catch (e) {
console.warn("Comrade: Wikidata API call failed.");
}
}
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
if (domain.includes(".") && url.pathname === "/") {
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
try {
const response = await fetch(citationURL);
if (response.ok) {
await checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
}
} catch (e) {
console.warn("Comrade: Citation API check failed.");
}
}
} catch (e) {
console.warn("Comrade: Error parsing URL object.");
}
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan, resolvedTitle) {
const api = new mw.Api();
const candidates = resolvedTitle ? [resolvedTitle] : [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
redirects: 0
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const foundTitle = page.title;
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) break;
const $container = $('<div>').addClass('comrade-container');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (isOrphan) {
$container.addClass('comrade-nudge');
$header.append($('<strong>').text('Comrade nudge:'));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
}
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(foundTitle),
target: '_blank'
}).text(foundTitle), "; should it be?"));
const snippet = '* {{anbl|' + currentTitle + '}}';
const $snippetWrapper = $('<div>').css({
'display': 'flex',
'align-items': 'center',
'gap': '10px',
'margin-top': '4px'
});
const $instructionSpan = $('<span>').append('If the Short description is good, consider adding ', $('<code>').text(snippet).css({
'background-color': '#f8f9fa',
'border': '1px solid #eaecf0',
'border-radius': '2px',
'padding': '1px 4px'
}));
const $copyBtn = $('<button>').text('Copy & insert').css('margin-left', '0').on('click', function() {
navigator.clipboard.writeText(snippet).then(() => {
const $this = $(this);
const originalText = $this.text();
$this.text('Copied!').prop('disabled', true);
const editUrl = mw.util.getUrl(foundTitle, {
action: 'edit',
summary: snippet
});
window.open(editUrl, '_blank');
setTimeout(() => {
$this.text(originalText).prop('disabled', false);
}, 1500);
});
});
$snippetWrapper.append($instructionSpan, $copyBtn);
$content.append($snippetWrapper);
if (!hasShortDesc) {
$content.append($('<span>').css({
'color': '#d33',
'font-weight': 'bold',
'margin-top': '5px'
}).text('⚠️ Missing short description: Please add a concise one before listing.'));
}
$container.append($header, $content);
appendDismiss($container);
break;
}
}
}
}
async function createModernRedirect(redirTitle, targetTitle, rCategory, customSummary, elementId, customWikitext) {
const api = new mw.Api();
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
try {
await api.postWithEditToken({
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
});
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
} catch (e) {
mw.notify("Failed to create redirect.", {
type: 'error'
});
}
}
function getSmartSortKey(fullName, entity) {
const countryIds = entity.claims?.P27?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const locationIds = entity.claims?.P17?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const placeIds = [...(entity.claims?.P19 || []), ...(entity.claims?.P119 || [])].map(c => c.mainsnak?.datavalue?.value?.id).filter(Boolean);
const allRelevantIds = [...countryIds, ...locationIds, ...placeIds];
const birthYear = parseInt(entity.claims?.P569?.[0]?.mainsnak?.datavalue?.value?.time?.match(/[+-](\d{4})/)?.[1]) || null;
if (fullName.startsWith("Saint ")) return fullName.replace("Saint ", "");
const arabicParticles = /\b(Abu|Abd|Abdel|Abdul|ben|bin|bint)\b/i;
if (fullName.match(arabicParticles)) {
if (birthYear && birthYear > 1900) {
const parts = fullName.split(' ');
const binIndex = parts.findIndex(p => p.toLowerCase() === 'bin' || p.toLowerCase() === 'ben');
if (binIndex > 0) return parts.slice(binIndex).join(' ') + ', ' + parts.slice(0, binIndex).join(' ');
} else {
return fullName;
}
}
const eastAsianQids = ["Q148", "Q184", "Q884", "Q424", "Q881", "Q17"];
if (allRelevantIds.some(id => eastAsianQids.includes(id))) {
if (allRelevantIds.includes("Q17") && birthYear && birthYear > 1885) return westernSort(fullName);
const parts = fullName.split(' ');
if (parts.length > 1) return `${parts[0]}, ${parts.slice(1).join(' ')}`;
}
return westernSort(fullName);
}
function westernSort(name) {
let cleanName = name;
if (name.startsWith("O'")) cleanName = name.replace("O'", "O");
const parts = cleanName.split(' ');
if (parts.length < 2) return cleanName;
const surname = parts.pop();
if (["Jr.", "Jr", "III", "II", "Sr.", "Sr"].includes(surname)) {
const actualSurname = parts.pop();
return `${actualSurname}, ${parts.join(' ')} ${surname}`;
}
return `${surname}, ${parts.join(' ')}`;
}
async function performDeorphan() {
const api = new mw.Api();
try {
await api.edit(mw.config.get('wgPageName'), function(rev) {
let text = rev.content;
const summary = 'Article has backlinks; removed [[Template:Orphan|{{Orphan}}]]';
text = text.replace(/\{\{Orphan\s*(?:\|[^}]*)?\}\}\s*/gi, '');
text = text.replace(/(\{\{Multiple[ _]issues\s*)\|\s*\|/gi, '$1|');
text = replaceMI(text);
text = text.replace(/\n{3,}/g, '\n\n').trim();
return {
text,
summary,
minor: true
};
});
mw.notify('Orphan tag removed!', {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
setTimeout(() => location.reload(), 700);
} catch (e) {
mw.notify('Failed to remove orphan tag.', {
type: 'error'
});
}
}
function replaceMI(text) {
const miRegex = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miRegex.exec(text)) !== null) {
const start = match.index;
result += text.slice(lastIndex, start);
let depth = 0;
let i = start;
while (i < text.length) {
if (text[i] === '{' && text[i + 1] === '{') {
depth++;
i += 2;
} else if (text[i] === '}' && text[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
const end = i;
const full = text.slice(start, end);
const pipeIdx = full.indexOf('|');
const inner = full.slice(pipeIdx + 1, full.length - 2).trim();
const topLevel = extractTopLevelTemplates(inner);
if (topLevel.length === 0) {
result += '';
} else if (topLevel.length === 1) {
result += topLevel[0].trim();
} else {
result += full;
}
lastIndex = end;
}
result += text.slice(lastIndex);
return result;
}
function extractTopLevelTemplates(inner) {
const templates = [];
let i = 0;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
let depth = 0;
const start = i;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
depth++;
i += 2;
} else if (inner[i] === '}' && inner[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
templates.push(inner.slice(start, i));
} else {
i++;
}
}
return templates;
}
async function performSortCorrection(newName, type) {
const api = new mw.Api();
const title = mw.config.get("wgPageName");
try {
const data = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2
});
let content = data.query.pages[0].revisions[0].content;
const dsRegex = /\{\{(?:DEFAULTSORT|Defaultsort):[^}]+\}\}/i;
const newTag = `{{DEFAULTSORT:${newName}}}`;
let actionDesc = type === "add" ? "added" : "corrected";
if (dsRegex.test(content)) {
content = content.replace(dsRegex, newTag);
} else {
const catRegex = /\[\[Category:/i;
if (catRegex.test(content)) {
content = content.replace(catRegex, `${newTag}\n[[Category:`);
} else {
content += `\n\n${newTag}`;
}
}
await api.postWithEditToken({
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName}`,
nocreate: true
});
mw.notify(`DEFAULTSORT ${actionDesc} to ${newName}!`, {
title: 'Comrade Success',
type: 'success'
});
setTimeout(() => location.reload(), 700);
} catch (err) {
mw.notify('Failed to save DEFAULTSORT.', {
type: 'error'
});
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
function renderSortNudge(target, reason) {
const $dsNudge = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Correct to: ${target}`).on('click', () => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
async function fetchWikidataNameLabels(entity) {
const familyIds = entity.claims?.P734?.map(c => c.mainsnak.datavalue.value.id) || [];
const givenIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
const allNameIds = [...new Set([...familyIds, ...givenIds])];
if (allNameIds.length === 0) return {
givens: [],
surnames: []
};
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: allNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
});
const nameData = await fetch(url).then(r => r.json());
return {
givens: givenIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean),
surnames: familyIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean)
};
}
function fixNameCasing(part, referenceName) {
return part.split(' ').map(word => {
const match = referenceName.match(new RegExp(`\\b${word}\\b`, 'i'));
if (match) return match[0];
return referenceName.split(' ').find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase())) || word;
}).join(' ');
}
function getLongNameFromWikitext(wikitext) {
const firstLine = wikitext.split('\n').find(l => l.includes("'''"));
if (!firstLine) return null;
const boldMatch = firstLine.match(/'''(.+?)'''/);
if (!boldMatch) return null;
return boldMatch[1].replace(/\s*([“"«《„‹「『'].+?[”"»》”›」』']|\([^)\n]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
}
async function init() {
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") return;
const api = new mw.Api();
const qid = mw.config.get("wgWikibaseItemId");
try {
const [pageData, backlinkData] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
api.get({
action: 'query',
list: 'backlinks',
blfilterredir: 'nonredirects',
bllimit: 50,
blnamespace: 0,
bltitle: mw.config.get("wgPageName"),
formatversion: 2
})
]);
const page = pageData.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const currentTitle = mw.config.get("wgTitle");
const cleanTitle = stripDiacritics(currentTitle);
const isOrphanTagged = /\{\{\s*(Orphan|Do-attempt|Lonely|Orp|Orphaned article)/i.test(wikitext) || /\| *orphan *=/i.test(wikitext);
const backLinkCount = backlinkData.query.backlinks.length;
if (isOrphanTagged && backLinkCount >= 1) {
const $container = $('<div>').addClass('comrade-container comrade-status');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text('Remove orphan tag').on('click', () => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) await checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
await checkDomainRedirect(qid, currentTitle);
let isHuman = false;
let entity = null;
if (qid) {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
});
const wikiData = await fetch(url).then(r => r.json());
entity = wikiData.entities[qid];
isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
}
await checkNamingPrecision(currentTitle, isHuman);
if (qid && entity) {
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const nameParts = tClean.split(' ');
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
const isOrphan = isOrphanTagged && backLinkCount < 1;
const primarySurname = hasFamilyNameHatnote ? nameParts[0] : (getSmartSortKey(tClean, entity).split(',')[0] || nameParts[nameParts.length - 1]);
let targetSortName = "";
if (isHuman) {
const {
givens,
surnames
} = await fetchWikidataNameLabels(entity);
const refName = entity.labels?.en?.value || currentTitle;
if (dsMatch) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixNameCasing(lastPart, refName)}, ${fixNameCasing(firstPart, refName)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field.");
} else if (givens.length > 0 && lastPart.split(' ').length > 1) {
const misplacedGiven = lastPart.split(' ').find(part => givens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastPart.split(' ').filter(p => p !== misplacedGiven).join(' ');
targetSortName = `${fixNameCasing(newSurname, refName)}, ${fixNameCasing(misplacedGiven + ' ' + firstPart, refName)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
}
}
} else {
targetSortName = hasFamilyNameHatnote ? `${nameParts[0]}, ${nameParts.slice(1).join(' ')}` : getSmartSortKey(tClean, entity);
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).on('click', () => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
const longName = getLongNameFromWikitext(wikitext);
if (longName && longName.length > tClean.length) {
let sortKey = hasFamilyNameHatnote ? `${longName.split(' ')[0]}, ${longName.split(' ').slice(1).join(' ')}` : getSmartSortKey(longName, entity);
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
await checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
if (targetSortName) {
let targetPage = currentTitle.includes(' (') ? currentTitle.split(' (')[0] : currentTitle;
let rCat = targetPage !== currentTitle ? "R from ambiguous sort name" : "R from sort name";
if (targetSortName.includes(',')) {
const p = targetSortName.split(',');
rCat += `|${p[0].trim().charAt(0).toUpperCase()}|${p[1].trim().charAt(0).toUpperCase()}`;
}
await checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name");
}
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
surnames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
givens.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) finalGivenNames.add(gn);
});
if (finalFamilyNames.size === 0 && nameParts.length >= 2) finalFamilyNames.add(nameParts[nameParts.length - 1]);
if (finalGivenNames.size === 0 && nameParts.length >= 2) finalGivenNames.add(nameParts[0]);
const getPriorityTarget = async (name, type) => {
const titles = type === 'surname' ? [`List of people with surname ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`] : [`List of people with given name ${name}`, `${name} (given name)`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
return titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
};
for (const name of finalGivenNames) {
if (name.toLowerCase() === primarySurname.toLowerCase()) continue;
const target = await getPriorityTarget(name, 'given');
if (target) await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
for (const name of finalFamilyNames) {
const titles = [name, `${name} (surname)`, `List of people with surname ${name}`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
const target = titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
if (target) await checkNameList(name, 'surname', currentTitle, hasShortDesc, isOrphan, target);
}
}
}
} catch (err) {
console.error("Comrade error:", err);
}
}
init();
})();
(function() {
if (window.IllWill) return;
const STRIKEOUT = true;
const HIDE = 'hide';
const IllWill = {
Version: "1.1.1",
Attribution: 'created by <a target="_blank" href="/wiki/User:Cobaltcigs">cobaltcigs</a>',
Summary: "Added interlanguage links using IllWill.js",
DataApi: "https://www.wikidata.org/w/api.php",
LocalLang: mw.config.get("wgPageContentLanguage"),
Disambiguator: / \(.*\)$/,
SiteLinks: {},
Config: {
MaxLanguages: 5,
IgnoreScientific: STRIKEOUT,
AutoDiff: true
},
css: `
#illwill { clear: both; margin-bottom: 1.5em; border: 1px solid #a2a9b1; padding: 15px; background: #f8f9fa; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.ill-sitelinks a { display: inline-block; margin-bottom: 3px; font-size: 0.9em; color: #36c; }
.ill-size-tag { font-size: 0.8em; color: #72777d; margin-left: 6px; white-space: nowrap; }
.illwill-ignore { text-decoration: line-through; color: #72777d; font-size: 0.85em; }
.ill-x { width: 35px; text-align: center; }
.ill-input, .ill-output { font-family: 'Courier New', monospace; white-space: pre-wrap; font-size: 0.9em; background: #fff; border: 1px solid #eaecf0; padding: 4px; border-radius: 2px; }
#ill-buttons { text-align: right; padding: 10px; border-top: 1px solid #a2a9b1; margin-top: 10px; }
#ill-buttons button { margin-left: 10px; cursor: pointer; padding: 6px 16px; border-radius: 2px; border: 1px solid #a2a9b1; transition: all 0.2s; }
#ill-ok { background: #36c; color: #fff; border-color: #36c !important; font-weight: bold; }
#ill-ok:hover { background: #447ff5; }
#ill-ok:disabled { background: #eaecf0; color: #72777d; border-color: #c8ccd1 !important; cursor: not-allowed; opacity: 0.7; }
#ill-table { width: 100%; border-collapse: collapse; margin-top: 10px; background: white; }
#ill-table th { background: #f2f2f2; text-align: left; padding: 8px; border: 1px solid #a2a9b1; }
#ill-table td { padding: 8px; border: 1px solid #a2a9b1; vertical-align: top; }
#ill-table caption { background: #eaffea; font-weight: bold; padding: 10px; border: 1px solid #a2a9b1; border-bottom: none; font-size: 1.1em; }
#ill-help { font-size: 0.85em; color: #54595d; padding: 10px; line-height: 1.4; }
.ill-loading-inline { font-style: italic; color: #72777d; font-size: 0.9em; display: block; margin: 5px 0; }
`,
setup() {
if (!["edit", "submit"].includes(mw.config.get('wgAction'))) return;
mw.loader.addStyleTag(this.css);
const link = mw.util.addPortletLink('p-cactions', '#', 'IllWill', 'ca-illwill', "Add interlanguage links via Wikidata");
if (link) {
link.addEventListener('click', (e) => {
e.preventDefault();
this.makePanel();
});
}
},
norm(s) {
if (!s) return "";
const t = s.trim().replace(/[\s_]+/g, " ");
return t.charAt(0).toUpperCase() + t.slice(1);
},
async getRedLinks($textbox) {
const txt = $textbox.textSelection('getContents') || "";
const wl = txt.match(/\[\[[^[\]\n]+\]\]/g);
if (!wl) return {
reds: [],
txt
};
const api = new mw.Api();
const textToParse = wl.join("").replace(/\|[^\]]+/g, "");
try {
const data = await api.post({
action: 'parse',
text: textToParse,
contentmodel: 'wikitext',
formatversion: 2
});
const tempDiv = document.createElement('div');
tempDiv.innerHTML = data.parse.text;
const reds = Array.from(tempDiv.querySelectorAll("a.new")).map(x => {
const urlParams = new URLSearchParams(x.href.split('?')[1]);
return urlParams.get('title')?.replace(/_/g, " ");
}).filter(Boolean);
return {
reds: [...new Set(reds)],
txt
};
} catch (e) {
return {
reds: [],
txt
};
}
},
async makePanel() {
const $editform = $('#editform');
const $textbox = $('#wpTextbox1');
$('#illwill').remove();
$editform.hide();
const $wrapper = $('<div id="illwill">Loading redlinks...</div>').insertBefore($editform);
const {
reds,
txt
} = await this.getRedLinks($textbox);
const wtLinks = [];
reds.forEach(title => {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s/g, "[\\s_]+");
const re = new RegExp("\\[\\[\\s*" + escaped + "\\s*(?:\\|[^\\[\\]]+)?\\]\\]", "ig");
const match = txt.match(re);
if (match && !wtLinks.some(z => z.title === title)) {
wtLinks.push({
link: match[0],
title
});
}
});
if (!wtLinks.length) {
const $back = $('<button>Quit</button>').on('click', () => {
$wrapper.remove();
$editform.show();
});
$wrapper.empty().append($('<strong>No valid redlinks found in text.</strong> '), $back);
return;
}
const $table = $(`
<table id="ill-table" class="wikitable">
<caption>IllWill.js (v${this.Version})</caption>
<thead>
<tr>
<th class="ill-x"><input type="checkbox" id="ill-select-all" disabled></th>
<th>Input link</th>
<th>Possible Wikidata matches</th>
<th>Sitelinks (top ${this.Config.MaxLanguages} by size)</th>
<th>Resulting wikitext</th>
</tr>
</thead>
<tbody></tbody>
<tfoot>
<tr>
<td colspan="3" id="ill-help">Carefully examine possible Wikidata matches.</td>
<td colspan="2" id="ill-buttons">
<button id="ill-cancel">Cancel</button>
<button id="ill-ok" disabled>Show changes</button>
</td>
</tr>
</tfoot>
</table>
`);
const $tbody = $table.find('tbody');
wtLinks.forEach((item, i) => {
$tbody.append(`
<tr id="ill-row-${i}" data-index="${i}">
<td class="ill-x"><input type="checkbox" class="ill-row-check" disabled></td>
<td class="ill-input">${this.escapeHTML(item.link)}</td>
<td class="ill-wd-item"><span class="ill-loading-inline">Searching...</span></td>
<td class="ill-sitelinks"></td>
<td class="ill-output"></td>
</tr>
`);
});
$wrapper.empty().append($table);
const chunkSize = 5;
for (let i = 0; i < wtLinks.length; i += chunkSize) {
const chunk = wtLinks.slice(i, i + chunkSize);
await Promise.all(chunk.map((item, idx) => this.fetchWikidataOptimized(item.title, i + idx)));
}
$table.on('click', '#ill-cancel', () => {
$editform.show();
$wrapper.remove();
});
$table.on('click', '#ill-ok', () => this.onOkButton($editform, $wrapper, $textbox));
$table.on('change', '#ill-select-all', (e) => {
const isChecked = $(e.target).is(':checked');
$table.find('.ill-row-check:not(:disabled)').each(function() {
$(this).prop('checked', isChecked).trigger('change');
});
});
$table.on('change', '.ill-row-check', (e) => {
this.onCheckbox(e);
this.updateOkButtonState($table);
});
$table.on('change', 'input[type="radio"]', (e) => {
this.showSiteLinks(e);
});
},
async fetchWikidataOptimized(title, index) {
const query = title.replace(this.Disambiguator, "");
const url = `${this.DataApi}?action=wbsearchentities&search=${encodeURIComponent(query)}&language=${this.LocalLang}&limit=5&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
const results = data.search;
const $cell = $(`#ill-row-${index} .ill-wd-item`);
if (!results || !results.length) {
$cell.html('<span class="ill-na">(no results)</span>');
return;
}
const entityIds = results.map(r => r.id);
await this.fetchEntities(entityIds, index, $cell);
} catch (e) {
$(`#ill-row-${index} .ill-wd-item`).text("Search failed.");
}
},
async fetchEntities(ids, rowIndex, $cell) {
const url = `${this.DataApi}?action=wbgetentities&ids=${ids.join('|')}&props=labels|descriptions|claims|sitelinks&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
$cell.empty();
ids.forEach(qNum => {
const entity = data.entities[qNum];
if (!entity) return;
const isScientific = entity.claims?.P31?.some(s => s.mainsnak?.datavalue?.value?.id === "Q13442814");
if (isScientific && this.Config.IgnoreScientific === HIDE) return;
const desc = entity.descriptions?.[this.LocalLang]?.value || entity.descriptions?.en?.value || "";
const label = entity.labels?.[this.LocalLang]?.value || entity.labels?.en?.value || qNum;
const isStrikeout = isScientific && this.Config.IgnoreScientific === STRIKEOUT;
const $div = $(`
<div class="${isStrikeout ? 'illwill-ignore' : ''}">
<input type="radio" name="selection-${rowIndex}" value="${qNum}" id="radio-${qNum}" ${isStrikeout ? 'disabled' : ''}>
<label for="radio-${qNum}">
<a href="https://wikidata.org/wiki/${qNum}" target="_blank">${this.escapeHTML(label)}</a>:
<span class="ill-desc">${desc ? this.escapeHTML(desc) : '<i>no description</i>'}</span>
</label>
</div>
`);
$cell.append($div);
this.SiteLinks[qNum] = entity.sitelinks || {};
});
} catch (e) {
$cell.text("Detail fetch failed.");
}
},
updateOkButtonState($table) {
const $rows = $table.find('.ill-row-check:not(:disabled)');
const $checkedRows = $rows.filter(':checked');
const anyEnabled = $rows.length > 0;
$table.find('#ill-ok').prop('disabled', $checkedRows.length === 0);
const $master = $table.find('#ill-select-all');
$master.prop('disabled', !anyEnabled);
if (anyEnabled && $rows.length === $checkedRows.length) {
$master.prop('checked', true);
} else {
$master.prop('checked', false);
}
},
async showSiteLinks(e) {
const isManualToggling = e.fromCheckbox || false;
const qNum = e.target.value;
const $row = $(e.target).closest('tr');
const sitelinks = this.SiteLinks[qNum];
const $checkbox = $row.find('.ill-row-check');
const $sitelinkCell = $row.find('.ill-sitelinks');
const $outputCell = $row.find('.ill-output');
const $table = $('#ill-table');
const effectiveMax = Math.min(Math.max(this.Config.MaxLanguages, 3), 6);
const validKeys = Object.keys(sitelinks).filter(k => k.endsWith('wiki') && !k.includes('commons'));
$sitelinkCell.html('<span class="ill-loading-inline">Measuring articles...</span>');
$outputCell.empty();
if (!isManualToggling) {
$checkbox.prop('disabled', true).prop('checked', false);
}
const candidates = validKeys.slice(0, 25);
const sizeMap = [];
await Promise.all(candidates.map(async (key) => {
const lang = key.replace('wiki', '').replace(/_/g, '-');
const title = sitelinks[key].title;
const endpoint = `https://${lang}.wikipedia.org/w/api.php?action=query&titles=${encodeURIComponent(title)}&prop=info&format=json&origin=*`;
try {
const res = await fetch(endpoint).then(r => r.json());
const page = Object.values(res.query.pages)[0];
sizeMap.push({
lang,
title,
length: page.length || 0
});
} catch (err) {
sizeMap.push({
lang,
title,
length: 0
});
}
}));
sizeMap.sort((a, b) => b.length - a.length);
const topLinks = sizeMap.slice(0, effectiveMax);
$sitelinkCell.html(topLinks.map(item => {
const kbValue = Math.round(item.length / 1024);
return `<div><a href="https://${item.lang}.wikipedia.org/wiki/${encodeURIComponent(item.title)}" target="_blank">${item.lang}:${this.escapeHTML(item.title)}</a> <span class="ill-size-tag">(${kbValue} kB)</span></div>`;
}).join(""));
const inputWikitext = $row.find('.ill-input').text();
const match = inputWikitext.match(/\[\[(.*?)\]\]/);
if (match) {
const parts = match[1].split('|');
const targetPage = parts[0].trim();
const surfaceName = (parts[1] || parts[0]).trim();
const illLangs = topLinks.map(item => {
const isSameTitle = this.norm(item.title) === this.norm(targetPage);
return `|${item.lang}|${isSameTitle ? '' : item.title}`;
}).join("");
const ltParam = (this.norm(surfaceName) === this.norm(targetPage)) ? "" : `|lt=${surfaceName}`;
$outputCell.text(`{{ill|${targetPage}${ltParam}${illLangs}}}`);
}
$checkbox.prop('disabled', !topLinks.length);
if (!isManualToggling) {
$checkbox.prop('checked', !!topLinks.length);
}
this.updateOkButtonState($table);
},
onCheckbox(e) {
const $row = $(e.target).closest('tr');
const $radio = $row.find('input[type="radio"]:checked');
const isChecked = $(e.target).is(':checked');
if (isChecked) {
if ($radio.length) {
this.showSiteLinks({
target: $radio[0],
fromCheckbox: true
});
}
} else {
$row.find('.ill-sitelinks, .ill-output').empty();
}
},
onOkButton($editform, $wrapper, $textbox) {
let text = $textbox.textSelection('getContents');
let changed = false;
$wrapper.find('.ill-row-check:checked').each(function() {
const $row = $(this).closest('tr');
const input = $row.find('.ill-input').text();
const output = $row.find('.ill-output').text();
if (output) {
const escapedInput = input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
text = text.replace(new RegExp(escapedInput, "gi"), output);
changed = true;
}
});
if (changed) {
$('#wpSummary').val((i, v) => (v ? v + "; " : "") + this.Summary);
$textbox.textSelection('setContents', text);
$wrapper.remove();
$editform.show();
if (this.Config.AutoDiff) $('#wpDiff').click();
}
},
escapeHTML(str) {
return str.replace(/[&<>"']/g, m => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
})[m]);
}
};
window.IllWill = IllWill;
mw.loader.using(['mediawiki.util', 'mediawiki.api', 'jquery.textSelection'], () => {
$(() => IllWill.setup());
});
})();
(function() {
'use strict';
const config = {
enableNS14: true,
rootWhitelists: ['Materials'],
userWhitelist: ""
};
const categoryDataCache = {};
let scanProgress = 0;
let totalToScan = 0;
async function verifyTreeMembership() {
if (!config.enableNS14) return false;
const api = new mw.Api();
const currentTitle = mw.config.get("wgPageName").replace(/_/g, ' ');
const fullRoots = config.rootWhitelists.concat(config.userWhitelist.split(',').map(s => s.trim()).filter(s => s)).map(cat => (cat.startsWith('Category:') ? cat : `Category:${cat}`));
if (fullRoots.includes(currentTitle)) return true;
let categoriesToTable = mw.config.get('wgCategories') || [];
let checked = new Set();
while (categoriesToTable.length > 0) {
if (categoriesToTable.some(cat => fullRoots.includes('Category:' + cat))) {
return true;
}
categoriesToTable.forEach(cat => checked.add(cat));
try {
const res = await api.get({
formatversion: 2,
prop: 'categories',
titles: categoriesToTable.map(c => 'Category:' + c),
cllimit: 'max'
});
let nextBatch = [];
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.categories) {
page.categories.forEach(parent => {
const parentName = parent.title.substring('Category:'.length);
if (!checked.has(parentName)) {
nextBatch.push(parentName);
}
});
}
});
}
categoriesToTable = [...new Set(nextBatch)];
} catch (e) {
return false;
}
}
return false;
}
function getChemboxBlock(wikitext) {
const chemboxRegex = /\{\{\s*Chembox[\s_]*(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const match = wikitext.match(chemboxRegex);
return match ? match[0] : null;
}
function getFormulaFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const formulaMatch = chembox.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
let rawFormula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
rawFormula = rawFormula.split(/\s+/)[0];
return rawFormula.split(',')[0].trim();
}
const propRegex = /\{\{\s*Chembox[\s_]+Properties(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const propSection = chembox.match(propRegex);
if (propSection) {
const content = propSection[0];
const allElements = ['Ac', 'Ag', 'Al', 'Am', 'Ar', 'As', 'At', 'Au', 'B', 'Ba', 'Be', 'Bh', 'Bi', 'Bk', 'Br', 'C', 'Ca', 'Cd', 'Ce', 'Cf', 'Cl', 'Cm', 'Cn', 'Co', 'Cr', 'Cs', 'Cu', 'D', 'Db', 'Ds', 'Dy', 'Er', 'Es', 'Eu', 'F', 'Fe', 'Fl', 'Fm', 'Fr', 'Ga', 'Gd', 'Ge', 'H', 'He', 'Hf', 'Hg', 'Ho', 'Hs', 'I', 'In', 'Ir', 'K', 'Kr', 'La', 'Li', 'Lr', 'Lu', 'Lv', 'Mc', 'Md', 'Mg', 'Mn', 'Mo', 'Mt', 'N', 'Na', 'Nb', 'Nd', 'Ne', 'Nh', 'Ni', 'No', 'Np', 'O', 'Og', 'Os', 'P', 'Pa', 'Pb', 'Pd', 'Pm', 'Po', 'Pr', 'Pt', 'Pu', 'Ra', 'Rb', 'Re', 'Rf', 'Rg', 'Rh', 'Rn', 'Ru', 'S', 'Sb', 'Sc', 'Se', 'Sg', 'Si', 'Sm', 'Sn', 'Sr', 'Ta', 'Tb', 'Tc', 'Te', 'Th', 'Ti', 'Tl', 'Tm', 'Ts', 'U', 'V', 'W', 'Xe', 'Y', 'Yb', 'Zn', 'Zr'];
let data = {};
allElements.forEach(el => {
const elRegex = new RegExp(`\\|\\s*${el}\\s*=\\s*(\\d*\\.?\\d+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch && elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
});
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
elementsPresent.filter(el => !(hasC && (el === "C" || el === "H"))).sort().forEach(el => {
formulaArray.push(el + data[el]);
});
return formulaArray.join('');
}
}
return null;
}
function getCASFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const idRegex = /\{\{\s*Chembox[\s_]+Identifiers(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const idSection = chembox.match(idRegex);
if (!idSection) return null;
const content = idSection[0];
const nbsp = String.fromCharCode(160);
const casMatch = content.match(/\|\s*CASNo\d*\b(?!\s*_)\s*=\s*([^|}\n]+)/i);
if (casMatch) {
let val = casMatch[1].replace(/\{\{[^}]+\}\}/g, "").replace(/\u00AD/g, "").split(" ").join("");
while (val.indexOf(nbsp) !== -1) val = val.replace(nbsp, "");
while (val.indexOf(" ") !== -1) val = val.replace(" ", "");
const cleanedCas = val.trim();
return /^\d{2,7}-\d{2}-\d$/.test(cleanedCas) ? cleanedCas : null;
}
return null;
}
async function getWikidataValue() {
const qid = mw.config.get('wgWikidataItemId');
if (!qid) return null;
const api = new mw.ForeignApi('https://www.wikidata.org/w/api.php');
const res = await api.get({
action: 'wbgetentities',
ids: qid,
props: 'claims',
format: 'json',
formatversion: 2
});
const claims = res.entities[qid].claims;
const data = {};
if (claims.P274) data.formula = claims.P274[0].mainsnak.datavalue.value;
if (claims.P231) data.cas = claims.P231[0].mainsnak.datavalue.value;
return data;
}
async function startBackgroundCategoryScan() {
const api = new mw.Api();
const titles = $('#mw-pages li a').map((i, el) => $(el).attr('title') || $(el).text()).get();
totalToScan = titles.length;
if (totalToScan === 0) return;
const chunks = arrayChunk(titles, 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: chunk,
rvprop: 'content',
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const f = getFormulaFromWikitext(wikitext);
const c = getCASFromWikitext(wikitext);
if (f || c) categoryDataCache[page.title] = {
formula: f,
cas: c
};
});
}
scanProgress += chunk.length;
$('#caschemassist-cat-btn').text(`Scanning... ${scanProgress}/${totalToScan}`);
}
$('#caschemassist-cat-btn').text('Show missing CAS/Chem').prop('disabled', false);
}
async function renderCategoryAudit() {
const api = new mw.Api();
const missingCheckTitles = [];
Object.values(categoryDataCache).forEach(data => {
if (data.formula) missingCheckTitles.push(data.formula);
if (data.cas) missingCheckTitles.push(data.cas);
});
if (missingCheckTitles.length === 0) {
mw.notify("No valid Chembox data found in this category.");
return;
}
const existenceMap = {};
const chunks = arrayChunk([...new Set(missingCheckTitles)], 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
titles: chunk,
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(p => {
existenceMap[p.title] = !p.missing;
});
}
}
$('#mw-pages li').each(function() {
const title = $(this).find('a').attr('title') || $(this).find('a').text().trim();
const data = categoryDataCache[title];
if (!data) return;
if (data.formula && !existenceMap[data.formula]) {
$(this).append($('<span>').css({
'color': 'red',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [${data.formula}]`));
}
if (data.cas && !existenceMap[data.cas]) {
$(this).append($('<span>').css({
'color': 'darkred',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [CAS: ${data.cas}]`));
}
});
$('#caschemassist-cat-btn').text('Audit complete').prop('disabled', true);
}
async function handleSingleArticle() {
const api = new mw.Api();
const currentTitle = mw.config.get("wgTitle");
const [wikiRes, wikidata] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
getWikidataValue()
]);
const page = wikiRes.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const formula = getFormulaFromWikitext(wikitext);
const cas = getCASFromWikitext(wikitext);
if (wikidata) {
let mismatches = [];
if (formula && wikidata.formula && formula !== wikidata.formula) {
mismatches.push(`Formula mismatch: Chembox "${formula}" vs Wikidata "${wikidata.formula}"`);
}
if (cas && wikidata.cas && cas !== wikidata.cas) {
mismatches.push(`CAS mismatch: Chembox "${cas}" vs Wikidata "${wikidata.cas}"`);
}
if (mismatches.length > 0) {
const $warning = $('<div>').css({
'background': '#fff2f2',
'border': '2px solid #d33',
'padding': '10px',
'margin-bottom': '1em',
'color': '#b32424'
}).append($('<strong>').text('⚠️ Data Mismatch Detected: '), mismatches.join(' | '));
$('#mw-content-text').prepend($warning);
}
}
async function checkAndRender(target, label, customWikitext) {
const check = await api.get({
action: 'query',
titles: target,
formatversion: 2
});
if (check.query.pages[0].missing) {
const $container = $('<div>').css({
'background': '#f8f9fa',
'border': '1px solid #a2a9b1',
'padding': '10px',
'margin-bottom': '1em'
});
const $btn = $('<button>').text(`Create redirect: ${target}`).click(async function() {
await api.postWithEditToken({
action: 'edit',
title: target,
text: customWikitext,
summary: `Creating redirect to [[${currentTitle}]] (${label})`,
createonly: true
});
mw.notify(`Created redirect: ${target}`);
$container.fadeOut(400, function() {
$(this).remove();
});
});
$container.append($('<strong>').text(`${label}: `), `Missing redirect. `, $btn);
$('#mw-content-text').prepend($container);
}
}
if (formula && formula !== currentTitle) {
const formulaWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from chemical formula}}\n}}`;
await checkAndRender(formula, "Chemical formula", formulaWikitext);
}
if (cas && cas !== currentTitle) {
const casWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from CAS Registry Number}}\n{{R unprintworthy}}\n}}`;
await checkAndRender(cas, "CAS number", casWikitext);
}
}
function arrayChunk(arr, size) {
const result = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
$(document).ready(async function() {
const ns = mw.config.get("wgNamespaceNumber");
if (ns === 14) {
if (await verifyTreeMembership()) {
const $catContainer = $('#mw-pages');
if ($catContainer.length) {
const $btn = $('<button>').text('Initializing scan...').attr('id', 'caschemassist-cat-btn').addClass('mw-ui-button').css({
'margin': '0 10px 10px 0',
'float': 'right',
'position': 'relative',
'z-index': '100'
}).prop('disabled', true).click(renderCategoryAudit);
$catContainer.prepend($btn);
startBackgroundCategoryScan();
}
}
} else if (ns === 0 && mw.config.get("wgAction") === "view") {
handleSingleArticle();
}
});
})();
//</nowiki>
krfaj2ar9aq0fc5spewclmkk03udylw
739289
739288
2026-04-24T21:33:52Z
Sam Sailor
26820
Test
739289
javascript
text/javascript
//<nowiki>
(function() {
mw.util.addCSS(`
.ambox-Orphan { display: block !important; }
.comrade-container {
box-shadow: 0 1px 3px rgba(0,0,0,0.1); border-radius: 2px;
padding: 10px 15px; margin: 10px 0; display: flex;
flex-direction: column; align-items: flex-start; gap: 8px;
border: 1px solid #a2a9b1; background: #fcfcfc; line-height: 1.5;
}
.comrade-header { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.comrade-content { display: flex; flex-direction: column; gap: 5px; width: 100%; }
.comrade-success { background-color: #d5f5e3 !important; border-left: 5px solid #27ae60 !important; color: #1b5e20 !important; }
.comrade-nudge { background: #fff9ea; border-left: 5px solid #f0ad4e; }
.comrade-info { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-status { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-container button:not(.comrade-dismiss) {
background: #f8f9fa; border: 1px solid #a2a9b1; border-radius: 2px;
padding: 2px 10px; cursor: pointer; font-weight: bold; font-size: 0.95em; margin-left: 10px;
}
.comrade-container button:not(.comrade-dismiss):hover { border-color: #36c; color: #36c; background: #fff; }
.comrade-dismiss {
background: transparent; border: none; color: #d33; font-weight: bold;
cursor: pointer; font-size: 0.9em;
}
.comrade-dismiss:hover { text-decoration: underline; }
.comrade-code-block { background: #f1f1f1; padding: 4px 8px; border-radius: 3px; font-family: monospace; display: inline-block; margin-top: 4px; }
`);
function appendDismiss($el) {
$('<button>').addClass('comrade-dismiss').text('Dismiss').on('click', function() {
$(this).closest('.comrade-container').fadeOut();
}).appendTo($el.find('.comrade-header'));
$("#siteSub").after($el);
}
async function checkNamingPrecision(currentTitle, isHuman) {
const disambigMatch = currentTitle.match(/^(.+?)(?:, | \()/);
if (isHuman || !disambigMatch) return;
const baseTitle = disambigMatch[1].trim();
const api = new mw.Api();
try {
const baseRes = await api.get({
action: 'query',
titles: baseTitle,
redirects: 0,
formatversion: 2
});
const basePage = baseRes.query.pages[0];
const baseExists = !basePage.missing;
let baseRedirectsToSelf = false;
if (baseExists && basePage.redirect) {
const redirRes = await api.get({
action: 'query',
titles: baseTitle,
redirects: 1,
formatversion: 2
});
if (redirRes.query.pages[0].title === currentTitle) {
baseRedirectsToSelf = true;
}
}
const searchRes = await api.get({
action: 'query',
list: 'allpages',
apprefix: baseTitle,
aplimit: 50,
formatversion: 2
});
const competitors = searchRes.query.allpages.filter(p => {
const t = p.title;
return t !== currentTitle && (t.startsWith(baseTitle + ', ') || t.startsWith(baseTitle + ' ('));
});
const $container = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (!baseExists || baseRedirectsToSelf) {
if (competitors.length === 0) {
$header.append($('<strong>').text('Precision:'));
const reason = baseRedirectsToSelf ? "already redirects here" : "is free";
$content.append($('<span>').append(`The base name `, $('<code>').text(baseTitle), ` ${reason}. Consider moving this article.`));
const moveUrl = mw.util.getUrl('Special:MovePage/' + currentTitle, {
wpNewTitle: baseTitle,
wpReason: 'Unnecessary disambiguation; base name is free/redirects here per [[WP:PRECISION]]'
});
$content.append($('<button>').text(`Move to ${baseTitle}`).on('click', () => window.open(moveUrl, '_blank')));
} else {
$header.append($('<strong>').text('Observation:'));
$content.append($('<span>').append(`Base name `, $('<code>').text(baseTitle), ` has ${competitors.length} competitors, but it is currently a ${baseRedirectsToSelf ? 'redirect to this page' : 'redlink'}.`));
$content.append($('<span>').text('Consider if a disambiguation page is needed.'));
}
$container.append($header, $content);
appendDismiss($container);
}
} catch (e) {
console.warn("Comrade: Precision check failed", e);
}
}
async function checkAndRenderRedirect(redirTitle, targetTitle, rCategory, labelText, customWikitext) {
const api = new mw.Api();
const res = await api.get({
action: 'query',
titles: redirTitle,
formatversion: 2
});
if (res.query.pages[0].missing) {
const safeId = "comrade-redir-" + redirTitle.replace(/[^a-z0-9]/gi, '-');
const $container = $('<div>').addClass('comrade-container comrade-info').attr('id', safeId);
const $header = $('<div>').addClass('comrade-header');
const summary = `Created redirect from ${labelText.toLowerCase()} to [[${targetTitle}]]`;
const $btn = $('<button>').text('Create redirect').on('click', () => createModernRedirect(redirTitle, targetTitle, rCategory, summary, safeId, customWikitext));
$header.append($('<div>').append($('<strong>').text(`${labelText}: `), `Create redirect from `, $('<code>').text(redirTitle), $btn));
$container.append($header);
appendDismiss($container);
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
if (qid) {
try {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
});
const res = await fetch(url).then(r => r.json());
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
}
} catch (e) {
console.warn("Comrade: Wikidata API call failed.");
}
}
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
if (domain.includes(".") && url.pathname === "/") {
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
try {
const response = await fetch(citationURL);
if (response.ok) {
await checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
}
} catch (e) {
console.warn("Comrade: Citation API check failed.");
}
}
} catch (e) {
console.warn("Comrade: Error parsing URL object.");
}
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan, resolvedTitle) {
const api = new mw.Api();
const candidates = resolvedTitle ? [resolvedTitle] : [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
redirects: 0
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const foundTitle = page.title;
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) break;
const $container = $('<div>').addClass('comrade-container');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (isOrphan) {
$container.addClass('comrade-nudge');
$header.append($('<strong>').text('Comrade nudge:'));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
}
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(foundTitle),
target: '_blank'
}).text(foundTitle), "; should it be?"));
const snippet = '* {{anbl|' + currentTitle + '}}';
const $snippetWrapper = $('<div>').css({
'display': 'flex',
'align-items': 'center',
'gap': '10px',
'margin-top': '4px'
});
const $instructionSpan = $('<span>').append('If the Short description is good, consider adding ', $('<code>').text(snippet).css({
'background-color': '#f8f9fa',
'border': '1px solid #eaecf0',
'border-radius': '2px',
'padding': '1px 4px'
}));
const $copyBtn = $('<button>').text('Copy & insert').css('margin-left', '0').on('click', function() {
navigator.clipboard.writeText(snippet).then(() => {
const $this = $(this);
const originalText = $this.text();
$this.text('Copied!').prop('disabled', true);
const editUrl = mw.util.getUrl(foundTitle, {
action: 'edit',
summary: snippet
});
window.open(editUrl, '_blank');
setTimeout(() => {
$this.text(originalText).prop('disabled', false);
}, 1500);
});
});
$snippetWrapper.append($instructionSpan, $copyBtn);
$content.append($snippetWrapper);
if (!hasShortDesc) {
$content.append($('<span>').css({
'color': '#d33',
'font-weight': 'bold',
'margin-top': '5px'
}).text('⚠️ Missing short description: Please add a concise one before listing.'));
}
$container.append($header, $content);
appendDismiss($container);
break;
}
}
}
}
async function createModernRedirect(redirTitle, targetTitle, rCategory, customSummary, elementId, customWikitext) {
const api = new mw.Api();
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
try {
await api.postWithEditToken({
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
});
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
} catch (e) {
mw.notify("Failed to create redirect.", {
type: 'error'
});
}
}
function getSmartSortKey(fullName, entity) {
const countryIds = entity.claims?.P27?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const locationIds = entity.claims?.P17?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const placeIds = [...(entity.claims?.P19 || []), ...(entity.claims?.P119 || [])].map(c => c.mainsnak?.datavalue?.value?.id).filter(Boolean);
const allRelevantIds = [...countryIds, ...locationIds, ...placeIds];
const birthYear = parseInt(entity.claims?.P569?.[0]?.mainsnak?.datavalue?.value?.time?.match(/[+-](\d{4})/)?.[1]) || null;
if (fullName.startsWith("Saint ")) return fullName.replace("Saint ", "");
const arabicParticles = /\b(Abu|Abd|Abdel|Abdul|ben|bin|bint)\b/i;
if (fullName.match(arabicParticles)) {
if (birthYear && birthYear > 1900) {
const parts = fullName.split(' ');
const binIndex = parts.findIndex(p => p.toLowerCase() === 'bin' || p.toLowerCase() === 'ben');
if (binIndex > 0) return parts.slice(binIndex).join(' ') + ', ' + parts.slice(0, binIndex).join(' ');
} else {
return fullName;
}
}
const eastAsianQids = ["Q148", "Q184", "Q884", "Q424", "Q881", "Q17"];
if (allRelevantIds.some(id => eastAsianQids.includes(id))) {
if (allRelevantIds.includes("Q17") && birthYear && birthYear > 1885) return westernSort(fullName);
const parts = fullName.split(' ');
if (parts.length > 1) return `${parts[0]}, ${parts.slice(1).join(' ')}`;
}
return westernSort(fullName);
}
function westernSort(name) {
let cleanName = name;
if (name.startsWith("O'")) cleanName = name.replace("O'", "O");
const parts = cleanName.split(' ');
if (parts.length < 2) return cleanName;
const surname = parts.pop();
if (["Jr.", "Jr", "III", "II", "Sr.", "Sr"].includes(surname)) {
const actualSurname = parts.pop();
return `${actualSurname}, ${parts.join(' ')} ${surname}`;
}
return `${surname}, ${parts.join(' ')}`;
}
async function performDeorphan() {
const api = new mw.Api();
try {
await api.edit(mw.config.get('wgPageName'), function(rev) {
let text = rev.content;
const summary = 'Article has backlinks; removed [[Template:Orphan|{{Orphan}}]]';
text = text.replace(/\{\{Orphan\s*(?:\|[^}]*)?\}\}\s*/gi, '');
text = text.replace(/(\{\{Multiple[ _]issues\s*)\|\s*\|/gi, '$1|');
text = replaceMI(text);
text = text.replace(/\n{3,}/g, '\n\n').trim();
return {
text,
summary,
minor: true
};
});
mw.notify('Orphan tag removed!', {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
setTimeout(() => location.reload(), 700);
} catch (e) {
mw.notify('Failed to remove orphan tag.', {
type: 'error'
});
}
}
function replaceMI(text) {
const miRegex = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miRegex.exec(text)) !== null) {
const start = match.index;
result += text.slice(lastIndex, start);
let depth = 0;
let i = start;
while (i < text.length) {
if (text[i] === '{' && text[i + 1] === '{') {
depth++;
i += 2;
} else if (text[i] === '}' && text[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
const end = i;
const full = text.slice(start, end);
const pipeIdx = full.indexOf('|');
const inner = full.slice(pipeIdx + 1, full.length - 2).trim();
const topLevel = extractTopLevelTemplates(inner);
if (topLevel.length === 0) {
result += '';
} else if (topLevel.length === 1) {
result += topLevel[0].trim();
} else {
result += full;
}
lastIndex = end;
}
result += text.slice(lastIndex);
return result;
}
function extractTopLevelTemplates(inner) {
const templates = [];
let i = 0;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
let depth = 0;
const start = i;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
depth++;
i += 2;
} else if (inner[i] === '}' && inner[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
templates.push(inner.slice(start, i));
} else {
i++;
}
}
return templates;
}
async function performSortCorrection(newName, type) {
const api = new mw.Api();
const title = mw.config.get("wgPageName");
try {
const data = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2
});
let content = data.query.pages[0].revisions[0].content;
const dsRegex = /\{\{(?:DEFAULTSORT|Defaultsort):[^}]+\}\}/i;
const newTag = `{{DEFAULTSORT:${newName}}}`;
let actionDesc = type === "add" ? "added" : "corrected";
if (dsRegex.test(content)) {
content = content.replace(dsRegex, newTag);
} else {
const catRegex = /\[\[Category:/i;
if (catRegex.test(content)) {
content = content.replace(catRegex, `${newTag}\n[[Category:`);
} else {
content += `\n\n${newTag}`;
}
}
await api.postWithEditToken({
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName}`,
nocreate: true
});
mw.notify(`DEFAULTSORT ${actionDesc} to ${newName}!`, {
title: 'Comrade Success',
type: 'success'
});
setTimeout(() => location.reload(), 700);
} catch (err) {
mw.notify('Failed to save DEFAULTSORT.', {
type: 'error'
});
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
function renderSortNudge(target, reason) {
const $dsNudge = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Correct to: ${target}`).on('click', () => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
async function fetchWikidataNameLabels(entity) {
const familyIds = entity.claims?.P734?.map(c => c.mainsnak.datavalue.value.id) || [];
const givenIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
const allNameIds = [...new Set([...familyIds, ...givenIds])];
if (allNameIds.length === 0) return {
givens: [],
surnames: []
};
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: allNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
});
const nameData = await fetch(url).then(r => r.json());
return {
givens: givenIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean),
surnames: familyIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean)
};
}
function fixNameCasing(part, referenceName) {
return part.split(' ').map(word => {
const match = referenceName.match(new RegExp(`\\b${word}\\b`, 'i'));
if (match) return match[0];
return referenceName.split(' ').find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase())) || word;
}).join(' ');
}
function getLongNameFromWikitext(wikitext) {
const firstLine = wikitext.split('\n').find(l => l.includes("'''"));
if (!firstLine) return null;
const boldMatch = firstLine.match(/'''(.+?)'''/);
if (!boldMatch) return null;
return boldMatch[1].replace(/\s*([“"«《„‹「『'].+?[”"»》”›」』']|\([^)\n]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
}
async function init() {
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") return;
const api = new mw.Api();
const qid = mw.config.get("wgWikibaseItemId");
try {
const [pageData, backlinkData] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
api.get({
action: 'query',
list: 'backlinks',
blfilterredir: 'nonredirects',
bllimit: 50,
blnamespace: 0,
bltitle: mw.config.get("wgPageName"),
formatversion: 2
})
]);
const page = pageData.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const currentTitle = mw.config.get("wgTitle");
const cleanTitle = stripDiacritics(currentTitle);
const isOrphanTagged = /\{\{\s*(Orphan|Do-attempt|Lonely|Orp|Orphaned article)/i.test(wikitext) || /\| *orphan *=/i.test(wikitext);
const backLinkCount = backlinkData.query.backlinks.length;
if (isOrphanTagged && backLinkCount >= 1) {
const $container = $('<div>').addClass('comrade-container comrade-status');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text('Remove orphan tag').on('click', () => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) await checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
await checkDomainRedirect(qid, currentTitle);
let isHuman = false;
let entity = null;
if (qid) {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
});
const wikiData = await fetch(url).then(r => r.json());
entity = wikiData.entities[qid];
isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
}
await checkNamingPrecision(currentTitle, isHuman);
if (qid && entity) {
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const nameParts = tClean.split(' ');
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
const isOrphan = isOrphanTagged && backLinkCount < 1;
const primarySurname = hasFamilyNameHatnote ? nameParts[0] : (getSmartSortKey(tClean, entity).split(',')[0] || nameParts[nameParts.length - 1]);
let targetSortName = "";
if (isHuman) {
const {
givens,
surnames
} = await fetchWikidataNameLabels(entity);
const refName = entity.labels?.en?.value || currentTitle;
if (dsMatch) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixNameCasing(lastPart, refName)}, ${fixNameCasing(firstPart, refName)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field.");
} else if (givens.length > 0 && lastPart.split(' ').length > 1) {
const misplacedGiven = lastPart.split(' ').find(part => givens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastPart.split(' ').filter(p => p !== misplacedGiven).join(' ');
targetSortName = `${fixNameCasing(newSurname, refName)}, ${fixNameCasing(misplacedGiven + ' ' + firstPart, refName)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
}
}
} else {
targetSortName = hasFamilyNameHatnote ? `${nameParts[0]}, ${nameParts.slice(1).join(' ')}` : getSmartSortKey(tClean, entity);
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).on('click', () => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
const longName = getLongNameFromWikitext(wikitext);
if (longName && longName.length > tClean.length) {
let sortKey = hasFamilyNameHatnote ? `${longName.split(' ')[0]}, ${longName.split(' ').slice(1).join(' ')}` : getSmartSortKey(longName, entity);
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
await checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
if (targetSortName) {
let targetPage = currentTitle.includes(' (') ? currentTitle.split(' (')[0] : currentTitle;
let rCat = targetPage !== currentTitle ? "R from ambiguous sort name" : "R from sort name";
if (targetSortName.includes(',')) {
const p = targetSortName.split(',');
rCat += `|${p[0].trim().charAt(0).toUpperCase()}|${p[1].trim().charAt(0).toUpperCase()}`;
}
await checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name");
}
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
surnames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
givens.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) finalGivenNames.add(gn);
});
if (finalFamilyNames.size === 0 && nameParts.length >= 2) finalFamilyNames.add(nameParts[nameParts.length - 1]);
if (finalGivenNames.size === 0 && nameParts.length >= 2) finalGivenNames.add(nameParts[0]);
const getPriorityTarget = async (name, type) => {
const titles = type === 'surname' ? [`List of people with surname ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`] : [`List of people with given name ${name}`, `${name} (given name)`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
return titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
};
for (const name of finalGivenNames) {
if (name.toLowerCase() === primarySurname.toLowerCase()) continue;
const target = await getPriorityTarget(name, 'given');
if (target) await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
for (const name of finalFamilyNames) {
const titles = [name, `${name} (surname)`, `List of people with surname ${name}`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
const target = titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
if (target) await checkNameList(name, 'surname', currentTitle, hasShortDesc, isOrphan, target);
}
}
}
} catch (err) {
console.error("Comrade error:", err);
}
}
init();
})();
(function() {
if (window.IllWill) return;
const STRIKEOUT = true;
const HIDE = 'hide';
const IllWill = {
Version: "1.1.1",
Attribution: 'created by <a target="_blank" href="/wiki/User:Cobaltcigs">cobaltcigs</a>',
Summary: "Added interlanguage links using IllWill.js",
DataApi: "https://www.wikidata.org/w/api.php",
LocalLang: mw.config.get("wgPageContentLanguage"),
Disambiguator: / \(.*\)$/,
SiteLinks: {},
Config: {
MaxLanguages: 5,
IgnoreScientific: STRIKEOUT,
AutoDiff: true
},
css: `
#illwill { clear: both; margin-bottom: 1.5em; border: 1px solid #a2a9b1; padding: 15px; background: #f8f9fa; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.ill-sitelinks a { display: inline-block; margin-bottom: 3px; font-size: 0.9em; color: #36c; }
.ill-size-tag { font-size: 0.8em; color: #72777d; margin-left: 6px; white-space: nowrap; }
.illwill-ignore { text-decoration: line-through; color: #72777d; font-size: 0.85em; }
.ill-x { width: 35px; text-align: center; }
.ill-input, .ill-output { font-family: 'Courier New', monospace; white-space: pre-wrap; font-size: 0.9em; background: #fff; border: 1px solid #eaecf0; padding: 4px; border-radius: 2px; }
#ill-buttons { text-align: right; padding: 10px; border-top: 1px solid #a2a9b1; margin-top: 10px; }
#ill-buttons button { margin-left: 10px; cursor: pointer; padding: 6px 16px; border-radius: 2px; border: 1px solid #a2a9b1; transition: all 0.2s; }
#ill-ok { background: #36c; color: #fff; border-color: #36c !important; font-weight: bold; }
#ill-ok:hover { background: #447ff5; }
#ill-ok:disabled { background: #eaecf0; color: #72777d; border-color: #c8ccd1 !important; cursor: not-allowed; opacity: 0.7; }
#ill-table { width: 100%; border-collapse: collapse; margin-top: 10px; background: white; }
#ill-table th { background: #f2f2f2; text-align: left; padding: 8px; border: 1px solid #a2a9b1; }
#ill-table td { padding: 8px; border: 1px solid #a2a9b1; vertical-align: top; }
#ill-table caption { background: #eaffea; font-weight: bold; padding: 10px; border: 1px solid #a2a9b1; border-bottom: none; font-size: 1.1em; }
#ill-help { font-size: 0.85em; color: #54595d; padding: 10px; line-height: 1.4; }
.ill-loading-inline { font-style: italic; color: #72777d; font-size: 0.9em; display: block; margin: 5px 0; }
`,
setup() {
if (!["edit", "submit"].includes(mw.config.get('wgAction'))) return;
mw.loader.addStyleTag(this.css);
const link = mw.util.addPortletLink('p-cactions', '#', 'IllWill', 'ca-illwill', "Add interlanguage links via Wikidata");
if (link) {
link.addEventListener('click', (e) => {
e.preventDefault();
this.makePanel();
});
}
},
norm(s) {
if (!s) return "";
const t = s.trim().replace(/[\s_]+/g, " ");
return t.charAt(0).toUpperCase() + t.slice(1);
},
async getRedLinks($textbox) {
const txt = $textbox.textSelection('getContents') || "";
const wl = txt.match(/\[\[[^[\]\n]+\]\]/g);
if (!wl) return {
reds: [],
txt
};
const api = new mw.Api();
const textToParse = wl.join("").replace(/\|[^\]]+/g, "");
try {
const data = await api.post({
action: 'parse',
text: textToParse,
contentmodel: 'wikitext',
formatversion: 2
});
const tempDiv = document.createElement('div');
tempDiv.innerHTML = data.parse.text;
const reds = Array.from(tempDiv.querySelectorAll("a.new")).map(x => {
const urlParams = new URLSearchParams(x.href.split('?')[1]);
return urlParams.get('title')?.replace(/_/g, " ");
}).filter(Boolean);
return {
reds: [...new Set(reds)],
txt
};
} catch (e) {
return {
reds: [],
txt
};
}
},
async makePanel() {
const $editform = $('#editform');
const $textbox = $('#wpTextbox1');
$('#illwill').remove();
$editform.hide();
const $wrapper = $('<div id="illwill">Loading redlinks...</div>').insertBefore($editform);
const {
reds,
txt
} = await this.getRedLinks($textbox);
const wtLinks = [];
reds.forEach(title => {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s/g, "[\\s_]+");
const re = new RegExp("\\[\\[\\s*" + escaped + "\\s*(?:\\|[^\\[\\]]+)?\\]\\]", "ig");
const match = txt.match(re);
if (match && !wtLinks.some(z => z.title === title)) {
wtLinks.push({
link: match[0],
title
});
}
});
if (!wtLinks.length) {
const $back = $('<button>Quit</button>').on('click', () => {
$wrapper.remove();
$editform.show();
});
$wrapper.empty().append($('<strong>No valid redlinks found in text.</strong> '), $back);
return;
}
const $table = $(`
<table id="ill-table" class="wikitable">
<caption>IllWill.js (v${this.Version})</caption>
<thead>
<tr>
<th class="ill-x"><input type="checkbox" id="ill-select-all" disabled></th>
<th>Input link</th>
<th>Possible Wikidata matches</th>
<th>Sitelinks (top ${this.Config.MaxLanguages} by size)</th>
<th>Resulting wikitext</th>
</tr>
</thead>
<tbody></tbody>
<tfoot>
<tr>
<td colspan="3" id="ill-help">Carefully examine possible Wikidata matches.</td>
<td colspan="2" id="ill-buttons">
<button id="ill-cancel">Cancel</button>
<button id="ill-ok" disabled>Show changes</button>
</td>
</tr>
</tfoot>
</table>
`);
const $tbody = $table.find('tbody');
wtLinks.forEach((item, i) => {
$tbody.append(`
<tr id="ill-row-${i}" data-index="${i}">
<td class="ill-x"><input type="checkbox" class="ill-row-check" disabled></td>
<td class="ill-input">${this.escapeHTML(item.link)}</td>
<td class="ill-wd-item"><span class="ill-loading-inline">Searching...</span></td>
<td class="ill-sitelinks"></td>
<td class="ill-output"></td>
</tr>
`);
});
$wrapper.empty().append($table);
const chunkSize = 5;
for (let i = 0; i < wtLinks.length; i += chunkSize) {
const chunk = wtLinks.slice(i, i + chunkSize);
await Promise.all(chunk.map((item, idx) => this.fetchWikidataOptimized(item.title, i + idx)));
}
$table.on('click', '#ill-cancel', () => {
$editform.show();
$wrapper.remove();
});
$table.on('click', '#ill-ok', () => this.onOkButton($editform, $wrapper, $textbox));
$table.on('change', '#ill-select-all', (e) => {
const isChecked = $(e.target).is(':checked');
$table.find('.ill-row-check:not(:disabled)').each(function() {
$(this).prop('checked', isChecked).trigger('change');
});
});
$table.on('change', '.ill-row-check', (e) => {
this.onCheckbox(e);
this.updateOkButtonState($table);
});
$table.on('change', 'input[type="radio"]', (e) => {
this.showSiteLinks(e);
});
},
async fetchWikidataOptimized(title, index) {
const query = title.replace(this.Disambiguator, "");
const url = `${this.DataApi}?action=wbsearchentities&search=${encodeURIComponent(query)}&language=${this.LocalLang}&limit=5&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
const results = data.search;
const $cell = $(`#ill-row-${index} .ill-wd-item`);
if (!results || !results.length) {
$cell.html('<span class="ill-na">(no results)</span>');
return;
}
const entityIds = results.map(r => r.id);
await this.fetchEntities(entityIds, index, $cell);
} catch (e) {
$(`#ill-row-${index} .ill-wd-item`).text("Search failed.");
}
},
async fetchEntities(ids, rowIndex, $cell) {
const url = `${this.DataApi}?action=wbgetentities&ids=${ids.join('|')}&props=labels|descriptions|claims|sitelinks&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
$cell.empty();
ids.forEach(qNum => {
const entity = data.entities[qNum];
if (!entity) return;
const isScientific = entity.claims?.P31?.some(s => s.mainsnak?.datavalue?.value?.id === "Q13442814");
if (isScientific && this.Config.IgnoreScientific === HIDE) return;
const desc = entity.descriptions?.[this.LocalLang]?.value || entity.descriptions?.en?.value || "";
const label = entity.labels?.[this.LocalLang]?.value || entity.labels?.en?.value || qNum;
const isStrikeout = isScientific && this.Config.IgnoreScientific === STRIKEOUT;
const $div = $(`
<div class="${isStrikeout ? 'illwill-ignore' : ''}">
<input type="radio" name="selection-${rowIndex}" value="${qNum}" id="radio-${qNum}" ${isStrikeout ? 'disabled' : ''}>
<label for="radio-${qNum}">
<a href="https://wikidata.org/wiki/${qNum}" target="_blank">${this.escapeHTML(label)}</a>:
<span class="ill-desc">${desc ? this.escapeHTML(desc) : '<i>no description</i>'}</span>
</label>
</div>
`);
$cell.append($div);
this.SiteLinks[qNum] = entity.sitelinks || {};
});
} catch (e) {
$cell.text("Detail fetch failed.");
}
},
updateOkButtonState($table) {
const $rows = $table.find('.ill-row-check:not(:disabled)');
const $checkedRows = $rows.filter(':checked');
const anyEnabled = $rows.length > 0;
$table.find('#ill-ok').prop('disabled', $checkedRows.length === 0);
const $master = $table.find('#ill-select-all');
$master.prop('disabled', !anyEnabled);
if (anyEnabled && $rows.length === $checkedRows.length) {
$master.prop('checked', true);
} else {
$master.prop('checked', false);
}
},
async showSiteLinks(e) {
const isManualToggling = e.fromCheckbox || false;
const qNum = e.target.value;
const $row = $(e.target).closest('tr');
const sitelinks = this.SiteLinks[qNum];
const $checkbox = $row.find('.ill-row-check');
const $sitelinkCell = $row.find('.ill-sitelinks');
const $outputCell = $row.find('.ill-output');
const $table = $('#ill-table');
const effectiveMax = Math.min(Math.max(this.Config.MaxLanguages, 3), 6);
const validKeys = Object.keys(sitelinks).filter(k => k.endsWith('wiki') && !k.includes('commons'));
$sitelinkCell.html('<span class="ill-loading-inline">Measuring articles...</span>');
$outputCell.empty();
if (!isManualToggling) {
$checkbox.prop('disabled', true).prop('checked', false);
}
const candidates = validKeys.slice(0, 25);
const sizeMap = [];
await Promise.all(candidates.map(async (key) => {
const lang = key.replace('wiki', '').replace(/_/g, '-');
const title = sitelinks[key].title;
const endpoint = `https://${lang}.wikipedia.org/w/api.php?action=query&titles=${encodeURIComponent(title)}&prop=info&format=json&origin=*`;
try {
const res = await fetch(endpoint).then(r => r.json());
const page = Object.values(res.query.pages)[0];
sizeMap.push({
lang,
title,
length: page.length || 0
});
} catch (err) {
sizeMap.push({
lang,
title,
length: 0
});
}
}));
sizeMap.sort((a, b) => b.length - a.length);
const topLinks = sizeMap.slice(0, effectiveMax);
$sitelinkCell.html(topLinks.map(item => {
const kbValue = Math.round(item.length / 1024);
return `<div><a href="https://${item.lang}.wikipedia.org/wiki/${encodeURIComponent(item.title)}" target="_blank">${item.lang}:${this.escapeHTML(item.title)}</a> <span class="ill-size-tag">(${kbValue} kB)</span></div>`;
}).join(""));
const inputWikitext = $row.find('.ill-input').text();
const match = inputWikitext.match(/\[\[(.*?)\]\]/);
if (match) {
const parts = match[1].split('|');
const targetPage = parts[0].trim();
const surfaceName = (parts[1] || parts[0]).trim();
const illLangs = topLinks.map(item => {
const isSameTitle = this.norm(item.title) === this.norm(targetPage);
return `|${item.lang}|${isSameTitle ? '' : item.title}`;
}).join("");
const ltParam = (this.norm(surfaceName) === this.norm(targetPage)) ? "" : `|lt=${surfaceName}`;
$outputCell.text(`{{ill|${targetPage}${ltParam}${illLangs}}}`);
}
$checkbox.prop('disabled', !topLinks.length);
if (!isManualToggling) {
$checkbox.prop('checked', !!topLinks.length);
}
this.updateOkButtonState($table);
},
onCheckbox(e) {
const $row = $(e.target).closest('tr');
const $radio = $row.find('input[type="radio"]:checked');
const isChecked = $(e.target).is(':checked');
if (isChecked) {
if ($radio.length) {
this.showSiteLinks({
target: $radio[0],
fromCheckbox: true
});
}
} else {
$row.find('.ill-sitelinks, .ill-output').empty();
}
},
onOkButton($editform, $wrapper, $textbox) {
let text = $textbox.textSelection('getContents');
let changed = false;
$wrapper.find('.ill-row-check:checked').each(function() {
const $row = $(this).closest('tr');
const input = $row.find('.ill-input').text();
const output = $row.find('.ill-output').text();
if (output) {
const escapedInput = input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
text = text.replace(new RegExp(escapedInput, "gi"), output);
changed = true;
}
});
if (changed) {
$('#wpSummary').val((i, v) => (v ? v + "; " : "") + this.Summary);
$textbox.textSelection('setContents', text);
$wrapper.remove();
$editform.show();
if (this.Config.AutoDiff) $('#wpDiff').click();
}
},
escapeHTML(str) {
return str.replace(/[&<>"']/g, m => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
})[m]);
}
};
window.IllWill = IllWill;
mw.loader.using(['mediawiki.util', 'mediawiki.api', 'jquery.textSelection'], () => {
$(() => IllWill.setup());
});
})();
(function() {
'use strict';
const config = {
enableNS14: true,
rootWhitelists: ['Materials'],
userWhitelist: ""
};
const categoryDataCache = {};
let scanProgress = 0;
let totalToScan = 0;
async function verifyTreeMembership() {
if (!config.enableNS14) return false;
const api = new mw.Api();
const currentTitle = mw.config.get("wgPageName").replace(/_/g, ' ');
const fullRoots = config.rootWhitelists.concat(config.userWhitelist.split(',').map(s => s.trim()).filter(s => s)).map(cat => (cat.startsWith('Category:') ? cat : `Category:${cat}`));
if (fullRoots.includes(currentTitle)) return true;
let categoriesToTable = mw.config.get('wgCategories') || [];
let checked = new Set();
while (categoriesToTable.length > 0) {
if (categoriesToTable.some(cat => fullRoots.includes('Category:' + cat))) {
return true;
}
categoriesToTable.forEach(cat => checked.add(cat));
try {
const res = await api.get({
formatversion: 2,
prop: 'categories',
titles: categoriesToTable.map(c => 'Category:' + c),
cllimit: 'max'
});
let nextBatch = [];
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.categories) {
page.categories.forEach(parent => {
const parentName = parent.title.substring('Category:'.length);
if (!checked.has(parentName)) {
nextBatch.push(parentName);
}
});
}
});
}
categoriesToTable = [...new Set(nextBatch)];
} catch (e) {
return false;
}
}
return false;
}
function getChemboxBlock(wikitext) {
const chemboxRegex = /\{\{\s*Chembox[\s_]*(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const match = wikitext.match(chemboxRegex);
return match ? match[0] : null;
}
function getFormulaFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const formulaMatch = chembox.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
let rawFormula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
rawFormula = rawFormula.split(/\s+/)[0];
return rawFormula.split(',')[0].trim();
}
const propRegex = /\{\{\s*Chembox[\s_]+Properties(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const propSection = chembox.match(propRegex);
if (propSection) {
const content = propSection[0];
const allElements = ['Ac', 'Ag', 'Al', 'Am', 'Ar', 'As', 'At', 'Au', 'B', 'Ba', 'Be', 'Bh', 'Bi', 'Bk', 'Br', 'C', 'Ca', 'Cd', 'Ce', 'Cf', 'Cl', 'Cm', 'Cn', 'Co', 'Cr', 'Cs', 'Cu', 'D', 'Db', 'Ds', 'Dy', 'Er', 'Es', 'Eu', 'F', 'Fe', 'Fl', 'Fm', 'Fr', 'Ga', 'Gd', 'Ge', 'H', 'He', 'Hf', 'Hg', 'Ho', 'Hs', 'I', 'In', 'Ir', 'K', 'Kr', 'La', 'Li', 'Lr', 'Lu', 'Lv', 'Mc', 'Md', 'Mg', 'Mn', 'Mo', 'Mt', 'N', 'Na', 'Nb', 'Nd', 'Ne', 'Nh', 'Ni', 'No', 'Np', 'O', 'Og', 'Os', 'P', 'Pa', 'Pb', 'Pd', 'Pm', 'Po', 'Pr', 'Pt', 'Pu', 'Ra', 'Rb', 'Re', 'Rf', 'Rg', 'Rh', 'Rn', 'Ru', 'S', 'Sb', 'Sc', 'Se', 'Sg', 'Si', 'Sm', 'Sn', 'Sr', 'Ta', 'Tb', 'Tc', 'Te', 'Th', 'Ti', 'Tl', 'Tm', 'Ts', 'U', 'V', 'W', 'Xe', 'Y', 'Yb', 'Zn', 'Zr'];
let data = {};
allElements.forEach(el => {
const elRegex = new RegExp(`\\|\\s*${el}\\s*=\\s*(\\d*\\.?\\d+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch && elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
});
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
elementsPresent.filter(el => !(hasC && (el === "C" || el === "H"))).sort().forEach(el => {
formulaArray.push(el + data[el]);
});
return formulaArray.join('');
}
}
return null;
}
function getCASFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const idRegex = /\{\{\s*Chembox[\s_]+Identifiers(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const idSection = chembox.match(idRegex);
if (!idSection) return null;
const content = idSection[0];
const nbsp = String.fromCharCode(160);
const casMatch = content.match(/\|\s*CASNo\d*\b(?!\s*_)\s*=\s*([^|}\n]+)/i);
if (casMatch) {
let val = casMatch[1].replace(/\{\{[^}]+\}\}/g, "").replace(/\u00AD/g, "").split(" ").join("");
while (val.indexOf(nbsp) !== -1) val = val.replace(nbsp, "");
while (val.indexOf(" ") !== -1) val = val.replace(" ", "");
const cleanedCas = val.trim();
return /^\d{2,7}-\d{2}-\d$/.test(cleanedCas) ? cleanedCas : null;
}
return null;
}
async function getWikidataValue() {
const qid = mw.config.get('wgWikidataItemId');
if (!qid) return null;
const api = new mw.ForeignApi('https://www.wikidata.org/w/api.php');
const res = await api.get({
action: 'wbgetentities',
ids: qid,
props: 'claims',
format: 'json',
formatversion: 2
});
const claims = res.entities[qid].claims;
const data = {};
if (claims.P274) data.formula = claims.P274[0].mainsnak.datavalue.value;
if (claims.P231) data.cas = claims.P231[0].mainsnak.datavalue.value;
return data;
}
async function startBackgroundCategoryScan() {
const api = new mw.Api();
const titles = $('#mw-pages li a').map((i, el) => $(el).attr('title') || $(el).text()).get();
totalToScan = titles.length;
if (totalToScan === 0) return;
const chunks = arrayChunk(titles, 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: chunk,
rvprop: 'content',
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const f = getFormulaFromWikitext(wikitext);
const c = getCASFromWikitext(wikitext);
if (f || c) categoryDataCache[page.title] = {
formula: f,
cas: c
};
});
}
scanProgress += chunk.length;
$('#caschemassist-cat-btn').text(`Scanning... ${scanProgress}/${totalToScan}`);
}
$('#caschemassist-cat-btn').text('Show missing CAS/Chem').prop('disabled', false);
}
async function renderCategoryAudit() {
const api = new mw.Api();
const missingCheckTitles = [];
Object.values(categoryDataCache).forEach(data => {
if (data.formula) missingCheckTitles.push(data.formula);
if (data.cas) missingCheckTitles.push(data.cas);
});
if (missingCheckTitles.length === 0) {
mw.notify("No valid Chembox data found in this category.");
return;
}
const existenceMap = {};
const chunks = arrayChunk([...new Set(missingCheckTitles)], 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
titles: chunk,
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(p => {
existenceMap[p.title] = !p.missing;
});
}
}
$('#mw-pages li').each(function() {
const title = $(this).find('a').attr('title') || $(this).find('a').text().trim();
const data = categoryDataCache[title];
if (!data) return;
if (data.formula && !existenceMap[data.formula]) {
$(this).append($('<span>').css({
'color': 'red',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [${data.formula}]`));
}
if (data.cas && !existenceMap[data.cas]) {
$(this).append($('<span>').css({
'color': 'darkred',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [CAS: ${data.cas}]`));
}
});
$('#caschemassist-cat-btn').text('Audit complete').prop('disabled', true);
}
async function handleSingleArticle() {
const api = new mw.Api();
const currentTitle = mw.config.get("wgTitle");
const [wikiRes, wikidata] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
getWikidataValue()
]);
const page = wikiRes.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const formula = getFormulaFromWikitext(wikitext);
const cas = getCASFromWikitext(wikitext);
if (wikidata) {
let mismatches = [];
if (formula && wikidata.formula && formula !== wikidata.formula) {
mismatches.push(`Formula mismatch: Chembox "${formula}" vs Wikidata "${wikidata.formula}"`);
}
if (cas && wikidata.cas && cas !== wikidata.cas) {
mismatches.push(`CAS mismatch: Chembox "${cas}" vs Wikidata "${wikidata.cas}"`);
}
if (mismatches.length > 0) {
const $warning = $('<div>').css({
'background': '#fff2f2',
'border': '2px solid #d33',
'padding': '10px',
'margin-bottom': '1em',
'color': '#b32424'
}).append($('<strong>').text('⚠️ Data Mismatch Detected: '), mismatches.join(' | '));
$('#mw-content-text').prepend($warning);
}
}
async function checkAndRender(target, label, customWikitext) {
const check = await api.get({
action: 'query',
titles: target,
formatversion: 2
});
if (check.query.pages[0].missing) {
const $container = $('<div>').css({
'background': '#f8f9fa',
'border': '1px solid #a2a9b1',
'padding': '10px',
'margin-bottom': '1em'
});
const $btn = $('<button>').text(`Create redirect: ${target}`).click(async function() {
await api.postWithEditToken({
action: 'edit',
title: target,
text: customWikitext,
summary: `Creating redirect to [[${currentTitle}]] (${label})`,
createonly: true
});
mw.notify(`Created redirect: ${target}`);
$container.fadeOut(400, function() {
$(this).remove();
});
});
$container.append($('<strong>').text(`${label}: `), `Missing redirect. `, $btn);
$('#mw-content-text').prepend($container);
}
}
if (formula && formula !== currentTitle) {
const formulaWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from chemical formula}}\n}}`;
await checkAndRender(formula, "Chemical formula", formulaWikitext);
}
if (cas && cas !== currentTitle) {
const casWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from CAS Registry Number}}\n{{R unprintworthy}}\n}}`;
await checkAndRender(cas, "CAS number", casWikitext);
}
}
function arrayChunk(arr, size) {
const result = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
$(document).ready(async function() {
const ns = mw.config.get("wgNamespaceNumber");
if (ns === 14) {
if (await verifyTreeMembership()) {
const $catContainer = $('#mw-pages');
if ($catContainer.length) {
const $btn = $('<button>').text('Initializing scan...').attr('id', 'caschemassist-cat-btn').addClass('mw-ui-button').css({
'margin': '0 10px 10px 0',
'float': 'right',
'position': 'relative',
'z-index': '100'
}).prop('disabled', true).click(renderCategoryAudit);
$catContainer.prepend($btn);
startBackgroundCategoryScan();
}
}
} else if (ns === 0 && mw.config.get("wgAction") === "view") {
handleSingleArticle();
}
});
})();
//</nowiki>
on45mry8tkkq7mfct0d0223z86i2aik
739290
739289
2026-04-24T21:40:03Z
Sam Sailor
26820
Test
739290
javascript
text/javascript
//<nowiki>
(function() {
mw.util.addCSS(`
.ambox-Orphan { display: block !important; }
.comrade-container {
box-shadow: 0 1px 3px rgba(0,0,0,0.1); border-radius: 2px;
padding: 10px 15px; margin: 10px 0; display: flex;
flex-direction: column; align-items: flex-start; gap: 8px;
border: 1px solid #a2a9b1; background: #fcfcfc; line-height: 1.5;
}
.comrade-header { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.comrade-content { display: flex; flex-direction: column; gap: 5px; width: 100%; }
.comrade-success { background-color: #d5f5e3 !important; border-left: 5px solid #27ae60 !important; color: #1b5e20 !important; }
.comrade-nudge { background: #fff9ea; border-left: 5px solid #f0ad4e; }
.comrade-info { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-status { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-container button:not(.comrade-dismiss) {
background: #f8f9fa; border: 1px solid #a2a9b1; border-radius: 2px;
padding: 2px 10px; cursor: pointer; font-weight: bold; font-size: 0.95em; margin-left: 10px;
}
.comrade-container button:not(.comrade-dismiss):hover { border-color: #36c; color: #36c; background: #fff; }
.comrade-dismiss {
background: transparent; border: none; color: #d33; font-weight: bold;
cursor: pointer; font-size: 0.9em;
}
.comrade-dismiss:hover { text-decoration: underline; }
.comrade-code-block { background: #f1f1f1; padding: 4px 8px; border-radius: 3px; font-family: monospace; display: inline-block; margin-top: 4px; }
`);
function appendDismiss($el) {
$('<button>').addClass('comrade-dismiss').text('Dismiss').on('click', function() {
$(this).closest('.comrade-container').fadeOut();
}).appendTo($el.find('.comrade-header'));
$("#siteSub").after($el);
}
async function checkNamingPrecision(currentTitle, isHuman) {
const disambigMatch = currentTitle.match(/^(.+?)(?:, | \()/);
if (isHuman || !disambigMatch) return;
const baseTitle = disambigMatch[1].trim();
const api = new mw.Api();
try {
const baseRes = await api.get({
action: 'query',
titles: baseTitle,
redirects: 0,
formatversion: 2
});
const basePage = baseRes.query.pages[0];
const baseExists = !basePage.missing;
let baseRedirectsToSelf = false;
if (baseExists && basePage.redirect) {
const redirRes = await api.get({
action: 'query',
titles: baseTitle,
redirects: 1,
formatversion: 2
});
if (redirRes.query.pages[0].title === currentTitle) {
baseRedirectsToSelf = true;
}
}
const searchRes = await api.get({
action: 'query',
list: 'allpages',
apprefix: baseTitle,
aplimit: 50,
formatversion: 2
});
const competitors = searchRes.query.allpages.filter(p => {
const t = p.title;
const isCurrent = t === currentTitle;
const isBase = t === baseTitle;
const isDisambiguatedVersion = t.startsWith(baseTitle + ', ') || t.startsWith(baseTitle + ' (');
return !isCurrent && !isBase && isDisambiguatedVersion;
});
if (!baseExists || baseRedirectsToSelf) {
const $container = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (competitors.length === 0) {
$header.append($('<strong>').text('Precision:'));
const reason = baseRedirectsToSelf ? "already redirects here" : "is free";
$content.append($('<span>').append(`The base name `, $('<code>').text(baseTitle), ` ${reason}. Consider moving this article.`));
const moveUrl = mw.util.getUrl('Special:MovePage/' + currentTitle, {
wpNewTitle: baseTitle,
wpReason: 'Unnecessary disambiguation; base name is free/redirects here per [[WP:PRECISION]]'
});
$content.append($('<button>').text(`Move to ${baseTitle}`).on('click', () => window.open(moveUrl, '_blank')));
} else {
$header.append($('<strong>').text('Observation:'));
$content.append($('<span>').append(`Base name `, $('<code>').text(baseTitle), ` has ${competitors.length} competitors, but it is currently a ${baseRedirectsToSelf ? 'redirect to this page' : 'redlink'}.`));
$content.append($('<span>').text('Consider if a disambiguation page is needed.'));
}
$container.append($header, $content);
appendDismiss($container);
}
} catch (e) {
console.warn("Comrade: Precision check failed", e);
}
}
async function checkAndRenderRedirect(redirTitle, targetTitle, rCategory, labelText, customWikitext) {
const api = new mw.Api();
const res = await api.get({
action: 'query',
titles: redirTitle,
formatversion: 2
});
if (res.query.pages[0].missing) {
const safeId = "comrade-redir-" + redirTitle.replace(/[^a-z0-9]/gi, '-');
const $container = $('<div>').addClass('comrade-container comrade-info').attr('id', safeId);
const $header = $('<div>').addClass('comrade-header');
const summary = `Created redirect from ${labelText.toLowerCase()} to [[${targetTitle}]]`;
const $btn = $('<button>').text('Create redirect').on('click', () => createModernRedirect(redirTitle, targetTitle, rCategory, summary, safeId, customWikitext));
$header.append($('<div>').append($('<strong>').text(`${labelText}: `), `Create redirect from `, $('<code>').text(redirTitle), $btn));
$container.append($header);
appendDismiss($container);
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
if (qid) {
try {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
});
const res = await fetch(url).then(r => r.json());
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
}
} catch (e) {
console.warn("Comrade: Wikidata API call failed.");
}
}
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
if (domain.includes(".") && url.pathname === "/") {
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
try {
const response = await fetch(citationURL);
if (response.ok) {
await checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
}
} catch (e) {
console.warn("Comrade: Citation API check failed.");
}
}
} catch (e) {
console.warn("Comrade: Error parsing URL object.");
}
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan, resolvedTitle) {
const api = new mw.Api();
const candidates = resolvedTitle ? [resolvedTitle] : [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
redirects: 0
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const foundTitle = page.title;
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) break;
const $container = $('<div>').addClass('comrade-container');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (isOrphan) {
$container.addClass('comrade-nudge');
$header.append($('<strong>').text('Comrade nudge:'));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
}
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(foundTitle),
target: '_blank'
}).text(foundTitle), "; should it be?"));
const snippet = '* {{anbl|' + currentTitle + '}}';
const $snippetWrapper = $('<div>').css({
'display': 'flex',
'align-items': 'center',
'gap': '10px',
'margin-top': '4px'
});
const $instructionSpan = $('<span>').append('If the Short description is good, consider adding ', $('<code>').text(snippet).css({
'background-color': '#f8f9fa',
'border': '1px solid #eaecf0',
'border-radius': '2px',
'padding': '1px 4px'
}));
const $copyBtn = $('<button>').text('Copy & insert').css('margin-left', '0').on('click', function() {
navigator.clipboard.writeText(snippet).then(() => {
const $this = $(this);
const originalText = $this.text();
$this.text('Copied!').prop('disabled', true);
const editUrl = mw.util.getUrl(foundTitle, {
action: 'edit',
summary: snippet
});
window.open(editUrl, '_blank');
setTimeout(() => {
$this.text(originalText).prop('disabled', false);
}, 1500);
});
});
$snippetWrapper.append($instructionSpan, $copyBtn);
$content.append($snippetWrapper);
if (!hasShortDesc) {
$content.append($('<span>').css({
'color': '#d33',
'font-weight': 'bold',
'margin-top': '5px'
}).text('⚠️ Missing short description: Please add a concise one before listing.'));
}
$container.append($header, $content);
appendDismiss($container);
break;
}
}
}
}
async function createModernRedirect(redirTitle, targetTitle, rCategory, customSummary, elementId, customWikitext) {
const api = new mw.Api();
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
try {
await api.postWithEditToken({
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
});
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
} catch (e) {
mw.notify("Failed to create redirect.", {
type: 'error'
});
}
}
function getSmartSortKey(fullName, entity) {
const countryIds = entity.claims?.P27?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const locationIds = entity.claims?.P17?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const placeIds = [...(entity.claims?.P19 || []), ...(entity.claims?.P119 || [])].map(c => c.mainsnak?.datavalue?.value?.id).filter(Boolean);
const allRelevantIds = [...countryIds, ...locationIds, ...placeIds];
const birthYear = parseInt(entity.claims?.P569?.[0]?.mainsnak?.datavalue?.value?.time?.match(/[+-](\d{4})/)?.[1]) || null;
if (fullName.startsWith("Saint ")) return fullName.replace("Saint ", "");
const arabicParticles = /\b(Abu|Abd|Abdel|Abdul|ben|bin|bint)\b/i;
if (fullName.match(arabicParticles)) {
if (birthYear && birthYear > 1900) {
const parts = fullName.split(' ');
const binIndex = parts.findIndex(p => p.toLowerCase() === 'bin' || p.toLowerCase() === 'ben');
if (binIndex > 0) return parts.slice(binIndex).join(' ') + ', ' + parts.slice(0, binIndex).join(' ');
} else {
return fullName;
}
}
const eastAsianQids = ["Q148", "Q184", "Q884", "Q424", "Q881", "Q17"];
if (allRelevantIds.some(id => eastAsianQids.includes(id))) {
if (allRelevantIds.includes("Q17") && birthYear && birthYear > 1885) return westernSort(fullName);
const parts = fullName.split(' ');
if (parts.length > 1) return `${parts[0]}, ${parts.slice(1).join(' ')}`;
}
return westernSort(fullName);
}
function westernSort(name) {
let cleanName = name;
if (name.startsWith("O'")) cleanName = name.replace("O'", "O");
const parts = cleanName.split(' ');
if (parts.length < 2) return cleanName;
const surname = parts.pop();
if (["Jr.", "Jr", "III", "II", "Sr.", "Sr"].includes(surname)) {
const actualSurname = parts.pop();
return `${actualSurname}, ${parts.join(' ')} ${surname}`;
}
return `${surname}, ${parts.join(' ')}`;
}
async function performDeorphan() {
const api = new mw.Api();
try {
await api.edit(mw.config.get('wgPageName'), function(rev) {
let text = rev.content;
const summary = 'Article has backlinks; removed [[Template:Orphan|{{Orphan}}]]';
text = text.replace(/\{\{Orphan\s*(?:\|[^}]*)?\}\}\s*/gi, '');
text = text.replace(/(\{\{Multiple[ _]issues\s*)\|\s*\|/gi, '$1|');
text = replaceMI(text);
text = text.replace(/\n{3,}/g, '\n\n').trim();
return {
text,
summary,
minor: true
};
});
mw.notify('Orphan tag removed!', {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
setTimeout(() => location.reload(), 700);
} catch (e) {
mw.notify('Failed to remove orphan tag.', {
type: 'error'
});
}
}
function replaceMI(text) {
const miRegex = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miRegex.exec(text)) !== null) {
const start = match.index;
result += text.slice(lastIndex, start);
let depth = 0;
let i = start;
while (i < text.length) {
if (text[i] === '{' && text[i + 1] === '{') {
depth++;
i += 2;
} else if (text[i] === '}' && text[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
const end = i;
const full = text.slice(start, end);
const pipeIdx = full.indexOf('|');
const inner = full.slice(pipeIdx + 1, full.length - 2).trim();
const topLevel = extractTopLevelTemplates(inner);
if (topLevel.length === 0) {
result += '';
} else if (topLevel.length === 1) {
result += topLevel[0].trim();
} else {
result += full;
}
lastIndex = end;
}
result += text.slice(lastIndex);
return result;
}
function extractTopLevelTemplates(inner) {
const templates = [];
let i = 0;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
let depth = 0;
const start = i;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
depth++;
i += 2;
} else if (inner[i] === '}' && inner[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
templates.push(inner.slice(start, i));
} else {
i++;
}
}
return templates;
}
async function performSortCorrection(newName, type) {
const api = new mw.Api();
const title = mw.config.get("wgPageName");
try {
const data = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2
});
let content = data.query.pages[0].revisions[0].content;
const dsRegex = /\{\{(?:DEFAULTSORT|Defaultsort):[^}]+\}\}/i;
const newTag = `{{DEFAULTSORT:${newName}}}`;
let actionDesc = type === "add" ? "added" : "corrected";
if (dsRegex.test(content)) {
content = content.replace(dsRegex, newTag);
} else {
const catRegex = /\[\[Category:/i;
if (catRegex.test(content)) {
content = content.replace(catRegex, `${newTag}\n[[Category:`);
} else {
content += `\n\n${newTag}`;
}
}
await api.postWithEditToken({
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName}`,
nocreate: true
});
mw.notify(`DEFAULTSORT ${actionDesc} to ${newName}!`, {
title: 'Comrade Success',
type: 'success'
});
setTimeout(() => location.reload(), 700);
} catch (err) {
mw.notify('Failed to save DEFAULTSORT.', {
type: 'error'
});
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
function renderSortNudge(target, reason) {
const $dsNudge = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Correct to: ${target}`).on('click', () => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
async function fetchWikidataNameLabels(entity) {
const familyIds = entity.claims?.P734?.map(c => c.mainsnak.datavalue.value.id) || [];
const givenIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
const allNameIds = [...new Set([...familyIds, ...givenIds])];
if (allNameIds.length === 0) return {
givens: [],
surnames: []
};
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: allNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
});
const nameData = await fetch(url).then(r => r.json());
return {
givens: givenIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean),
surnames: familyIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean)
};
}
function fixNameCasing(part, referenceName) {
return part.split(' ').map(word => {
const match = referenceName.match(new RegExp(`\\b${word}\\b`, 'i'));
if (match) return match[0];
return referenceName.split(' ').find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase())) || word;
}).join(' ');
}
function getLongNameFromWikitext(wikitext) {
const firstLine = wikitext.split('\n').find(l => l.includes("'''"));
if (!firstLine) return null;
const boldMatch = firstLine.match(/'''(.+?)'''/);
if (!boldMatch) return null;
return boldMatch[1].replace(/\s*([“"«《„‹「『'].+?[”"»》”›」』']|\([^)\n]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
}
async function init() {
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") return;
const api = new mw.Api();
const qid = mw.config.get("wgWikibaseItemId");
try {
const [pageData, backlinkData] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
api.get({
action: 'query',
list: 'backlinks',
blfilterredir: 'nonredirects',
bllimit: 50,
blnamespace: 0,
bltitle: mw.config.get("wgPageName"),
formatversion: 2
})
]);
const page = pageData.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const currentTitle = mw.config.get("wgTitle");
const cleanTitle = stripDiacritics(currentTitle);
const isOrphanTagged = /\{\{\s*(Orphan|Do-attempt|Lonely|Orp|Orphaned article)/i.test(wikitext) || /\| *orphan *=/i.test(wikitext);
const backLinkCount = backlinkData.query.backlinks.length;
if (isOrphanTagged && backLinkCount >= 1) {
const $container = $('<div>').addClass('comrade-container comrade-status');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text('Remove orphan tag').on('click', () => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) await checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
await checkDomainRedirect(qid, currentTitle);
let isHuman = false;
let entity = null;
if (qid) {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
});
const wikiData = await fetch(url).then(r => r.json());
entity = wikiData.entities[qid];
isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
}
await checkNamingPrecision(currentTitle, isHuman);
if (qid && entity) {
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const nameParts = tClean.split(' ');
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
const isOrphan = isOrphanTagged && backLinkCount < 1;
const primarySurname = hasFamilyNameHatnote ? nameParts[0] : (getSmartSortKey(tClean, entity).split(',')[0] || nameParts[nameParts.length - 1]);
let targetSortName = "";
if (isHuman) {
const {
givens,
surnames
} = await fetchWikidataNameLabels(entity);
const refName = entity.labels?.en?.value || currentTitle;
if (dsMatch) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixNameCasing(lastPart, refName)}, ${fixNameCasing(firstPart, refName)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field.");
} else if (givens.length > 0 && lastPart.split(' ').length > 1) {
const misplacedGiven = lastPart.split(' ').find(part => givens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastPart.split(' ').filter(p => p !== misplacedGiven).join(' ');
targetSortName = `${fixNameCasing(newSurname, refName)}, ${fixNameCasing(misplacedGiven + ' ' + firstPart, refName)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
}
}
} else {
targetSortName = hasFamilyNameHatnote ? `${nameParts[0]}, ${nameParts.slice(1).join(' ')}` : getSmartSortKey(tClean, entity);
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).on('click', () => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
const longName = getLongNameFromWikitext(wikitext);
if (longName && longName.length > tClean.length) {
let sortKey = hasFamilyNameHatnote ? `${longName.split(' ')[0]}, ${longName.split(' ').slice(1).join(' ')}` : getSmartSortKey(longName, entity);
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
await checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
if (targetSortName) {
let targetPage = currentTitle.includes(' (') ? currentTitle.split(' (')[0] : currentTitle;
let rCat = targetPage !== currentTitle ? "R from ambiguous sort name" : "R from sort name";
if (targetSortName.includes(',')) {
const p = targetSortName.split(',');
rCat += `|${p[0].trim().charAt(0).toUpperCase()}|${p[1].trim().charAt(0).toUpperCase()}`;
}
await checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name");
}
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
surnames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
givens.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) finalGivenNames.add(gn);
});
if (finalFamilyNames.size === 0 && nameParts.length >= 2) finalFamilyNames.add(nameParts[nameParts.length - 1]);
if (finalGivenNames.size === 0 && nameParts.length >= 2) finalGivenNames.add(nameParts[0]);
const getPriorityTarget = async (name, type) => {
const titles = type === 'surname' ? [`List of people with surname ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`] : [`List of people with given name ${name}`, `${name} (given name)`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
return titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
};
for (const name of finalGivenNames) {
if (name.toLowerCase() === primarySurname.toLowerCase()) continue;
const target = await getPriorityTarget(name, 'given');
if (target) await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
for (const name of finalFamilyNames) {
const titles = [name, `${name} (surname)`, `List of people with surname ${name}`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
const target = titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
if (target) await checkNameList(name, 'surname', currentTitle, hasShortDesc, isOrphan, target);
}
}
}
} catch (err) {
console.error("Comrade error:", err);
}
}
init();
})();
(function() {
if (window.IllWill) return;
const STRIKEOUT = true;
const HIDE = 'hide';
const IllWill = {
Version: "1.1.1",
Attribution: 'created by <a target="_blank" href="/wiki/User:Cobaltcigs">cobaltcigs</a>',
Summary: "Added interlanguage links using IllWill.js",
DataApi: "https://www.wikidata.org/w/api.php",
LocalLang: mw.config.get("wgPageContentLanguage"),
Disambiguator: / \(.*\)$/,
SiteLinks: {},
Config: {
MaxLanguages: 5,
IgnoreScientific: STRIKEOUT,
AutoDiff: true
},
css: `
#illwill { clear: both; margin-bottom: 1.5em; border: 1px solid #a2a9b1; padding: 15px; background: #f8f9fa; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.ill-sitelinks a { display: inline-block; margin-bottom: 3px; font-size: 0.9em; color: #36c; }
.ill-size-tag { font-size: 0.8em; color: #72777d; margin-left: 6px; white-space: nowrap; }
.illwill-ignore { text-decoration: line-through; color: #72777d; font-size: 0.85em; }
.ill-x { width: 35px; text-align: center; }
.ill-input, .ill-output { font-family: 'Courier New', monospace; white-space: pre-wrap; font-size: 0.9em; background: #fff; border: 1px solid #eaecf0; padding: 4px; border-radius: 2px; }
#ill-buttons { text-align: right; padding: 10px; border-top: 1px solid #a2a9b1; margin-top: 10px; }
#ill-buttons button { margin-left: 10px; cursor: pointer; padding: 6px 16px; border-radius: 2px; border: 1px solid #a2a9b1; transition: all 0.2s; }
#ill-ok { background: #36c; color: #fff; border-color: #36c !important; font-weight: bold; }
#ill-ok:hover { background: #447ff5; }
#ill-ok:disabled { background: #eaecf0; color: #72777d; border-color: #c8ccd1 !important; cursor: not-allowed; opacity: 0.7; }
#ill-table { width: 100%; border-collapse: collapse; margin-top: 10px; background: white; }
#ill-table th { background: #f2f2f2; text-align: left; padding: 8px; border: 1px solid #a2a9b1; }
#ill-table td { padding: 8px; border: 1px solid #a2a9b1; vertical-align: top; }
#ill-table caption { background: #eaffea; font-weight: bold; padding: 10px; border: 1px solid #a2a9b1; border-bottom: none; font-size: 1.1em; }
#ill-help { font-size: 0.85em; color: #54595d; padding: 10px; line-height: 1.4; }
.ill-loading-inline { font-style: italic; color: #72777d; font-size: 0.9em; display: block; margin: 5px 0; }
`,
setup() {
if (!["edit", "submit"].includes(mw.config.get('wgAction'))) return;
mw.loader.addStyleTag(this.css);
const link = mw.util.addPortletLink('p-cactions', '#', 'IllWill', 'ca-illwill', "Add interlanguage links via Wikidata");
if (link) {
link.addEventListener('click', (e) => {
e.preventDefault();
this.makePanel();
});
}
},
norm(s) {
if (!s) return "";
const t = s.trim().replace(/[\s_]+/g, " ");
return t.charAt(0).toUpperCase() + t.slice(1);
},
async getRedLinks($textbox) {
const txt = $textbox.textSelection('getContents') || "";
const wl = txt.match(/\[\[[^[\]\n]+\]\]/g);
if (!wl) return {
reds: [],
txt
};
const api = new mw.Api();
const textToParse = wl.join("").replace(/\|[^\]]+/g, "");
try {
const data = await api.post({
action: 'parse',
text: textToParse,
contentmodel: 'wikitext',
formatversion: 2
});
const tempDiv = document.createElement('div');
tempDiv.innerHTML = data.parse.text;
const reds = Array.from(tempDiv.querySelectorAll("a.new")).map(x => {
const urlParams = new URLSearchParams(x.href.split('?')[1]);
return urlParams.get('title')?.replace(/_/g, " ");
}).filter(Boolean);
return {
reds: [...new Set(reds)],
txt
};
} catch (e) {
return {
reds: [],
txt
};
}
},
async makePanel() {
const $editform = $('#editform');
const $textbox = $('#wpTextbox1');
$('#illwill').remove();
$editform.hide();
const $wrapper = $('<div id="illwill">Loading redlinks...</div>').insertBefore($editform);
const {
reds,
txt
} = await this.getRedLinks($textbox);
const wtLinks = [];
reds.forEach(title => {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s/g, "[\\s_]+");
const re = new RegExp("\\[\\[\\s*" + escaped + "\\s*(?:\\|[^\\[\\]]+)?\\]\\]", "ig");
const match = txt.match(re);
if (match && !wtLinks.some(z => z.title === title)) {
wtLinks.push({
link: match[0],
title
});
}
});
if (!wtLinks.length) {
const $back = $('<button>Quit</button>').on('click', () => {
$wrapper.remove();
$editform.show();
});
$wrapper.empty().append($('<strong>No valid redlinks found in text.</strong> '), $back);
return;
}
const $table = $(`
<table id="ill-table" class="wikitable">
<caption>IllWill.js (v${this.Version})</caption>
<thead>
<tr>
<th class="ill-x"><input type="checkbox" id="ill-select-all" disabled></th>
<th>Input link</th>
<th>Possible Wikidata matches</th>
<th>Sitelinks (top ${this.Config.MaxLanguages} by size)</th>
<th>Resulting wikitext</th>
</tr>
</thead>
<tbody></tbody>
<tfoot>
<tr>
<td colspan="3" id="ill-help">Carefully examine possible Wikidata matches.</td>
<td colspan="2" id="ill-buttons">
<button id="ill-cancel">Cancel</button>
<button id="ill-ok" disabled>Show changes</button>
</td>
</tr>
</tfoot>
</table>
`);
const $tbody = $table.find('tbody');
wtLinks.forEach((item, i) => {
$tbody.append(`
<tr id="ill-row-${i}" data-index="${i}">
<td class="ill-x"><input type="checkbox" class="ill-row-check" disabled></td>
<td class="ill-input">${this.escapeHTML(item.link)}</td>
<td class="ill-wd-item"><span class="ill-loading-inline">Searching...</span></td>
<td class="ill-sitelinks"></td>
<td class="ill-output"></td>
</tr>
`);
});
$wrapper.empty().append($table);
const chunkSize = 5;
for (let i = 0; i < wtLinks.length; i += chunkSize) {
const chunk = wtLinks.slice(i, i + chunkSize);
await Promise.all(chunk.map((item, idx) => this.fetchWikidataOptimized(item.title, i + idx)));
}
$table.on('click', '#ill-cancel', () => {
$editform.show();
$wrapper.remove();
});
$table.on('click', '#ill-ok', () => this.onOkButton($editform, $wrapper, $textbox));
$table.on('change', '#ill-select-all', (e) => {
const isChecked = $(e.target).is(':checked');
$table.find('.ill-row-check:not(:disabled)').each(function() {
$(this).prop('checked', isChecked).trigger('change');
});
});
$table.on('change', '.ill-row-check', (e) => {
this.onCheckbox(e);
this.updateOkButtonState($table);
});
$table.on('change', 'input[type="radio"]', (e) => {
this.showSiteLinks(e);
});
},
async fetchWikidataOptimized(title, index) {
const query = title.replace(this.Disambiguator, "");
const url = `${this.DataApi}?action=wbsearchentities&search=${encodeURIComponent(query)}&language=${this.LocalLang}&limit=5&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
const results = data.search;
const $cell = $(`#ill-row-${index} .ill-wd-item`);
if (!results || !results.length) {
$cell.html('<span class="ill-na">(no results)</span>');
return;
}
const entityIds = results.map(r => r.id);
await this.fetchEntities(entityIds, index, $cell);
} catch (e) {
$(`#ill-row-${index} .ill-wd-item`).text("Search failed.");
}
},
async fetchEntities(ids, rowIndex, $cell) {
const url = `${this.DataApi}?action=wbgetentities&ids=${ids.join('|')}&props=labels|descriptions|claims|sitelinks&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
$cell.empty();
ids.forEach(qNum => {
const entity = data.entities[qNum];
if (!entity) return;
const isScientific = entity.claims?.P31?.some(s => s.mainsnak?.datavalue?.value?.id === "Q13442814");
if (isScientific && this.Config.IgnoreScientific === HIDE) return;
const desc = entity.descriptions?.[this.LocalLang]?.value || entity.descriptions?.en?.value || "";
const label = entity.labels?.[this.LocalLang]?.value || entity.labels?.en?.value || qNum;
const isStrikeout = isScientific && this.Config.IgnoreScientific === STRIKEOUT;
const $div = $(`
<div class="${isStrikeout ? 'illwill-ignore' : ''}">
<input type="radio" name="selection-${rowIndex}" value="${qNum}" id="radio-${qNum}" ${isStrikeout ? 'disabled' : ''}>
<label for="radio-${qNum}">
<a href="https://wikidata.org/wiki/${qNum}" target="_blank">${this.escapeHTML(label)}</a>:
<span class="ill-desc">${desc ? this.escapeHTML(desc) : '<i>no description</i>'}</span>
</label>
</div>
`);
$cell.append($div);
this.SiteLinks[qNum] = entity.sitelinks || {};
});
} catch (e) {
$cell.text("Detail fetch failed.");
}
},
updateOkButtonState($table) {
const $rows = $table.find('.ill-row-check:not(:disabled)');
const $checkedRows = $rows.filter(':checked');
const anyEnabled = $rows.length > 0;
$table.find('#ill-ok').prop('disabled', $checkedRows.length === 0);
const $master = $table.find('#ill-select-all');
$master.prop('disabled', !anyEnabled);
if (anyEnabled && $rows.length === $checkedRows.length) {
$master.prop('checked', true);
} else {
$master.prop('checked', false);
}
},
async showSiteLinks(e) {
const isManualToggling = e.fromCheckbox || false;
const qNum = e.target.value;
const $row = $(e.target).closest('tr');
const sitelinks = this.SiteLinks[qNum];
const $checkbox = $row.find('.ill-row-check');
const $sitelinkCell = $row.find('.ill-sitelinks');
const $outputCell = $row.find('.ill-output');
const $table = $('#ill-table');
const effectiveMax = Math.min(Math.max(this.Config.MaxLanguages, 3), 6);
const validKeys = Object.keys(sitelinks).filter(k => k.endsWith('wiki') && !k.includes('commons'));
$sitelinkCell.html('<span class="ill-loading-inline">Measuring articles...</span>');
$outputCell.empty();
if (!isManualToggling) {
$checkbox.prop('disabled', true).prop('checked', false);
}
const candidates = validKeys.slice(0, 25);
const sizeMap = [];
await Promise.all(candidates.map(async (key) => {
const lang = key.replace('wiki', '').replace(/_/g, '-');
const title = sitelinks[key].title;
const endpoint = `https://${lang}.wikipedia.org/w/api.php?action=query&titles=${encodeURIComponent(title)}&prop=info&format=json&origin=*`;
try {
const res = await fetch(endpoint).then(r => r.json());
const page = Object.values(res.query.pages)[0];
sizeMap.push({
lang,
title,
length: page.length || 0
});
} catch (err) {
sizeMap.push({
lang,
title,
length: 0
});
}
}));
sizeMap.sort((a, b) => b.length - a.length);
const topLinks = sizeMap.slice(0, effectiveMax);
$sitelinkCell.html(topLinks.map(item => {
const kbValue = Math.round(item.length / 1024);
return `<div><a href="https://${item.lang}.wikipedia.org/wiki/${encodeURIComponent(item.title)}" target="_blank">${item.lang}:${this.escapeHTML(item.title)}</a> <span class="ill-size-tag">(${kbValue} kB)</span></div>`;
}).join(""));
const inputWikitext = $row.find('.ill-input').text();
const match = inputWikitext.match(/\[\[(.*?)\]\]/);
if (match) {
const parts = match[1].split('|');
const targetPage = parts[0].trim();
const surfaceName = (parts[1] || parts[0]).trim();
const illLangs = topLinks.map(item => {
const isSameTitle = this.norm(item.title) === this.norm(targetPage);
return `|${item.lang}|${isSameTitle ? '' : item.title}`;
}).join("");
const ltParam = (this.norm(surfaceName) === this.norm(targetPage)) ? "" : `|lt=${surfaceName}`;
$outputCell.text(`{{ill|${targetPage}${ltParam}${illLangs}}}`);
}
$checkbox.prop('disabled', !topLinks.length);
if (!isManualToggling) {
$checkbox.prop('checked', !!topLinks.length);
}
this.updateOkButtonState($table);
},
onCheckbox(e) {
const $row = $(e.target).closest('tr');
const $radio = $row.find('input[type="radio"]:checked');
const isChecked = $(e.target).is(':checked');
if (isChecked) {
if ($radio.length) {
this.showSiteLinks({
target: $radio[0],
fromCheckbox: true
});
}
} else {
$row.find('.ill-sitelinks, .ill-output').empty();
}
},
onOkButton($editform, $wrapper, $textbox) {
let text = $textbox.textSelection('getContents');
let changed = false;
$wrapper.find('.ill-row-check:checked').each(function() {
const $row = $(this).closest('tr');
const input = $row.find('.ill-input').text();
const output = $row.find('.ill-output').text();
if (output) {
const escapedInput = input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
text = text.replace(new RegExp(escapedInput, "gi"), output);
changed = true;
}
});
if (changed) {
$('#wpSummary').val((i, v) => (v ? v + "; " : "") + this.Summary);
$textbox.textSelection('setContents', text);
$wrapper.remove();
$editform.show();
if (this.Config.AutoDiff) $('#wpDiff').click();
}
},
escapeHTML(str) {
return str.replace(/[&<>"']/g, m => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
})[m]);
}
};
window.IllWill = IllWill;
mw.loader.using(['mediawiki.util', 'mediawiki.api', 'jquery.textSelection'], () => {
$(() => IllWill.setup());
});
})();
(function() {
'use strict';
const config = {
enableNS14: true,
rootWhitelists: ['Materials'],
userWhitelist: ""
};
const categoryDataCache = {};
let scanProgress = 0;
let totalToScan = 0;
async function verifyTreeMembership() {
if (!config.enableNS14) return false;
const api = new mw.Api();
const currentTitle = mw.config.get("wgPageName").replace(/_/g, ' ');
const fullRoots = config.rootWhitelists.concat(config.userWhitelist.split(',').map(s => s.trim()).filter(s => s)).map(cat => (cat.startsWith('Category:') ? cat : `Category:${cat}`));
if (fullRoots.includes(currentTitle)) return true;
let categoriesToTable = mw.config.get('wgCategories') || [];
let checked = new Set();
while (categoriesToTable.length > 0) {
if (categoriesToTable.some(cat => fullRoots.includes('Category:' + cat))) {
return true;
}
categoriesToTable.forEach(cat => checked.add(cat));
try {
const res = await api.get({
formatversion: 2,
prop: 'categories',
titles: categoriesToTable.map(c => 'Category:' + c),
cllimit: 'max'
});
let nextBatch = [];
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.categories) {
page.categories.forEach(parent => {
const parentName = parent.title.substring('Category:'.length);
if (!checked.has(parentName)) {
nextBatch.push(parentName);
}
});
}
});
}
categoriesToTable = [...new Set(nextBatch)];
} catch (e) {
return false;
}
}
return false;
}
function getChemboxBlock(wikitext) {
const chemboxRegex = /\{\{\s*Chembox[\s_]*(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const match = wikitext.match(chemboxRegex);
return match ? match[0] : null;
}
function getFormulaFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const formulaMatch = chembox.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
let rawFormula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
rawFormula = rawFormula.split(/\s+/)[0];
return rawFormula.split(',')[0].trim();
}
const propRegex = /\{\{\s*Chembox[\s_]+Properties(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const propSection = chembox.match(propRegex);
if (propSection) {
const content = propSection[0];
const allElements = ['Ac', 'Ag', 'Al', 'Am', 'Ar', 'As', 'At', 'Au', 'B', 'Ba', 'Be', 'Bh', 'Bi', 'Bk', 'Br', 'C', 'Ca', 'Cd', 'Ce', 'Cf', 'Cl', 'Cm', 'Cn', 'Co', 'Cr', 'Cs', 'Cu', 'D', 'Db', 'Ds', 'Dy', 'Er', 'Es', 'Eu', 'F', 'Fe', 'Fl', 'Fm', 'Fr', 'Ga', 'Gd', 'Ge', 'H', 'He', 'Hf', 'Hg', 'Ho', 'Hs', 'I', 'In', 'Ir', 'K', 'Kr', 'La', 'Li', 'Lr', 'Lu', 'Lv', 'Mc', 'Md', 'Mg', 'Mn', 'Mo', 'Mt', 'N', 'Na', 'Nb', 'Nd', 'Ne', 'Nh', 'Ni', 'No', 'Np', 'O', 'Og', 'Os', 'P', 'Pa', 'Pb', 'Pd', 'Pm', 'Po', 'Pr', 'Pt', 'Pu', 'Ra', 'Rb', 'Re', 'Rf', 'Rg', 'Rh', 'Rn', 'Ru', 'S', 'Sb', 'Sc', 'Se', 'Sg', 'Si', 'Sm', 'Sn', 'Sr', 'Ta', 'Tb', 'Tc', 'Te', 'Th', 'Ti', 'Tl', 'Tm', 'Ts', 'U', 'V', 'W', 'Xe', 'Y', 'Yb', 'Zn', 'Zr'];
let data = {};
allElements.forEach(el => {
const elRegex = new RegExp(`\\|\\s*${el}\\s*=\\s*(\\d*\\.?\\d+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch && elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
});
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
elementsPresent.filter(el => !(hasC && (el === "C" || el === "H"))).sort().forEach(el => {
formulaArray.push(el + data[el]);
});
return formulaArray.join('');
}
}
return null;
}
function getCASFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const idRegex = /\{\{\s*Chembox[\s_]+Identifiers(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const idSection = chembox.match(idRegex);
if (!idSection) return null;
const content = idSection[0];
const nbsp = String.fromCharCode(160);
const casMatch = content.match(/\|\s*CASNo\d*\b(?!\s*_)\s*=\s*([^|}\n]+)/i);
if (casMatch) {
let val = casMatch[1].replace(/\{\{[^}]+\}\}/g, "").replace(/\u00AD/g, "").split(" ").join("");
while (val.indexOf(nbsp) !== -1) val = val.replace(nbsp, "");
while (val.indexOf(" ") !== -1) val = val.replace(" ", "");
const cleanedCas = val.trim();
return /^\d{2,7}-\d{2}-\d$/.test(cleanedCas) ? cleanedCas : null;
}
return null;
}
async function getWikidataValue() {
const qid = mw.config.get('wgWikidataItemId');
if (!qid) return null;
const api = new mw.ForeignApi('https://www.wikidata.org/w/api.php');
const res = await api.get({
action: 'wbgetentities',
ids: qid,
props: 'claims',
format: 'json',
formatversion: 2
});
const claims = res.entities[qid].claims;
const data = {};
if (claims.P274) data.formula = claims.P274[0].mainsnak.datavalue.value;
if (claims.P231) data.cas = claims.P231[0].mainsnak.datavalue.value;
return data;
}
async function startBackgroundCategoryScan() {
const api = new mw.Api();
const titles = $('#mw-pages li a').map((i, el) => $(el).attr('title') || $(el).text()).get();
totalToScan = titles.length;
if (totalToScan === 0) return;
const chunks = arrayChunk(titles, 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: chunk,
rvprop: 'content',
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const f = getFormulaFromWikitext(wikitext);
const c = getCASFromWikitext(wikitext);
if (f || c) categoryDataCache[page.title] = {
formula: f,
cas: c
};
});
}
scanProgress += chunk.length;
$('#caschemassist-cat-btn').text(`Scanning... ${scanProgress}/${totalToScan}`);
}
$('#caschemassist-cat-btn').text('Show missing CAS/Chem').prop('disabled', false);
}
async function renderCategoryAudit() {
const api = new mw.Api();
const missingCheckTitles = [];
Object.values(categoryDataCache).forEach(data => {
if (data.formula) missingCheckTitles.push(data.formula);
if (data.cas) missingCheckTitles.push(data.cas);
});
if (missingCheckTitles.length === 0) {
mw.notify("No valid Chembox data found in this category.");
return;
}
const existenceMap = {};
const chunks = arrayChunk([...new Set(missingCheckTitles)], 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
titles: chunk,
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(p => {
existenceMap[p.title] = !p.missing;
});
}
}
$('#mw-pages li').each(function() {
const title = $(this).find('a').attr('title') || $(this).find('a').text().trim();
const data = categoryDataCache[title];
if (!data) return;
if (data.formula && !existenceMap[data.formula]) {
$(this).append($('<span>').css({
'color': 'red',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [${data.formula}]`));
}
if (data.cas && !existenceMap[data.cas]) {
$(this).append($('<span>').css({
'color': 'darkred',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [CAS: ${data.cas}]`));
}
});
$('#caschemassist-cat-btn').text('Audit complete').prop('disabled', true);
}
async function handleSingleArticle() {
const api = new mw.Api();
const currentTitle = mw.config.get("wgTitle");
const [wikiRes, wikidata] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
getWikidataValue()
]);
const page = wikiRes.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const formula = getFormulaFromWikitext(wikitext);
const cas = getCASFromWikitext(wikitext);
if (wikidata) {
let mismatches = [];
if (formula && wikidata.formula && formula !== wikidata.formula) {
mismatches.push(`Formula mismatch: Chembox "${formula}" vs Wikidata "${wikidata.formula}"`);
}
if (cas && wikidata.cas && cas !== wikidata.cas) {
mismatches.push(`CAS mismatch: Chembox "${cas}" vs Wikidata "${wikidata.cas}"`);
}
if (mismatches.length > 0) {
const $warning = $('<div>').css({
'background': '#fff2f2',
'border': '2px solid #d33',
'padding': '10px',
'margin-bottom': '1em',
'color': '#b32424'
}).append($('<strong>').text('⚠️ Data Mismatch Detected: '), mismatches.join(' | '));
$('#mw-content-text').prepend($warning);
}
}
async function checkAndRender(target, label, customWikitext) {
const check = await api.get({
action: 'query',
titles: target,
formatversion: 2
});
if (check.query.pages[0].missing) {
const $container = $('<div>').css({
'background': '#f8f9fa',
'border': '1px solid #a2a9b1',
'padding': '10px',
'margin-bottom': '1em'
});
const $btn = $('<button>').text(`Create redirect: ${target}`).click(async function() {
await api.postWithEditToken({
action: 'edit',
title: target,
text: customWikitext,
summary: `Creating redirect to [[${currentTitle}]] (${label})`,
createonly: true
});
mw.notify(`Created redirect: ${target}`);
$container.fadeOut(400, function() {
$(this).remove();
});
});
$container.append($('<strong>').text(`${label}: `), `Missing redirect. `, $btn);
$('#mw-content-text').prepend($container);
}
}
if (formula && formula !== currentTitle) {
const formulaWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from chemical formula}}\n}}`;
await checkAndRender(formula, "Chemical formula", formulaWikitext);
}
if (cas && cas !== currentTitle) {
const casWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from CAS Registry Number}}\n{{R unprintworthy}}\n}}`;
await checkAndRender(cas, "CAS number", casWikitext);
}
}
function arrayChunk(arr, size) {
const result = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
$(document).ready(async function() {
const ns = mw.config.get("wgNamespaceNumber");
if (ns === 14) {
if (await verifyTreeMembership()) {
const $catContainer = $('#mw-pages');
if ($catContainer.length) {
const $btn = $('<button>').text('Initializing scan...').attr('id', 'caschemassist-cat-btn').addClass('mw-ui-button').css({
'margin': '0 10px 10px 0',
'float': 'right',
'position': 'relative',
'z-index': '100'
}).prop('disabled', true).click(renderCategoryAudit);
$catContainer.prepend($btn);
startBackgroundCategoryScan();
}
}
} else if (ns === 0 && mw.config.get("wgAction") === "view") {
handleSingleArticle();
}
});
})();
//</nowiki>
3t7dswj4il7biagzxdrqonru5iach76
739291
739290
2026-04-24T21:41:09Z
Sam Sailor
26820
Restored revision 739288 by [[Special:Contributions/Sam Sailor|Sam Sailor]] ([[User talk:Sam Sailor|talk]]): Test (TwinkleGlobal)
739291
javascript
text/javascript
//<nowiki>
(function() {
mw.util.addCSS(`
.ambox-Orphan { display: block !important; }
.comrade-container {
box-shadow: 0 1px 3px rgba(0,0,0,0.1); border-radius: 2px;
padding: 10px 15px; margin: 10px 0; display: flex;
flex-direction: column; align-items: flex-start; gap: 8px;
border: 1px solid #a2a9b1; background: #fcfcfc; line-height: 1.5;
}
.comrade-header { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.comrade-content { display: flex; flex-direction: column; gap: 5px; width: 100%; }
.comrade-success { background-color: #d5f5e3 !important; border-left: 5px solid #27ae60 !important; color: #1b5e20 !important; }
.comrade-nudge { background: #fff9ea; border-left: 5px solid #f0ad4e; }
.comrade-info { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-status { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-container button:not(.comrade-dismiss) {
background: #f8f9fa; border: 1px solid #a2a9b1; border-radius: 2px;
padding: 2px 10px; cursor: pointer; font-weight: bold; font-size: 0.95em; margin-left: 10px;
}
.comrade-container button:not(.comrade-dismiss):hover { border-color: #36c; color: #36c; background: #fff; }
.comrade-dismiss {
background: transparent; border: none; color: #d33; font-weight: bold;
cursor: pointer; font-size: 0.9em;
}
.comrade-dismiss:hover { text-decoration: underline; }
.comrade-code-block { background: #f1f1f1; padding: 4px 8px; border-radius: 3px; font-family: monospace; display: inline-block; margin-top: 4px; }
`);
function appendDismiss($el) {
$('<button>').addClass('comrade-dismiss').text('Dismiss').on('click', function() {
$(this).closest('.comrade-container').fadeOut();
}).appendTo($el.find('.comrade-header'));
$("#siteSub").after($el);
}
async function checkNamingPrecision(currentTitle, isHuman) {
const disambigMatch = currentTitle.match(/^(.+?)(?:, | \()/);
if (isHuman || !disambigMatch) return;
const baseTitle = disambigMatch[1].trim();
const api = new mw.Api();
try {
const baseRes = await api.get({
action: 'query',
titles: baseTitle,
redirects: 0,
formatversion: 2
});
const basePage = baseRes.query.pages[0];
const baseExists = !basePage.missing;
const isRedirectToSelf = baseExists && basePage.redirect && basePage.title === baseTitle;
const searchRes = await api.get({
action: 'query',
list: 'allpages',
apprefix: baseTitle,
aplimit: 50,
formatversion: 2
});
const competitors = searchRes.query.allpages.filter(p => {
const t = p.title;
return t !== currentTitle && (t.startsWith(baseTitle + ', ') || t.startsWith(baseTitle + ' ('));
});
const $container = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (!baseExists || isRedirectToSelf) {
if (competitors.length === 0) {
$header.append($('<strong>').text('Precision:'));
$content.append($('<span>').append(`Base name `, $('<code>').text(baseTitle), ` is free. This disambiguation may be unnecessary.`));
const moveUrl = mw.util.getUrl('Special:MovePage/' + currentTitle, {
wpNewTitle: baseTitle,
wpReason: 'Unnecessary disambiguation; base name is free per [[WP:PRECISION]]'
});
$content.append($('<button>').text(`Move to ${baseTitle}`).on('click', () => window.open(moveUrl, '_blank')));
} else {
$header.append($('<strong>').text('Observation:'));
$content.append($('<span>').append(`Base name `, $('<code>').text(baseTitle), ` is a redlink, but I found ${competitors.length} potential competitors (e.g., `, $('<i>').text(competitors[0].title), `).`));
$content.append($('<span>').text('Consider if a disambiguation page or primary topic move is needed.'));
}
$container.append($header, $content);
appendDismiss($container);
} else if (basePage.redirect) {
// Scenario: Base redirects elsewhere
$header.append($('<strong>').text('Conflict:'));
$content.append($('<span>').append(`Base name `, $('<code>').text(baseTitle), ` redirects to `, $('<i>').text(basePage.title), `. Verify primary topic status.`));
$container.append($header, $content);
appendDismiss($container);
}
} catch (e) {
console.warn("Comrade: Precision check failed", e);
}
}
async function checkAndRenderRedirect(redirTitle, targetTitle, rCategory, labelText, customWikitext) {
const api = new mw.Api();
const res = await api.get({
action: 'query',
titles: redirTitle,
formatversion: 2
});
if (res.query.pages[0].missing) {
const safeId = "comrade-redir-" + redirTitle.replace(/[^a-z0-9]/gi, '-');
const $container = $('<div>').addClass('comrade-container comrade-info').attr('id', safeId);
const $header = $('<div>').addClass('comrade-header');
const summary = `Created redirect from ${labelText.toLowerCase()} to [[${targetTitle}]]`;
const $btn = $('<button>').text('Create redirect').on('click', () => createModernRedirect(redirTitle, targetTitle, rCategory, summary, safeId, customWikitext));
$header.append($('<div>').append($('<strong>').text(`${labelText}: `), `Create redirect from `, $('<code>').text(redirTitle), $btn));
$container.append($header);
appendDismiss($container);
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
if (qid) {
try {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
});
const res = await fetch(url).then(r => r.json());
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
}
} catch (e) {
console.warn("Comrade: Wikidata API call failed.");
}
}
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
if (domain.includes(".") && url.pathname === "/") {
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
try {
const response = await fetch(citationURL);
if (response.ok) {
await checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
}
} catch (e) {
console.warn("Comrade: Citation API check failed.");
}
}
} catch (e) {
console.warn("Comrade: Error parsing URL object.");
}
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan, resolvedTitle) {
const api = new mw.Api();
const candidates = resolvedTitle ? [resolvedTitle] : [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
redirects: 0
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const foundTitle = page.title;
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) break;
const $container = $('<div>').addClass('comrade-container');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (isOrphan) {
$container.addClass('comrade-nudge');
$header.append($('<strong>').text('Comrade nudge:'));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
}
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(foundTitle),
target: '_blank'
}).text(foundTitle), "; should it be?"));
const snippet = '* {{anbl|' + currentTitle + '}}';
const $snippetWrapper = $('<div>').css({
'display': 'flex',
'align-items': 'center',
'gap': '10px',
'margin-top': '4px'
});
const $instructionSpan = $('<span>').append('If the Short description is good, consider adding ', $('<code>').text(snippet).css({
'background-color': '#f8f9fa',
'border': '1px solid #eaecf0',
'border-radius': '2px',
'padding': '1px 4px'
}));
const $copyBtn = $('<button>').text('Copy & insert').css('margin-left', '0').on('click', function() {
navigator.clipboard.writeText(snippet).then(() => {
const $this = $(this);
const originalText = $this.text();
$this.text('Copied!').prop('disabled', true);
const editUrl = mw.util.getUrl(foundTitle, {
action: 'edit',
summary: snippet
});
window.open(editUrl, '_blank');
setTimeout(() => {
$this.text(originalText).prop('disabled', false);
}, 1500);
});
});
$snippetWrapper.append($instructionSpan, $copyBtn);
$content.append($snippetWrapper);
if (!hasShortDesc) {
$content.append($('<span>').css({
'color': '#d33',
'font-weight': 'bold',
'margin-top': '5px'
}).text('⚠️ Missing short description: Please add a concise one before listing.'));
}
$container.append($header, $content);
appendDismiss($container);
break;
}
}
}
}
async function createModernRedirect(redirTitle, targetTitle, rCategory, customSummary, elementId, customWikitext) {
const api = new mw.Api();
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
try {
await api.postWithEditToken({
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
});
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
} catch (e) {
mw.notify("Failed to create redirect.", {
type: 'error'
});
}
}
function getSmartSortKey(fullName, entity) {
const countryIds = entity.claims?.P27?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const locationIds = entity.claims?.P17?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const placeIds = [...(entity.claims?.P19 || []), ...(entity.claims?.P119 || [])].map(c => c.mainsnak?.datavalue?.value?.id).filter(Boolean);
const allRelevantIds = [...countryIds, ...locationIds, ...placeIds];
const birthYear = parseInt(entity.claims?.P569?.[0]?.mainsnak?.datavalue?.value?.time?.match(/[+-](\d{4})/)?.[1]) || null;
if (fullName.startsWith("Saint ")) return fullName.replace("Saint ", "");
const arabicParticles = /\b(Abu|Abd|Abdel|Abdul|ben|bin|bint)\b/i;
if (fullName.match(arabicParticles)) {
if (birthYear && birthYear > 1900) {
const parts = fullName.split(' ');
const binIndex = parts.findIndex(p => p.toLowerCase() === 'bin' || p.toLowerCase() === 'ben');
if (binIndex > 0) return parts.slice(binIndex).join(' ') + ', ' + parts.slice(0, binIndex).join(' ');
} else {
return fullName;
}
}
const eastAsianQids = ["Q148", "Q184", "Q884", "Q424", "Q881", "Q17"];
if (allRelevantIds.some(id => eastAsianQids.includes(id))) {
if (allRelevantIds.includes("Q17") && birthYear && birthYear > 1885) return westernSort(fullName);
const parts = fullName.split(' ');
if (parts.length > 1) return `${parts[0]}, ${parts.slice(1).join(' ')}`;
}
return westernSort(fullName);
}
function westernSort(name) {
let cleanName = name;
if (name.startsWith("O'")) cleanName = name.replace("O'", "O");
const parts = cleanName.split(' ');
if (parts.length < 2) return cleanName;
const surname = parts.pop();
if (["Jr.", "Jr", "III", "II", "Sr.", "Sr"].includes(surname)) {
const actualSurname = parts.pop();
return `${actualSurname}, ${parts.join(' ')} ${surname}`;
}
return `${surname}, ${parts.join(' ')}`;
}
async function performDeorphan() {
const api = new mw.Api();
try {
await api.edit(mw.config.get('wgPageName'), function(rev) {
let text = rev.content;
const summary = 'Article has backlinks; removed [[Template:Orphan|{{Orphan}}]]';
text = text.replace(/\{\{Orphan\s*(?:\|[^}]*)?\}\}\s*/gi, '');
text = text.replace(/(\{\{Multiple[ _]issues\s*)\|\s*\|/gi, '$1|');
text = replaceMI(text);
text = text.replace(/\n{3,}/g, '\n\n').trim();
return {
text,
summary,
minor: true
};
});
mw.notify('Orphan tag removed!', {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
setTimeout(() => location.reload(), 700);
} catch (e) {
mw.notify('Failed to remove orphan tag.', {
type: 'error'
});
}
}
function replaceMI(text) {
const miRegex = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miRegex.exec(text)) !== null) {
const start = match.index;
result += text.slice(lastIndex, start);
let depth = 0;
let i = start;
while (i < text.length) {
if (text[i] === '{' && text[i + 1] === '{') {
depth++;
i += 2;
} else if (text[i] === '}' && text[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
const end = i;
const full = text.slice(start, end);
const pipeIdx = full.indexOf('|');
const inner = full.slice(pipeIdx + 1, full.length - 2).trim();
const topLevel = extractTopLevelTemplates(inner);
if (topLevel.length === 0) {
result += '';
} else if (topLevel.length === 1) {
result += topLevel[0].trim();
} else {
result += full;
}
lastIndex = end;
}
result += text.slice(lastIndex);
return result;
}
function extractTopLevelTemplates(inner) {
const templates = [];
let i = 0;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
let depth = 0;
const start = i;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
depth++;
i += 2;
} else if (inner[i] === '}' && inner[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
templates.push(inner.slice(start, i));
} else {
i++;
}
}
return templates;
}
async function performSortCorrection(newName, type) {
const api = new mw.Api();
const title = mw.config.get("wgPageName");
try {
const data = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2
});
let content = data.query.pages[0].revisions[0].content;
const dsRegex = /\{\{(?:DEFAULTSORT|Defaultsort):[^}]+\}\}/i;
const newTag = `{{DEFAULTSORT:${newName}}}`;
let actionDesc = type === "add" ? "added" : "corrected";
if (dsRegex.test(content)) {
content = content.replace(dsRegex, newTag);
} else {
const catRegex = /\[\[Category:/i;
if (catRegex.test(content)) {
content = content.replace(catRegex, `${newTag}\n[[Category:`);
} else {
content += `\n\n${newTag}`;
}
}
await api.postWithEditToken({
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName}`,
nocreate: true
});
mw.notify(`DEFAULTSORT ${actionDesc} to ${newName}!`, {
title: 'Comrade Success',
type: 'success'
});
setTimeout(() => location.reload(), 700);
} catch (err) {
mw.notify('Failed to save DEFAULTSORT.', {
type: 'error'
});
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
function renderSortNudge(target, reason) {
const $dsNudge = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Correct to: ${target}`).on('click', () => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
async function fetchWikidataNameLabels(entity) {
const familyIds = entity.claims?.P734?.map(c => c.mainsnak.datavalue.value.id) || [];
const givenIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
const allNameIds = [...new Set([...familyIds, ...givenIds])];
if (allNameIds.length === 0) return {
givens: [],
surnames: []
};
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: allNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
});
const nameData = await fetch(url).then(r => r.json());
return {
givens: givenIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean),
surnames: familyIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean)
};
}
function fixNameCasing(part, referenceName) {
return part.split(' ').map(word => {
const match = referenceName.match(new RegExp(`\\b${word}\\b`, 'i'));
if (match) return match[0];
return referenceName.split(' ').find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase())) || word;
}).join(' ');
}
function getLongNameFromWikitext(wikitext) {
const firstLine = wikitext.split('\n').find(l => l.includes("'''"));
if (!firstLine) return null;
const boldMatch = firstLine.match(/'''(.+?)'''/);
if (!boldMatch) return null;
return boldMatch[1].replace(/\s*([“"«《„‹「『'].+?[”"»》”›」』']|\([^)\n]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
}
async function init() {
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") return;
const api = new mw.Api();
const qid = mw.config.get("wgWikibaseItemId");
try {
const [pageData, backlinkData] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
api.get({
action: 'query',
list: 'backlinks',
blfilterredir: 'nonredirects',
bllimit: 50,
blnamespace: 0,
bltitle: mw.config.get("wgPageName"),
formatversion: 2
})
]);
const page = pageData.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const currentTitle = mw.config.get("wgTitle");
const cleanTitle = stripDiacritics(currentTitle);
const isOrphanTagged = /\{\{\s*(Orphan|Do-attempt|Lonely|Orp|Orphaned article)/i.test(wikitext) || /\| *orphan *=/i.test(wikitext);
const backLinkCount = backlinkData.query.backlinks.length;
if (isOrphanTagged && backLinkCount >= 1) {
const $container = $('<div>').addClass('comrade-container comrade-status');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text('Remove orphan tag').on('click', () => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) await checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
await checkDomainRedirect(qid, currentTitle);
let isHuman = false;
let entity = null;
if (qid) {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
});
const wikiData = await fetch(url).then(r => r.json());
entity = wikiData.entities[qid];
isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
}
await checkNamingPrecision(currentTitle, isHuman);
if (qid && entity) {
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const nameParts = tClean.split(' ');
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
const isOrphan = isOrphanTagged && backLinkCount < 1;
const primarySurname = hasFamilyNameHatnote ? nameParts[0] : (getSmartSortKey(tClean, entity).split(',')[0] || nameParts[nameParts.length - 1]);
let targetSortName = "";
if (isHuman) {
const {
givens,
surnames
} = await fetchWikidataNameLabels(entity);
const refName = entity.labels?.en?.value || currentTitle;
if (dsMatch) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixNameCasing(lastPart, refName)}, ${fixNameCasing(firstPart, refName)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field.");
} else if (givens.length > 0 && lastPart.split(' ').length > 1) {
const misplacedGiven = lastPart.split(' ').find(part => givens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastPart.split(' ').filter(p => p !== misplacedGiven).join(' ');
targetSortName = `${fixNameCasing(newSurname, refName)}, ${fixNameCasing(misplacedGiven + ' ' + firstPart, refName)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
}
}
} else {
targetSortName = hasFamilyNameHatnote ? `${nameParts[0]}, ${nameParts.slice(1).join(' ')}` : getSmartSortKey(tClean, entity);
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).on('click', () => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
const longName = getLongNameFromWikitext(wikitext);
if (longName && longName.length > tClean.length) {
let sortKey = hasFamilyNameHatnote ? `${longName.split(' ')[0]}, ${longName.split(' ').slice(1).join(' ')}` : getSmartSortKey(longName, entity);
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
await checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
if (targetSortName) {
let targetPage = currentTitle.includes(' (') ? currentTitle.split(' (')[0] : currentTitle;
let rCat = targetPage !== currentTitle ? "R from ambiguous sort name" : "R from sort name";
if (targetSortName.includes(',')) {
const p = targetSortName.split(',');
rCat += `|${p[0].trim().charAt(0).toUpperCase()}|${p[1].trim().charAt(0).toUpperCase()}`;
}
await checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name");
}
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
surnames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
givens.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) finalGivenNames.add(gn);
});
if (finalFamilyNames.size === 0 && nameParts.length >= 2) finalFamilyNames.add(nameParts[nameParts.length - 1]);
if (finalGivenNames.size === 0 && nameParts.length >= 2) finalGivenNames.add(nameParts[0]);
const getPriorityTarget = async (name, type) => {
const titles = type === 'surname' ? [`List of people with surname ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`] : [`List of people with given name ${name}`, `${name} (given name)`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
return titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
};
for (const name of finalGivenNames) {
if (name.toLowerCase() === primarySurname.toLowerCase()) continue;
const target = await getPriorityTarget(name, 'given');
if (target) await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
for (const name of finalFamilyNames) {
const titles = [name, `${name} (surname)`, `List of people with surname ${name}`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
const target = titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
if (target) await checkNameList(name, 'surname', currentTitle, hasShortDesc, isOrphan, target);
}
}
}
} catch (err) {
console.error("Comrade error:", err);
}
}
init();
})();
(function() {
if (window.IllWill) return;
const STRIKEOUT = true;
const HIDE = 'hide';
const IllWill = {
Version: "1.1.1",
Attribution: 'created by <a target="_blank" href="/wiki/User:Cobaltcigs">cobaltcigs</a>',
Summary: "Added interlanguage links using IllWill.js",
DataApi: "https://www.wikidata.org/w/api.php",
LocalLang: mw.config.get("wgPageContentLanguage"),
Disambiguator: / \(.*\)$/,
SiteLinks: {},
Config: {
MaxLanguages: 5,
IgnoreScientific: STRIKEOUT,
AutoDiff: true
},
css: `
#illwill { clear: both; margin-bottom: 1.5em; border: 1px solid #a2a9b1; padding: 15px; background: #f8f9fa; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.ill-sitelinks a { display: inline-block; margin-bottom: 3px; font-size: 0.9em; color: #36c; }
.ill-size-tag { font-size: 0.8em; color: #72777d; margin-left: 6px; white-space: nowrap; }
.illwill-ignore { text-decoration: line-through; color: #72777d; font-size: 0.85em; }
.ill-x { width: 35px; text-align: center; }
.ill-input, .ill-output { font-family: 'Courier New', monospace; white-space: pre-wrap; font-size: 0.9em; background: #fff; border: 1px solid #eaecf0; padding: 4px; border-radius: 2px; }
#ill-buttons { text-align: right; padding: 10px; border-top: 1px solid #a2a9b1; margin-top: 10px; }
#ill-buttons button { margin-left: 10px; cursor: pointer; padding: 6px 16px; border-radius: 2px; border: 1px solid #a2a9b1; transition: all 0.2s; }
#ill-ok { background: #36c; color: #fff; border-color: #36c !important; font-weight: bold; }
#ill-ok:hover { background: #447ff5; }
#ill-ok:disabled { background: #eaecf0; color: #72777d; border-color: #c8ccd1 !important; cursor: not-allowed; opacity: 0.7; }
#ill-table { width: 100%; border-collapse: collapse; margin-top: 10px; background: white; }
#ill-table th { background: #f2f2f2; text-align: left; padding: 8px; border: 1px solid #a2a9b1; }
#ill-table td { padding: 8px; border: 1px solid #a2a9b1; vertical-align: top; }
#ill-table caption { background: #eaffea; font-weight: bold; padding: 10px; border: 1px solid #a2a9b1; border-bottom: none; font-size: 1.1em; }
#ill-help { font-size: 0.85em; color: #54595d; padding: 10px; line-height: 1.4; }
.ill-loading-inline { font-style: italic; color: #72777d; font-size: 0.9em; display: block; margin: 5px 0; }
`,
setup() {
if (!["edit", "submit"].includes(mw.config.get('wgAction'))) return;
mw.loader.addStyleTag(this.css);
const link = mw.util.addPortletLink('p-cactions', '#', 'IllWill', 'ca-illwill', "Add interlanguage links via Wikidata");
if (link) {
link.addEventListener('click', (e) => {
e.preventDefault();
this.makePanel();
});
}
},
norm(s) {
if (!s) return "";
const t = s.trim().replace(/[\s_]+/g, " ");
return t.charAt(0).toUpperCase() + t.slice(1);
},
async getRedLinks($textbox) {
const txt = $textbox.textSelection('getContents') || "";
const wl = txt.match(/\[\[[^[\]\n]+\]\]/g);
if (!wl) return {
reds: [],
txt
};
const api = new mw.Api();
const textToParse = wl.join("").replace(/\|[^\]]+/g, "");
try {
const data = await api.post({
action: 'parse',
text: textToParse,
contentmodel: 'wikitext',
formatversion: 2
});
const tempDiv = document.createElement('div');
tempDiv.innerHTML = data.parse.text;
const reds = Array.from(tempDiv.querySelectorAll("a.new")).map(x => {
const urlParams = new URLSearchParams(x.href.split('?')[1]);
return urlParams.get('title')?.replace(/_/g, " ");
}).filter(Boolean);
return {
reds: [...new Set(reds)],
txt
};
} catch (e) {
return {
reds: [],
txt
};
}
},
async makePanel() {
const $editform = $('#editform');
const $textbox = $('#wpTextbox1');
$('#illwill').remove();
$editform.hide();
const $wrapper = $('<div id="illwill">Loading redlinks...</div>').insertBefore($editform);
const {
reds,
txt
} = await this.getRedLinks($textbox);
const wtLinks = [];
reds.forEach(title => {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s/g, "[\\s_]+");
const re = new RegExp("\\[\\[\\s*" + escaped + "\\s*(?:\\|[^\\[\\]]+)?\\]\\]", "ig");
const match = txt.match(re);
if (match && !wtLinks.some(z => z.title === title)) {
wtLinks.push({
link: match[0],
title
});
}
});
if (!wtLinks.length) {
const $back = $('<button>Quit</button>').on('click', () => {
$wrapper.remove();
$editform.show();
});
$wrapper.empty().append($('<strong>No valid redlinks found in text.</strong> '), $back);
return;
}
const $table = $(`
<table id="ill-table" class="wikitable">
<caption>IllWill.js (v${this.Version})</caption>
<thead>
<tr>
<th class="ill-x"><input type="checkbox" id="ill-select-all" disabled></th>
<th>Input link</th>
<th>Possible Wikidata matches</th>
<th>Sitelinks (top ${this.Config.MaxLanguages} by size)</th>
<th>Resulting wikitext</th>
</tr>
</thead>
<tbody></tbody>
<tfoot>
<tr>
<td colspan="3" id="ill-help">Carefully examine possible Wikidata matches.</td>
<td colspan="2" id="ill-buttons">
<button id="ill-cancel">Cancel</button>
<button id="ill-ok" disabled>Show changes</button>
</td>
</tr>
</tfoot>
</table>
`);
const $tbody = $table.find('tbody');
wtLinks.forEach((item, i) => {
$tbody.append(`
<tr id="ill-row-${i}" data-index="${i}">
<td class="ill-x"><input type="checkbox" class="ill-row-check" disabled></td>
<td class="ill-input">${this.escapeHTML(item.link)}</td>
<td class="ill-wd-item"><span class="ill-loading-inline">Searching...</span></td>
<td class="ill-sitelinks"></td>
<td class="ill-output"></td>
</tr>
`);
});
$wrapper.empty().append($table);
const chunkSize = 5;
for (let i = 0; i < wtLinks.length; i += chunkSize) {
const chunk = wtLinks.slice(i, i + chunkSize);
await Promise.all(chunk.map((item, idx) => this.fetchWikidataOptimized(item.title, i + idx)));
}
$table.on('click', '#ill-cancel', () => {
$editform.show();
$wrapper.remove();
});
$table.on('click', '#ill-ok', () => this.onOkButton($editform, $wrapper, $textbox));
$table.on('change', '#ill-select-all', (e) => {
const isChecked = $(e.target).is(':checked');
$table.find('.ill-row-check:not(:disabled)').each(function() {
$(this).prop('checked', isChecked).trigger('change');
});
});
$table.on('change', '.ill-row-check', (e) => {
this.onCheckbox(e);
this.updateOkButtonState($table);
});
$table.on('change', 'input[type="radio"]', (e) => {
this.showSiteLinks(e);
});
},
async fetchWikidataOptimized(title, index) {
const query = title.replace(this.Disambiguator, "");
const url = `${this.DataApi}?action=wbsearchentities&search=${encodeURIComponent(query)}&language=${this.LocalLang}&limit=5&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
const results = data.search;
const $cell = $(`#ill-row-${index} .ill-wd-item`);
if (!results || !results.length) {
$cell.html('<span class="ill-na">(no results)</span>');
return;
}
const entityIds = results.map(r => r.id);
await this.fetchEntities(entityIds, index, $cell);
} catch (e) {
$(`#ill-row-${index} .ill-wd-item`).text("Search failed.");
}
},
async fetchEntities(ids, rowIndex, $cell) {
const url = `${this.DataApi}?action=wbgetentities&ids=${ids.join('|')}&props=labels|descriptions|claims|sitelinks&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
$cell.empty();
ids.forEach(qNum => {
const entity = data.entities[qNum];
if (!entity) return;
const isScientific = entity.claims?.P31?.some(s => s.mainsnak?.datavalue?.value?.id === "Q13442814");
if (isScientific && this.Config.IgnoreScientific === HIDE) return;
const desc = entity.descriptions?.[this.LocalLang]?.value || entity.descriptions?.en?.value || "";
const label = entity.labels?.[this.LocalLang]?.value || entity.labels?.en?.value || qNum;
const isStrikeout = isScientific && this.Config.IgnoreScientific === STRIKEOUT;
const $div = $(`
<div class="${isStrikeout ? 'illwill-ignore' : ''}">
<input type="radio" name="selection-${rowIndex}" value="${qNum}" id="radio-${qNum}" ${isStrikeout ? 'disabled' : ''}>
<label for="radio-${qNum}">
<a href="https://wikidata.org/wiki/${qNum}" target="_blank">${this.escapeHTML(label)}</a>:
<span class="ill-desc">${desc ? this.escapeHTML(desc) : '<i>no description</i>'}</span>
</label>
</div>
`);
$cell.append($div);
this.SiteLinks[qNum] = entity.sitelinks || {};
});
} catch (e) {
$cell.text("Detail fetch failed.");
}
},
updateOkButtonState($table) {
const $rows = $table.find('.ill-row-check:not(:disabled)');
const $checkedRows = $rows.filter(':checked');
const anyEnabled = $rows.length > 0;
$table.find('#ill-ok').prop('disabled', $checkedRows.length === 0);
const $master = $table.find('#ill-select-all');
$master.prop('disabled', !anyEnabled);
if (anyEnabled && $rows.length === $checkedRows.length) {
$master.prop('checked', true);
} else {
$master.prop('checked', false);
}
},
async showSiteLinks(e) {
const isManualToggling = e.fromCheckbox || false;
const qNum = e.target.value;
const $row = $(e.target).closest('tr');
const sitelinks = this.SiteLinks[qNum];
const $checkbox = $row.find('.ill-row-check');
const $sitelinkCell = $row.find('.ill-sitelinks');
const $outputCell = $row.find('.ill-output');
const $table = $('#ill-table');
const effectiveMax = Math.min(Math.max(this.Config.MaxLanguages, 3), 6);
const validKeys = Object.keys(sitelinks).filter(k => k.endsWith('wiki') && !k.includes('commons'));
$sitelinkCell.html('<span class="ill-loading-inline">Measuring articles...</span>');
$outputCell.empty();
if (!isManualToggling) {
$checkbox.prop('disabled', true).prop('checked', false);
}
const candidates = validKeys.slice(0, 25);
const sizeMap = [];
await Promise.all(candidates.map(async (key) => {
const lang = key.replace('wiki', '').replace(/_/g, '-');
const title = sitelinks[key].title;
const endpoint = `https://${lang}.wikipedia.org/w/api.php?action=query&titles=${encodeURIComponent(title)}&prop=info&format=json&origin=*`;
try {
const res = await fetch(endpoint).then(r => r.json());
const page = Object.values(res.query.pages)[0];
sizeMap.push({
lang,
title,
length: page.length || 0
});
} catch (err) {
sizeMap.push({
lang,
title,
length: 0
});
}
}));
sizeMap.sort((a, b) => b.length - a.length);
const topLinks = sizeMap.slice(0, effectiveMax);
$sitelinkCell.html(topLinks.map(item => {
const kbValue = Math.round(item.length / 1024);
return `<div><a href="https://${item.lang}.wikipedia.org/wiki/${encodeURIComponent(item.title)}" target="_blank">${item.lang}:${this.escapeHTML(item.title)}</a> <span class="ill-size-tag">(${kbValue} kB)</span></div>`;
}).join(""));
const inputWikitext = $row.find('.ill-input').text();
const match = inputWikitext.match(/\[\[(.*?)\]\]/);
if (match) {
const parts = match[1].split('|');
const targetPage = parts[0].trim();
const surfaceName = (parts[1] || parts[0]).trim();
const illLangs = topLinks.map(item => {
const isSameTitle = this.norm(item.title) === this.norm(targetPage);
return `|${item.lang}|${isSameTitle ? '' : item.title}`;
}).join("");
const ltParam = (this.norm(surfaceName) === this.norm(targetPage)) ? "" : `|lt=${surfaceName}`;
$outputCell.text(`{{ill|${targetPage}${ltParam}${illLangs}}}`);
}
$checkbox.prop('disabled', !topLinks.length);
if (!isManualToggling) {
$checkbox.prop('checked', !!topLinks.length);
}
this.updateOkButtonState($table);
},
onCheckbox(e) {
const $row = $(e.target).closest('tr');
const $radio = $row.find('input[type="radio"]:checked');
const isChecked = $(e.target).is(':checked');
if (isChecked) {
if ($radio.length) {
this.showSiteLinks({
target: $radio[0],
fromCheckbox: true
});
}
} else {
$row.find('.ill-sitelinks, .ill-output').empty();
}
},
onOkButton($editform, $wrapper, $textbox) {
let text = $textbox.textSelection('getContents');
let changed = false;
$wrapper.find('.ill-row-check:checked').each(function() {
const $row = $(this).closest('tr');
const input = $row.find('.ill-input').text();
const output = $row.find('.ill-output').text();
if (output) {
const escapedInput = input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
text = text.replace(new RegExp(escapedInput, "gi"), output);
changed = true;
}
});
if (changed) {
$('#wpSummary').val((i, v) => (v ? v + "; " : "") + this.Summary);
$textbox.textSelection('setContents', text);
$wrapper.remove();
$editform.show();
if (this.Config.AutoDiff) $('#wpDiff').click();
}
},
escapeHTML(str) {
return str.replace(/[&<>"']/g, m => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
})[m]);
}
};
window.IllWill = IllWill;
mw.loader.using(['mediawiki.util', 'mediawiki.api', 'jquery.textSelection'], () => {
$(() => IllWill.setup());
});
})();
(function() {
'use strict';
const config = {
enableNS14: true,
rootWhitelists: ['Materials'],
userWhitelist: ""
};
const categoryDataCache = {};
let scanProgress = 0;
let totalToScan = 0;
async function verifyTreeMembership() {
if (!config.enableNS14) return false;
const api = new mw.Api();
const currentTitle = mw.config.get("wgPageName").replace(/_/g, ' ');
const fullRoots = config.rootWhitelists.concat(config.userWhitelist.split(',').map(s => s.trim()).filter(s => s)).map(cat => (cat.startsWith('Category:') ? cat : `Category:${cat}`));
if (fullRoots.includes(currentTitle)) return true;
let categoriesToTable = mw.config.get('wgCategories') || [];
let checked = new Set();
while (categoriesToTable.length > 0) {
if (categoriesToTable.some(cat => fullRoots.includes('Category:' + cat))) {
return true;
}
categoriesToTable.forEach(cat => checked.add(cat));
try {
const res = await api.get({
formatversion: 2,
prop: 'categories',
titles: categoriesToTable.map(c => 'Category:' + c),
cllimit: 'max'
});
let nextBatch = [];
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.categories) {
page.categories.forEach(parent => {
const parentName = parent.title.substring('Category:'.length);
if (!checked.has(parentName)) {
nextBatch.push(parentName);
}
});
}
});
}
categoriesToTable = [...new Set(nextBatch)];
} catch (e) {
return false;
}
}
return false;
}
function getChemboxBlock(wikitext) {
const chemboxRegex = /\{\{\s*Chembox[\s_]*(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const match = wikitext.match(chemboxRegex);
return match ? match[0] : null;
}
function getFormulaFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const formulaMatch = chembox.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
let rawFormula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
rawFormula = rawFormula.split(/\s+/)[0];
return rawFormula.split(',')[0].trim();
}
const propRegex = /\{\{\s*Chembox[\s_]+Properties(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const propSection = chembox.match(propRegex);
if (propSection) {
const content = propSection[0];
const allElements = ['Ac', 'Ag', 'Al', 'Am', 'Ar', 'As', 'At', 'Au', 'B', 'Ba', 'Be', 'Bh', 'Bi', 'Bk', 'Br', 'C', 'Ca', 'Cd', 'Ce', 'Cf', 'Cl', 'Cm', 'Cn', 'Co', 'Cr', 'Cs', 'Cu', 'D', 'Db', 'Ds', 'Dy', 'Er', 'Es', 'Eu', 'F', 'Fe', 'Fl', 'Fm', 'Fr', 'Ga', 'Gd', 'Ge', 'H', 'He', 'Hf', 'Hg', 'Ho', 'Hs', 'I', 'In', 'Ir', 'K', 'Kr', 'La', 'Li', 'Lr', 'Lu', 'Lv', 'Mc', 'Md', 'Mg', 'Mn', 'Mo', 'Mt', 'N', 'Na', 'Nb', 'Nd', 'Ne', 'Nh', 'Ni', 'No', 'Np', 'O', 'Og', 'Os', 'P', 'Pa', 'Pb', 'Pd', 'Pm', 'Po', 'Pr', 'Pt', 'Pu', 'Ra', 'Rb', 'Re', 'Rf', 'Rg', 'Rh', 'Rn', 'Ru', 'S', 'Sb', 'Sc', 'Se', 'Sg', 'Si', 'Sm', 'Sn', 'Sr', 'Ta', 'Tb', 'Tc', 'Te', 'Th', 'Ti', 'Tl', 'Tm', 'Ts', 'U', 'V', 'W', 'Xe', 'Y', 'Yb', 'Zn', 'Zr'];
let data = {};
allElements.forEach(el => {
const elRegex = new RegExp(`\\|\\s*${el}\\s*=\\s*(\\d*\\.?\\d+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch && elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
});
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
elementsPresent.filter(el => !(hasC && (el === "C" || el === "H"))).sort().forEach(el => {
formulaArray.push(el + data[el]);
});
return formulaArray.join('');
}
}
return null;
}
function getCASFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const idRegex = /\{\{\s*Chembox[\s_]+Identifiers(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const idSection = chembox.match(idRegex);
if (!idSection) return null;
const content = idSection[0];
const nbsp = String.fromCharCode(160);
const casMatch = content.match(/\|\s*CASNo\d*\b(?!\s*_)\s*=\s*([^|}\n]+)/i);
if (casMatch) {
let val = casMatch[1].replace(/\{\{[^}]+\}\}/g, "").replace(/\u00AD/g, "").split(" ").join("");
while (val.indexOf(nbsp) !== -1) val = val.replace(nbsp, "");
while (val.indexOf(" ") !== -1) val = val.replace(" ", "");
const cleanedCas = val.trim();
return /^\d{2,7}-\d{2}-\d$/.test(cleanedCas) ? cleanedCas : null;
}
return null;
}
async function getWikidataValue() {
const qid = mw.config.get('wgWikidataItemId');
if (!qid) return null;
const api = new mw.ForeignApi('https://www.wikidata.org/w/api.php');
const res = await api.get({
action: 'wbgetentities',
ids: qid,
props: 'claims',
format: 'json',
formatversion: 2
});
const claims = res.entities[qid].claims;
const data = {};
if (claims.P274) data.formula = claims.P274[0].mainsnak.datavalue.value;
if (claims.P231) data.cas = claims.P231[0].mainsnak.datavalue.value;
return data;
}
async function startBackgroundCategoryScan() {
const api = new mw.Api();
const titles = $('#mw-pages li a').map((i, el) => $(el).attr('title') || $(el).text()).get();
totalToScan = titles.length;
if (totalToScan === 0) return;
const chunks = arrayChunk(titles, 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: chunk,
rvprop: 'content',
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const f = getFormulaFromWikitext(wikitext);
const c = getCASFromWikitext(wikitext);
if (f || c) categoryDataCache[page.title] = {
formula: f,
cas: c
};
});
}
scanProgress += chunk.length;
$('#caschemassist-cat-btn').text(`Scanning... ${scanProgress}/${totalToScan}`);
}
$('#caschemassist-cat-btn').text('Show missing CAS/Chem').prop('disabled', false);
}
async function renderCategoryAudit() {
const api = new mw.Api();
const missingCheckTitles = [];
Object.values(categoryDataCache).forEach(data => {
if (data.formula) missingCheckTitles.push(data.formula);
if (data.cas) missingCheckTitles.push(data.cas);
});
if (missingCheckTitles.length === 0) {
mw.notify("No valid Chembox data found in this category.");
return;
}
const existenceMap = {};
const chunks = arrayChunk([...new Set(missingCheckTitles)], 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
titles: chunk,
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(p => {
existenceMap[p.title] = !p.missing;
});
}
}
$('#mw-pages li').each(function() {
const title = $(this).find('a').attr('title') || $(this).find('a').text().trim();
const data = categoryDataCache[title];
if (!data) return;
if (data.formula && !existenceMap[data.formula]) {
$(this).append($('<span>').css({
'color': 'red',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [${data.formula}]`));
}
if (data.cas && !existenceMap[data.cas]) {
$(this).append($('<span>').css({
'color': 'darkred',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [CAS: ${data.cas}]`));
}
});
$('#caschemassist-cat-btn').text('Audit complete').prop('disabled', true);
}
async function handleSingleArticle() {
const api = new mw.Api();
const currentTitle = mw.config.get("wgTitle");
const [wikiRes, wikidata] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
getWikidataValue()
]);
const page = wikiRes.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const formula = getFormulaFromWikitext(wikitext);
const cas = getCASFromWikitext(wikitext);
if (wikidata) {
let mismatches = [];
if (formula && wikidata.formula && formula !== wikidata.formula) {
mismatches.push(`Formula mismatch: Chembox "${formula}" vs Wikidata "${wikidata.formula}"`);
}
if (cas && wikidata.cas && cas !== wikidata.cas) {
mismatches.push(`CAS mismatch: Chembox "${cas}" vs Wikidata "${wikidata.cas}"`);
}
if (mismatches.length > 0) {
const $warning = $('<div>').css({
'background': '#fff2f2',
'border': '2px solid #d33',
'padding': '10px',
'margin-bottom': '1em',
'color': '#b32424'
}).append($('<strong>').text('⚠️ Data Mismatch Detected: '), mismatches.join(' | '));
$('#mw-content-text').prepend($warning);
}
}
async function checkAndRender(target, label, customWikitext) {
const check = await api.get({
action: 'query',
titles: target,
formatversion: 2
});
if (check.query.pages[0].missing) {
const $container = $('<div>').css({
'background': '#f8f9fa',
'border': '1px solid #a2a9b1',
'padding': '10px',
'margin-bottom': '1em'
});
const $btn = $('<button>').text(`Create redirect: ${target}`).click(async function() {
await api.postWithEditToken({
action: 'edit',
title: target,
text: customWikitext,
summary: `Creating redirect to [[${currentTitle}]] (${label})`,
createonly: true
});
mw.notify(`Created redirect: ${target}`);
$container.fadeOut(400, function() {
$(this).remove();
});
});
$container.append($('<strong>').text(`${label}: `), `Missing redirect. `, $btn);
$('#mw-content-text').prepend($container);
}
}
if (formula && formula !== currentTitle) {
const formulaWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from chemical formula}}\n}}`;
await checkAndRender(formula, "Chemical formula", formulaWikitext);
}
if (cas && cas !== currentTitle) {
const casWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from CAS Registry Number}}\n{{R unprintworthy}}\n}}`;
await checkAndRender(cas, "CAS number", casWikitext);
}
}
function arrayChunk(arr, size) {
const result = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
$(document).ready(async function() {
const ns = mw.config.get("wgNamespaceNumber");
if (ns === 14) {
if (await verifyTreeMembership()) {
const $catContainer = $('#mw-pages');
if ($catContainer.length) {
const $btn = $('<button>').text('Initializing scan...').attr('id', 'caschemassist-cat-btn').addClass('mw-ui-button').css({
'margin': '0 10px 10px 0',
'float': 'right',
'position': 'relative',
'z-index': '100'
}).prop('disabled', true).click(renderCategoryAudit);
$catContainer.prepend($btn);
startBackgroundCategoryScan();
}
}
} else if (ns === 0 && mw.config.get("wgAction") === "view") {
handleSingleArticle();
}
});
})();
//</nowiki>
krfaj2ar9aq0fc5spewclmkk03udylw
739292
739291
2026-04-24T21:41:54Z
Sam Sailor
26820
Undid revision [[Special:Diff/739291|739291]] by [[Special:Contributions/Sam Sailor|Sam Sailor]] ([[User talk:Sam Sailor|talk]])
739292
javascript
text/javascript
//<nowiki>
(function() {
mw.util.addCSS(`
.ambox-Orphan { display: block !important; }
.comrade-container {
box-shadow: 0 1px 3px rgba(0,0,0,0.1); border-radius: 2px;
padding: 10px 15px; margin: 10px 0; display: flex;
flex-direction: column; align-items: flex-start; gap: 8px;
border: 1px solid #a2a9b1; background: #fcfcfc; line-height: 1.5;
}
.comrade-header { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.comrade-content { display: flex; flex-direction: column; gap: 5px; width: 100%; }
.comrade-success { background-color: #d5f5e3 !important; border-left: 5px solid #27ae60 !important; color: #1b5e20 !important; }
.comrade-nudge { background: #fff9ea; border-left: 5px solid #f0ad4e; }
.comrade-info { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-status { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-container button:not(.comrade-dismiss) {
background: #f8f9fa; border: 1px solid #a2a9b1; border-radius: 2px;
padding: 2px 10px; cursor: pointer; font-weight: bold; font-size: 0.95em; margin-left: 10px;
}
.comrade-container button:not(.comrade-dismiss):hover { border-color: #36c; color: #36c; background: #fff; }
.comrade-dismiss {
background: transparent; border: none; color: #d33; font-weight: bold;
cursor: pointer; font-size: 0.9em;
}
.comrade-dismiss:hover { text-decoration: underline; }
.comrade-code-block { background: #f1f1f1; padding: 4px 8px; border-radius: 3px; font-family: monospace; display: inline-block; margin-top: 4px; }
`);
function appendDismiss($el) {
$('<button>').addClass('comrade-dismiss').text('Dismiss').on('click', function() {
$(this).closest('.comrade-container').fadeOut();
}).appendTo($el.find('.comrade-header'));
$("#siteSub").after($el);
}
async function checkNamingPrecision(currentTitle, isHuman) {
const disambigMatch = currentTitle.match(/^(.+?)(?:, | \()/);
if (isHuman || !disambigMatch) return;
const baseTitle = disambigMatch[1].trim();
const api = new mw.Api();
try {
const baseRes = await api.get({
action: 'query',
titles: baseTitle,
redirects: 0,
formatversion: 2
});
const basePage = baseRes.query.pages[0];
const baseExists = !basePage.missing;
let baseRedirectsToSelf = false;
if (baseExists && basePage.redirect) {
const redirRes = await api.get({
action: 'query',
titles: baseTitle,
redirects: 1,
formatversion: 2
});
if (redirRes.query.pages[0].title === currentTitle) {
baseRedirectsToSelf = true;
}
}
const searchRes = await api.get({
action: 'query',
list: 'allpages',
apprefix: baseTitle,
aplimit: 50,
formatversion: 2
});
const competitors = searchRes.query.allpages.filter(p => {
const t = p.title;
const isCurrent = t === currentTitle;
const isBase = t === baseTitle;
const isDisambiguatedVersion = t.startsWith(baseTitle + ', ') || t.startsWith(baseTitle + ' (');
return !isCurrent && !isBase && isDisambiguatedVersion;
});
if (!baseExists || baseRedirectsToSelf) {
const $container = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (competitors.length === 0) {
$header.append($('<strong>').text('Precision:'));
const reason = baseRedirectsToSelf ? "already redirects here" : "is free";
$content.append($('<span>').append(`The base name `, $('<code>').text(baseTitle), ` ${reason}. Consider moving this article.`));
const moveUrl = mw.util.getUrl('Special:MovePage/' + currentTitle, {
wpNewTitle: baseTitle,
wpReason: 'Unnecessary disambiguation; base name is free/redirects here per [[WP:PRECISION]]'
});
$content.append($('<button>').text(`Move to ${baseTitle}`).on('click', () => window.open(moveUrl, '_blank')));
} else {
$header.append($('<strong>').text('Observation:'));
$content.append($('<span>').append(`Base name `, $('<code>').text(baseTitle), ` has ${competitors.length} competitors, but it is currently a ${baseRedirectsToSelf ? 'redirect to this page' : 'redlink'}.`));
$content.append($('<span>').text('Consider if a disambiguation page is needed.'));
}
$container.append($header, $content);
appendDismiss($container);
}
} catch (e) {
console.warn("Comrade: Precision check failed", e);
}
}
async function checkAndRenderRedirect(redirTitle, targetTitle, rCategory, labelText, customWikitext) {
const api = new mw.Api();
const res = await api.get({
action: 'query',
titles: redirTitle,
formatversion: 2
});
if (res.query.pages[0].missing) {
const safeId = "comrade-redir-" + redirTitle.replace(/[^a-z0-9]/gi, '-');
const $container = $('<div>').addClass('comrade-container comrade-info').attr('id', safeId);
const $header = $('<div>').addClass('comrade-header');
const summary = `Created redirect from ${labelText.toLowerCase()} to [[${targetTitle}]]`;
const $btn = $('<button>').text('Create redirect').on('click', () => createModernRedirect(redirTitle, targetTitle, rCategory, summary, safeId, customWikitext));
$header.append($('<div>').append($('<strong>').text(`${labelText}: `), `Create redirect from `, $('<code>').text(redirTitle), $btn));
$container.append($header);
appendDismiss($container);
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
if (qid) {
try {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
});
const res = await fetch(url).then(r => r.json());
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
}
} catch (e) {
console.warn("Comrade: Wikidata API call failed.");
}
}
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
if (domain.includes(".") && url.pathname === "/") {
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
try {
const response = await fetch(citationURL);
if (response.ok) {
await checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
}
} catch (e) {
console.warn("Comrade: Citation API check failed.");
}
}
} catch (e) {
console.warn("Comrade: Error parsing URL object.");
}
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan, resolvedTitle) {
const api = new mw.Api();
const candidates = resolvedTitle ? [resolvedTitle] : [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
redirects: 0
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const foundTitle = page.title;
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) break;
const $container = $('<div>').addClass('comrade-container');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (isOrphan) {
$container.addClass('comrade-nudge');
$header.append($('<strong>').text('Comrade nudge:'));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
}
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(foundTitle),
target: '_blank'
}).text(foundTitle), "; should it be?"));
const snippet = '* {{anbl|' + currentTitle + '}}';
const $snippetWrapper = $('<div>').css({
'display': 'flex',
'align-items': 'center',
'gap': '10px',
'margin-top': '4px'
});
const $instructionSpan = $('<span>').append('If the Short description is good, consider adding ', $('<code>').text(snippet).css({
'background-color': '#f8f9fa',
'border': '1px solid #eaecf0',
'border-radius': '2px',
'padding': '1px 4px'
}));
const $copyBtn = $('<button>').text('Copy & insert').css('margin-left', '0').on('click', function() {
navigator.clipboard.writeText(snippet).then(() => {
const $this = $(this);
const originalText = $this.text();
$this.text('Copied!').prop('disabled', true);
const editUrl = mw.util.getUrl(foundTitle, {
action: 'edit',
summary: snippet
});
window.open(editUrl, '_blank');
setTimeout(() => {
$this.text(originalText).prop('disabled', false);
}, 1500);
});
});
$snippetWrapper.append($instructionSpan, $copyBtn);
$content.append($snippetWrapper);
if (!hasShortDesc) {
$content.append($('<span>').css({
'color': '#d33',
'font-weight': 'bold',
'margin-top': '5px'
}).text('⚠️ Missing short description: Please add a concise one before listing.'));
}
$container.append($header, $content);
appendDismiss($container);
break;
}
}
}
}
async function createModernRedirect(redirTitle, targetTitle, rCategory, customSummary, elementId, customWikitext) {
const api = new mw.Api();
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
try {
await api.postWithEditToken({
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
});
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
} catch (e) {
mw.notify("Failed to create redirect.", {
type: 'error'
});
}
}
function getSmartSortKey(fullName, entity) {
const countryIds = entity.claims?.P27?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const locationIds = entity.claims?.P17?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const placeIds = [...(entity.claims?.P19 || []), ...(entity.claims?.P119 || [])].map(c => c.mainsnak?.datavalue?.value?.id).filter(Boolean);
const allRelevantIds = [...countryIds, ...locationIds, ...placeIds];
const birthYear = parseInt(entity.claims?.P569?.[0]?.mainsnak?.datavalue?.value?.time?.match(/[+-](\d{4})/)?.[1]) || null;
if (fullName.startsWith("Saint ")) return fullName.replace("Saint ", "");
const arabicParticles = /\b(Abu|Abd|Abdel|Abdul|ben|bin|bint)\b/i;
if (fullName.match(arabicParticles)) {
if (birthYear && birthYear > 1900) {
const parts = fullName.split(' ');
const binIndex = parts.findIndex(p => p.toLowerCase() === 'bin' || p.toLowerCase() === 'ben');
if (binIndex > 0) return parts.slice(binIndex).join(' ') + ', ' + parts.slice(0, binIndex).join(' ');
} else {
return fullName;
}
}
const eastAsianQids = ["Q148", "Q184", "Q884", "Q424", "Q881", "Q17"];
if (allRelevantIds.some(id => eastAsianQids.includes(id))) {
if (allRelevantIds.includes("Q17") && birthYear && birthYear > 1885) return westernSort(fullName);
const parts = fullName.split(' ');
if (parts.length > 1) return `${parts[0]}, ${parts.slice(1).join(' ')}`;
}
return westernSort(fullName);
}
function westernSort(name) {
let cleanName = name;
if (name.startsWith("O'")) cleanName = name.replace("O'", "O");
const parts = cleanName.split(' ');
if (parts.length < 2) return cleanName;
const surname = parts.pop();
if (["Jr.", "Jr", "III", "II", "Sr.", "Sr"].includes(surname)) {
const actualSurname = parts.pop();
return `${actualSurname}, ${parts.join(' ')} ${surname}`;
}
return `${surname}, ${parts.join(' ')}`;
}
async function performDeorphan() {
const api = new mw.Api();
try {
await api.edit(mw.config.get('wgPageName'), function(rev) {
let text = rev.content;
const summary = 'Article has backlinks; removed [[Template:Orphan|{{Orphan}}]]';
text = text.replace(/\{\{Orphan\s*(?:\|[^}]*)?\}\}\s*/gi, '');
text = text.replace(/(\{\{Multiple[ _]issues\s*)\|\s*\|/gi, '$1|');
text = replaceMI(text);
text = text.replace(/\n{3,}/g, '\n\n').trim();
return {
text,
summary,
minor: true
};
});
mw.notify('Orphan tag removed!', {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
setTimeout(() => location.reload(), 700);
} catch (e) {
mw.notify('Failed to remove orphan tag.', {
type: 'error'
});
}
}
function replaceMI(text) {
const miRegex = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miRegex.exec(text)) !== null) {
const start = match.index;
result += text.slice(lastIndex, start);
let depth = 0;
let i = start;
while (i < text.length) {
if (text[i] === '{' && text[i + 1] === '{') {
depth++;
i += 2;
} else if (text[i] === '}' && text[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
const end = i;
const full = text.slice(start, end);
const pipeIdx = full.indexOf('|');
const inner = full.slice(pipeIdx + 1, full.length - 2).trim();
const topLevel = extractTopLevelTemplates(inner);
if (topLevel.length === 0) {
result += '';
} else if (topLevel.length === 1) {
result += topLevel[0].trim();
} else {
result += full;
}
lastIndex = end;
}
result += text.slice(lastIndex);
return result;
}
function extractTopLevelTemplates(inner) {
const templates = [];
let i = 0;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
let depth = 0;
const start = i;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
depth++;
i += 2;
} else if (inner[i] === '}' && inner[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
templates.push(inner.slice(start, i));
} else {
i++;
}
}
return templates;
}
async function performSortCorrection(newName, type) {
const api = new mw.Api();
const title = mw.config.get("wgPageName");
try {
const data = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2
});
let content = data.query.pages[0].revisions[0].content;
const dsRegex = /\{\{(?:DEFAULTSORT|Defaultsort):[^}]+\}\}/i;
const newTag = `{{DEFAULTSORT:${newName}}}`;
let actionDesc = type === "add" ? "added" : "corrected";
if (dsRegex.test(content)) {
content = content.replace(dsRegex, newTag);
} else {
const catRegex = /\[\[Category:/i;
if (catRegex.test(content)) {
content = content.replace(catRegex, `${newTag}\n[[Category:`);
} else {
content += `\n\n${newTag}`;
}
}
await api.postWithEditToken({
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName}`,
nocreate: true
});
mw.notify(`DEFAULTSORT ${actionDesc} to ${newName}!`, {
title: 'Comrade Success',
type: 'success'
});
setTimeout(() => location.reload(), 700);
} catch (err) {
mw.notify('Failed to save DEFAULTSORT.', {
type: 'error'
});
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
function renderSortNudge(target, reason) {
const $dsNudge = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Correct to: ${target}`).on('click', () => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
async function fetchWikidataNameLabels(entity) {
const familyIds = entity.claims?.P734?.map(c => c.mainsnak.datavalue.value.id) || [];
const givenIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
const allNameIds = [...new Set([...familyIds, ...givenIds])];
if (allNameIds.length === 0) return {
givens: [],
surnames: []
};
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: allNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
});
const nameData = await fetch(url).then(r => r.json());
return {
givens: givenIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean),
surnames: familyIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean)
};
}
function fixNameCasing(part, referenceName) {
return part.split(' ').map(word => {
const match = referenceName.match(new RegExp(`\\b${word}\\b`, 'i'));
if (match) return match[0];
return referenceName.split(' ').find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase())) || word;
}).join(' ');
}
function getLongNameFromWikitext(wikitext) {
const firstLine = wikitext.split('\n').find(l => l.includes("'''"));
if (!firstLine) return null;
const boldMatch = firstLine.match(/'''(.+?)'''/);
if (!boldMatch) return null;
return boldMatch[1].replace(/\s*([“"«《„‹「『'].+?[”"»》”›」』']|\([^)\n]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
}
async function init() {
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") return;
const api = new mw.Api();
const qid = mw.config.get("wgWikibaseItemId");
try {
const [pageData, backlinkData] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
api.get({
action: 'query',
list: 'backlinks',
blfilterredir: 'nonredirects',
bllimit: 50,
blnamespace: 0,
bltitle: mw.config.get("wgPageName"),
formatversion: 2
})
]);
const page = pageData.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const currentTitle = mw.config.get("wgTitle");
const cleanTitle = stripDiacritics(currentTitle);
const isOrphanTagged = /\{\{\s*(Orphan|Do-attempt|Lonely|Orp|Orphaned article)/i.test(wikitext) || /\| *orphan *=/i.test(wikitext);
const backLinkCount = backlinkData.query.backlinks.length;
if (isOrphanTagged && backLinkCount >= 1) {
const $container = $('<div>').addClass('comrade-container comrade-status');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text('Remove orphan tag').on('click', () => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) await checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
await checkDomainRedirect(qid, currentTitle);
let isHuman = false;
let entity = null;
if (qid) {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
});
const wikiData = await fetch(url).then(r => r.json());
entity = wikiData.entities[qid];
isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
}
await checkNamingPrecision(currentTitle, isHuman);
if (qid && entity) {
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const nameParts = tClean.split(' ');
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
const isOrphan = isOrphanTagged && backLinkCount < 1;
const primarySurname = hasFamilyNameHatnote ? nameParts[0] : (getSmartSortKey(tClean, entity).split(',')[0] || nameParts[nameParts.length - 1]);
let targetSortName = "";
if (isHuman) {
const {
givens,
surnames
} = await fetchWikidataNameLabels(entity);
const refName = entity.labels?.en?.value || currentTitle;
if (dsMatch) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixNameCasing(lastPart, refName)}, ${fixNameCasing(firstPart, refName)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field.");
} else if (givens.length > 0 && lastPart.split(' ').length > 1) {
const misplacedGiven = lastPart.split(' ').find(part => givens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastPart.split(' ').filter(p => p !== misplacedGiven).join(' ');
targetSortName = `${fixNameCasing(newSurname, refName)}, ${fixNameCasing(misplacedGiven + ' ' + firstPart, refName)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
}
}
} else {
targetSortName = hasFamilyNameHatnote ? `${nameParts[0]}, ${nameParts.slice(1).join(' ')}` : getSmartSortKey(tClean, entity);
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).on('click', () => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
const longName = getLongNameFromWikitext(wikitext);
if (longName && longName.length > tClean.length) {
let sortKey = hasFamilyNameHatnote ? `${longName.split(' ')[0]}, ${longName.split(' ').slice(1).join(' ')}` : getSmartSortKey(longName, entity);
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
await checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
if (targetSortName) {
let targetPage = currentTitle.includes(' (') ? currentTitle.split(' (')[0] : currentTitle;
let rCat = targetPage !== currentTitle ? "R from ambiguous sort name" : "R from sort name";
if (targetSortName.includes(',')) {
const p = targetSortName.split(',');
rCat += `|${p[0].trim().charAt(0).toUpperCase()}|${p[1].trim().charAt(0).toUpperCase()}`;
}
await checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name");
}
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
surnames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
givens.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) finalGivenNames.add(gn);
});
if (finalFamilyNames.size === 0 && nameParts.length >= 2) finalFamilyNames.add(nameParts[nameParts.length - 1]);
if (finalGivenNames.size === 0 && nameParts.length >= 2) finalGivenNames.add(nameParts[0]);
const getPriorityTarget = async (name, type) => {
const titles = type === 'surname' ? [`List of people with surname ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`] : [`List of people with given name ${name}`, `${name} (given name)`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
return titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
};
for (const name of finalGivenNames) {
if (name.toLowerCase() === primarySurname.toLowerCase()) continue;
const target = await getPriorityTarget(name, 'given');
if (target) await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
for (const name of finalFamilyNames) {
const titles = [name, `${name} (surname)`, `List of people with surname ${name}`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
const target = titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
if (target) await checkNameList(name, 'surname', currentTitle, hasShortDesc, isOrphan, target);
}
}
}
} catch (err) {
console.error("Comrade error:", err);
}
}
init();
})();
(function() {
if (window.IllWill) return;
const STRIKEOUT = true;
const HIDE = 'hide';
const IllWill = {
Version: "1.1.1",
Attribution: 'created by <a target="_blank" href="/wiki/User:Cobaltcigs">cobaltcigs</a>',
Summary: "Added interlanguage links using IllWill.js",
DataApi: "https://www.wikidata.org/w/api.php",
LocalLang: mw.config.get("wgPageContentLanguage"),
Disambiguator: / \(.*\)$/,
SiteLinks: {},
Config: {
MaxLanguages: 5,
IgnoreScientific: STRIKEOUT,
AutoDiff: true
},
css: `
#illwill { clear: both; margin-bottom: 1.5em; border: 1px solid #a2a9b1; padding: 15px; background: #f8f9fa; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.ill-sitelinks a { display: inline-block; margin-bottom: 3px; font-size: 0.9em; color: #36c; }
.ill-size-tag { font-size: 0.8em; color: #72777d; margin-left: 6px; white-space: nowrap; }
.illwill-ignore { text-decoration: line-through; color: #72777d; font-size: 0.85em; }
.ill-x { width: 35px; text-align: center; }
.ill-input, .ill-output { font-family: 'Courier New', monospace; white-space: pre-wrap; font-size: 0.9em; background: #fff; border: 1px solid #eaecf0; padding: 4px; border-radius: 2px; }
#ill-buttons { text-align: right; padding: 10px; border-top: 1px solid #a2a9b1; margin-top: 10px; }
#ill-buttons button { margin-left: 10px; cursor: pointer; padding: 6px 16px; border-radius: 2px; border: 1px solid #a2a9b1; transition: all 0.2s; }
#ill-ok { background: #36c; color: #fff; border-color: #36c !important; font-weight: bold; }
#ill-ok:hover { background: #447ff5; }
#ill-ok:disabled { background: #eaecf0; color: #72777d; border-color: #c8ccd1 !important; cursor: not-allowed; opacity: 0.7; }
#ill-table { width: 100%; border-collapse: collapse; margin-top: 10px; background: white; }
#ill-table th { background: #f2f2f2; text-align: left; padding: 8px; border: 1px solid #a2a9b1; }
#ill-table td { padding: 8px; border: 1px solid #a2a9b1; vertical-align: top; }
#ill-table caption { background: #eaffea; font-weight: bold; padding: 10px; border: 1px solid #a2a9b1; border-bottom: none; font-size: 1.1em; }
#ill-help { font-size: 0.85em; color: #54595d; padding: 10px; line-height: 1.4; }
.ill-loading-inline { font-style: italic; color: #72777d; font-size: 0.9em; display: block; margin: 5px 0; }
`,
setup() {
if (!["edit", "submit"].includes(mw.config.get('wgAction'))) return;
mw.loader.addStyleTag(this.css);
const link = mw.util.addPortletLink('p-cactions', '#', 'IllWill', 'ca-illwill', "Add interlanguage links via Wikidata");
if (link) {
link.addEventListener('click', (e) => {
e.preventDefault();
this.makePanel();
});
}
},
norm(s) {
if (!s) return "";
const t = s.trim().replace(/[\s_]+/g, " ");
return t.charAt(0).toUpperCase() + t.slice(1);
},
async getRedLinks($textbox) {
const txt = $textbox.textSelection('getContents') || "";
const wl = txt.match(/\[\[[^[\]\n]+\]\]/g);
if (!wl) return {
reds: [],
txt
};
const api = new mw.Api();
const textToParse = wl.join("").replace(/\|[^\]]+/g, "");
try {
const data = await api.post({
action: 'parse',
text: textToParse,
contentmodel: 'wikitext',
formatversion: 2
});
const tempDiv = document.createElement('div');
tempDiv.innerHTML = data.parse.text;
const reds = Array.from(tempDiv.querySelectorAll("a.new")).map(x => {
const urlParams = new URLSearchParams(x.href.split('?')[1]);
return urlParams.get('title')?.replace(/_/g, " ");
}).filter(Boolean);
return {
reds: [...new Set(reds)],
txt
};
} catch (e) {
return {
reds: [],
txt
};
}
},
async makePanel() {
const $editform = $('#editform');
const $textbox = $('#wpTextbox1');
$('#illwill').remove();
$editform.hide();
const $wrapper = $('<div id="illwill">Loading redlinks...</div>').insertBefore($editform);
const {
reds,
txt
} = await this.getRedLinks($textbox);
const wtLinks = [];
reds.forEach(title => {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s/g, "[\\s_]+");
const re = new RegExp("\\[\\[\\s*" + escaped + "\\s*(?:\\|[^\\[\\]]+)?\\]\\]", "ig");
const match = txt.match(re);
if (match && !wtLinks.some(z => z.title === title)) {
wtLinks.push({
link: match[0],
title
});
}
});
if (!wtLinks.length) {
const $back = $('<button>Quit</button>').on('click', () => {
$wrapper.remove();
$editform.show();
});
$wrapper.empty().append($('<strong>No valid redlinks found in text.</strong> '), $back);
return;
}
const $table = $(`
<table id="ill-table" class="wikitable">
<caption>IllWill.js (v${this.Version})</caption>
<thead>
<tr>
<th class="ill-x"><input type="checkbox" id="ill-select-all" disabled></th>
<th>Input link</th>
<th>Possible Wikidata matches</th>
<th>Sitelinks (top ${this.Config.MaxLanguages} by size)</th>
<th>Resulting wikitext</th>
</tr>
</thead>
<tbody></tbody>
<tfoot>
<tr>
<td colspan="3" id="ill-help">Carefully examine possible Wikidata matches.</td>
<td colspan="2" id="ill-buttons">
<button id="ill-cancel">Cancel</button>
<button id="ill-ok" disabled>Show changes</button>
</td>
</tr>
</tfoot>
</table>
`);
const $tbody = $table.find('tbody');
wtLinks.forEach((item, i) => {
$tbody.append(`
<tr id="ill-row-${i}" data-index="${i}">
<td class="ill-x"><input type="checkbox" class="ill-row-check" disabled></td>
<td class="ill-input">${this.escapeHTML(item.link)}</td>
<td class="ill-wd-item"><span class="ill-loading-inline">Searching...</span></td>
<td class="ill-sitelinks"></td>
<td class="ill-output"></td>
</tr>
`);
});
$wrapper.empty().append($table);
const chunkSize = 5;
for (let i = 0; i < wtLinks.length; i += chunkSize) {
const chunk = wtLinks.slice(i, i + chunkSize);
await Promise.all(chunk.map((item, idx) => this.fetchWikidataOptimized(item.title, i + idx)));
}
$table.on('click', '#ill-cancel', () => {
$editform.show();
$wrapper.remove();
});
$table.on('click', '#ill-ok', () => this.onOkButton($editform, $wrapper, $textbox));
$table.on('change', '#ill-select-all', (e) => {
const isChecked = $(e.target).is(':checked');
$table.find('.ill-row-check:not(:disabled)').each(function() {
$(this).prop('checked', isChecked).trigger('change');
});
});
$table.on('change', '.ill-row-check', (e) => {
this.onCheckbox(e);
this.updateOkButtonState($table);
});
$table.on('change', 'input[type="radio"]', (e) => {
this.showSiteLinks(e);
});
},
async fetchWikidataOptimized(title, index) {
const query = title.replace(this.Disambiguator, "");
const url = `${this.DataApi}?action=wbsearchentities&search=${encodeURIComponent(query)}&language=${this.LocalLang}&limit=5&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
const results = data.search;
const $cell = $(`#ill-row-${index} .ill-wd-item`);
if (!results || !results.length) {
$cell.html('<span class="ill-na">(no results)</span>');
return;
}
const entityIds = results.map(r => r.id);
await this.fetchEntities(entityIds, index, $cell);
} catch (e) {
$(`#ill-row-${index} .ill-wd-item`).text("Search failed.");
}
},
async fetchEntities(ids, rowIndex, $cell) {
const url = `${this.DataApi}?action=wbgetentities&ids=${ids.join('|')}&props=labels|descriptions|claims|sitelinks&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
$cell.empty();
ids.forEach(qNum => {
const entity = data.entities[qNum];
if (!entity) return;
const isScientific = entity.claims?.P31?.some(s => s.mainsnak?.datavalue?.value?.id === "Q13442814");
if (isScientific && this.Config.IgnoreScientific === HIDE) return;
const desc = entity.descriptions?.[this.LocalLang]?.value || entity.descriptions?.en?.value || "";
const label = entity.labels?.[this.LocalLang]?.value || entity.labels?.en?.value || qNum;
const isStrikeout = isScientific && this.Config.IgnoreScientific === STRIKEOUT;
const $div = $(`
<div class="${isStrikeout ? 'illwill-ignore' : ''}">
<input type="radio" name="selection-${rowIndex}" value="${qNum}" id="radio-${qNum}" ${isStrikeout ? 'disabled' : ''}>
<label for="radio-${qNum}">
<a href="https://wikidata.org/wiki/${qNum}" target="_blank">${this.escapeHTML(label)}</a>:
<span class="ill-desc">${desc ? this.escapeHTML(desc) : '<i>no description</i>'}</span>
</label>
</div>
`);
$cell.append($div);
this.SiteLinks[qNum] = entity.sitelinks || {};
});
} catch (e) {
$cell.text("Detail fetch failed.");
}
},
updateOkButtonState($table) {
const $rows = $table.find('.ill-row-check:not(:disabled)');
const $checkedRows = $rows.filter(':checked');
const anyEnabled = $rows.length > 0;
$table.find('#ill-ok').prop('disabled', $checkedRows.length === 0);
const $master = $table.find('#ill-select-all');
$master.prop('disabled', !anyEnabled);
if (anyEnabled && $rows.length === $checkedRows.length) {
$master.prop('checked', true);
} else {
$master.prop('checked', false);
}
},
async showSiteLinks(e) {
const isManualToggling = e.fromCheckbox || false;
const qNum = e.target.value;
const $row = $(e.target).closest('tr');
const sitelinks = this.SiteLinks[qNum];
const $checkbox = $row.find('.ill-row-check');
const $sitelinkCell = $row.find('.ill-sitelinks');
const $outputCell = $row.find('.ill-output');
const $table = $('#ill-table');
const effectiveMax = Math.min(Math.max(this.Config.MaxLanguages, 3), 6);
const validKeys = Object.keys(sitelinks).filter(k => k.endsWith('wiki') && !k.includes('commons'));
$sitelinkCell.html('<span class="ill-loading-inline">Measuring articles...</span>');
$outputCell.empty();
if (!isManualToggling) {
$checkbox.prop('disabled', true).prop('checked', false);
}
const candidates = validKeys.slice(0, 25);
const sizeMap = [];
await Promise.all(candidates.map(async (key) => {
const lang = key.replace('wiki', '').replace(/_/g, '-');
const title = sitelinks[key].title;
const endpoint = `https://${lang}.wikipedia.org/w/api.php?action=query&titles=${encodeURIComponent(title)}&prop=info&format=json&origin=*`;
try {
const res = await fetch(endpoint).then(r => r.json());
const page = Object.values(res.query.pages)[0];
sizeMap.push({
lang,
title,
length: page.length || 0
});
} catch (err) {
sizeMap.push({
lang,
title,
length: 0
});
}
}));
sizeMap.sort((a, b) => b.length - a.length);
const topLinks = sizeMap.slice(0, effectiveMax);
$sitelinkCell.html(topLinks.map(item => {
const kbValue = Math.round(item.length / 1024);
return `<div><a href="https://${item.lang}.wikipedia.org/wiki/${encodeURIComponent(item.title)}" target="_blank">${item.lang}:${this.escapeHTML(item.title)}</a> <span class="ill-size-tag">(${kbValue} kB)</span></div>`;
}).join(""));
const inputWikitext = $row.find('.ill-input').text();
const match = inputWikitext.match(/\[\[(.*?)\]\]/);
if (match) {
const parts = match[1].split('|');
const targetPage = parts[0].trim();
const surfaceName = (parts[1] || parts[0]).trim();
const illLangs = topLinks.map(item => {
const isSameTitle = this.norm(item.title) === this.norm(targetPage);
return `|${item.lang}|${isSameTitle ? '' : item.title}`;
}).join("");
const ltParam = (this.norm(surfaceName) === this.norm(targetPage)) ? "" : `|lt=${surfaceName}`;
$outputCell.text(`{{ill|${targetPage}${ltParam}${illLangs}}}`);
}
$checkbox.prop('disabled', !topLinks.length);
if (!isManualToggling) {
$checkbox.prop('checked', !!topLinks.length);
}
this.updateOkButtonState($table);
},
onCheckbox(e) {
const $row = $(e.target).closest('tr');
const $radio = $row.find('input[type="radio"]:checked');
const isChecked = $(e.target).is(':checked');
if (isChecked) {
if ($radio.length) {
this.showSiteLinks({
target: $radio[0],
fromCheckbox: true
});
}
} else {
$row.find('.ill-sitelinks, .ill-output').empty();
}
},
onOkButton($editform, $wrapper, $textbox) {
let text = $textbox.textSelection('getContents');
let changed = false;
$wrapper.find('.ill-row-check:checked').each(function() {
const $row = $(this).closest('tr');
const input = $row.find('.ill-input').text();
const output = $row.find('.ill-output').text();
if (output) {
const escapedInput = input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
text = text.replace(new RegExp(escapedInput, "gi"), output);
changed = true;
}
});
if (changed) {
$('#wpSummary').val((i, v) => (v ? v + "; " : "") + this.Summary);
$textbox.textSelection('setContents', text);
$wrapper.remove();
$editform.show();
if (this.Config.AutoDiff) $('#wpDiff').click();
}
},
escapeHTML(str) {
return str.replace(/[&<>"']/g, m => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
})[m]);
}
};
window.IllWill = IllWill;
mw.loader.using(['mediawiki.util', 'mediawiki.api', 'jquery.textSelection'], () => {
$(() => IllWill.setup());
});
})();
(function() {
'use strict';
const config = {
enableNS14: true,
rootWhitelists: ['Materials'],
userWhitelist: ""
};
const categoryDataCache = {};
let scanProgress = 0;
let totalToScan = 0;
async function verifyTreeMembership() {
if (!config.enableNS14) return false;
const api = new mw.Api();
const currentTitle = mw.config.get("wgPageName").replace(/_/g, ' ');
const fullRoots = config.rootWhitelists.concat(config.userWhitelist.split(',').map(s => s.trim()).filter(s => s)).map(cat => (cat.startsWith('Category:') ? cat : `Category:${cat}`));
if (fullRoots.includes(currentTitle)) return true;
let categoriesToTable = mw.config.get('wgCategories') || [];
let checked = new Set();
while (categoriesToTable.length > 0) {
if (categoriesToTable.some(cat => fullRoots.includes('Category:' + cat))) {
return true;
}
categoriesToTable.forEach(cat => checked.add(cat));
try {
const res = await api.get({
formatversion: 2,
prop: 'categories',
titles: categoriesToTable.map(c => 'Category:' + c),
cllimit: 'max'
});
let nextBatch = [];
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.categories) {
page.categories.forEach(parent => {
const parentName = parent.title.substring('Category:'.length);
if (!checked.has(parentName)) {
nextBatch.push(parentName);
}
});
}
});
}
categoriesToTable = [...new Set(nextBatch)];
} catch (e) {
return false;
}
}
return false;
}
function getChemboxBlock(wikitext) {
const chemboxRegex = /\{\{\s*Chembox[\s_]*(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const match = wikitext.match(chemboxRegex);
return match ? match[0] : null;
}
function getFormulaFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const formulaMatch = chembox.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
let rawFormula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
rawFormula = rawFormula.split(/\s+/)[0];
return rawFormula.split(',')[0].trim();
}
const propRegex = /\{\{\s*Chembox[\s_]+Properties(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const propSection = chembox.match(propRegex);
if (propSection) {
const content = propSection[0];
const allElements = ['Ac', 'Ag', 'Al', 'Am', 'Ar', 'As', 'At', 'Au', 'B', 'Ba', 'Be', 'Bh', 'Bi', 'Bk', 'Br', 'C', 'Ca', 'Cd', 'Ce', 'Cf', 'Cl', 'Cm', 'Cn', 'Co', 'Cr', 'Cs', 'Cu', 'D', 'Db', 'Ds', 'Dy', 'Er', 'Es', 'Eu', 'F', 'Fe', 'Fl', 'Fm', 'Fr', 'Ga', 'Gd', 'Ge', 'H', 'He', 'Hf', 'Hg', 'Ho', 'Hs', 'I', 'In', 'Ir', 'K', 'Kr', 'La', 'Li', 'Lr', 'Lu', 'Lv', 'Mc', 'Md', 'Mg', 'Mn', 'Mo', 'Mt', 'N', 'Na', 'Nb', 'Nd', 'Ne', 'Nh', 'Ni', 'No', 'Np', 'O', 'Og', 'Os', 'P', 'Pa', 'Pb', 'Pd', 'Pm', 'Po', 'Pr', 'Pt', 'Pu', 'Ra', 'Rb', 'Re', 'Rf', 'Rg', 'Rh', 'Rn', 'Ru', 'S', 'Sb', 'Sc', 'Se', 'Sg', 'Si', 'Sm', 'Sn', 'Sr', 'Ta', 'Tb', 'Tc', 'Te', 'Th', 'Ti', 'Tl', 'Tm', 'Ts', 'U', 'V', 'W', 'Xe', 'Y', 'Yb', 'Zn', 'Zr'];
let data = {};
allElements.forEach(el => {
const elRegex = new RegExp(`\\|\\s*${el}\\s*=\\s*(\\d*\\.?\\d+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch && elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
});
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
elementsPresent.filter(el => !(hasC && (el === "C" || el === "H"))).sort().forEach(el => {
formulaArray.push(el + data[el]);
});
return formulaArray.join('');
}
}
return null;
}
function getCASFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const idRegex = /\{\{\s*Chembox[\s_]+Identifiers(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const idSection = chembox.match(idRegex);
if (!idSection) return null;
const content = idSection[0];
const nbsp = String.fromCharCode(160);
const casMatch = content.match(/\|\s*CASNo\d*\b(?!\s*_)\s*=\s*([^|}\n]+)/i);
if (casMatch) {
let val = casMatch[1].replace(/\{\{[^}]+\}\}/g, "").replace(/\u00AD/g, "").split(" ").join("");
while (val.indexOf(nbsp) !== -1) val = val.replace(nbsp, "");
while (val.indexOf(" ") !== -1) val = val.replace(" ", "");
const cleanedCas = val.trim();
return /^\d{2,7}-\d{2}-\d$/.test(cleanedCas) ? cleanedCas : null;
}
return null;
}
async function getWikidataValue() {
const qid = mw.config.get('wgWikidataItemId');
if (!qid) return null;
const api = new mw.ForeignApi('https://www.wikidata.org/w/api.php');
const res = await api.get({
action: 'wbgetentities',
ids: qid,
props: 'claims',
format: 'json',
formatversion: 2
});
const claims = res.entities[qid].claims;
const data = {};
if (claims.P274) data.formula = claims.P274[0].mainsnak.datavalue.value;
if (claims.P231) data.cas = claims.P231[0].mainsnak.datavalue.value;
return data;
}
async function startBackgroundCategoryScan() {
const api = new mw.Api();
const titles = $('#mw-pages li a').map((i, el) => $(el).attr('title') || $(el).text()).get();
totalToScan = titles.length;
if (totalToScan === 0) return;
const chunks = arrayChunk(titles, 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: chunk,
rvprop: 'content',
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const f = getFormulaFromWikitext(wikitext);
const c = getCASFromWikitext(wikitext);
if (f || c) categoryDataCache[page.title] = {
formula: f,
cas: c
};
});
}
scanProgress += chunk.length;
$('#caschemassist-cat-btn').text(`Scanning... ${scanProgress}/${totalToScan}`);
}
$('#caschemassist-cat-btn').text('Show missing CAS/Chem').prop('disabled', false);
}
async function renderCategoryAudit() {
const api = new mw.Api();
const missingCheckTitles = [];
Object.values(categoryDataCache).forEach(data => {
if (data.formula) missingCheckTitles.push(data.formula);
if (data.cas) missingCheckTitles.push(data.cas);
});
if (missingCheckTitles.length === 0) {
mw.notify("No valid Chembox data found in this category.");
return;
}
const existenceMap = {};
const chunks = arrayChunk([...new Set(missingCheckTitles)], 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
titles: chunk,
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(p => {
existenceMap[p.title] = !p.missing;
});
}
}
$('#mw-pages li').each(function() {
const title = $(this).find('a').attr('title') || $(this).find('a').text().trim();
const data = categoryDataCache[title];
if (!data) return;
if (data.formula && !existenceMap[data.formula]) {
$(this).append($('<span>').css({
'color': 'red',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [${data.formula}]`));
}
if (data.cas && !existenceMap[data.cas]) {
$(this).append($('<span>').css({
'color': 'darkred',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [CAS: ${data.cas}]`));
}
});
$('#caschemassist-cat-btn').text('Audit complete').prop('disabled', true);
}
async function handleSingleArticle() {
const api = new mw.Api();
const currentTitle = mw.config.get("wgTitle");
const [wikiRes, wikidata] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
getWikidataValue()
]);
const page = wikiRes.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const formula = getFormulaFromWikitext(wikitext);
const cas = getCASFromWikitext(wikitext);
if (wikidata) {
let mismatches = [];
if (formula && wikidata.formula && formula !== wikidata.formula) {
mismatches.push(`Formula mismatch: Chembox "${formula}" vs Wikidata "${wikidata.formula}"`);
}
if (cas && wikidata.cas && cas !== wikidata.cas) {
mismatches.push(`CAS mismatch: Chembox "${cas}" vs Wikidata "${wikidata.cas}"`);
}
if (mismatches.length > 0) {
const $warning = $('<div>').css({
'background': '#fff2f2',
'border': '2px solid #d33',
'padding': '10px',
'margin-bottom': '1em',
'color': '#b32424'
}).append($('<strong>').text('⚠️ Data Mismatch Detected: '), mismatches.join(' | '));
$('#mw-content-text').prepend($warning);
}
}
async function checkAndRender(target, label, customWikitext) {
const check = await api.get({
action: 'query',
titles: target,
formatversion: 2
});
if (check.query.pages[0].missing) {
const $container = $('<div>').css({
'background': '#f8f9fa',
'border': '1px solid #a2a9b1',
'padding': '10px',
'margin-bottom': '1em'
});
const $btn = $('<button>').text(`Create redirect: ${target}`).click(async function() {
await api.postWithEditToken({
action: 'edit',
title: target,
text: customWikitext,
summary: `Creating redirect to [[${currentTitle}]] (${label})`,
createonly: true
});
mw.notify(`Created redirect: ${target}`);
$container.fadeOut(400, function() {
$(this).remove();
});
});
$container.append($('<strong>').text(`${label}: `), `Missing redirect. `, $btn);
$('#mw-content-text').prepend($container);
}
}
if (formula && formula !== currentTitle) {
const formulaWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from chemical formula}}\n}}`;
await checkAndRender(formula, "Chemical formula", formulaWikitext);
}
if (cas && cas !== currentTitle) {
const casWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from CAS Registry Number}}\n{{R unprintworthy}}\n}}`;
await checkAndRender(cas, "CAS number", casWikitext);
}
}
function arrayChunk(arr, size) {
const result = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
$(document).ready(async function() {
const ns = mw.config.get("wgNamespaceNumber");
if (ns === 14) {
if (await verifyTreeMembership()) {
const $catContainer = $('#mw-pages');
if ($catContainer.length) {
const $btn = $('<button>').text('Initializing scan...').attr('id', 'caschemassist-cat-btn').addClass('mw-ui-button').css({
'margin': '0 10px 10px 0',
'float': 'right',
'position': 'relative',
'z-index': '100'
}).prop('disabled', true).click(renderCategoryAudit);
$catContainer.prepend($btn);
startBackgroundCategoryScan();
}
}
} else if (ns === 0 && mw.config.get("wgAction") === "view") {
handleSingleArticle();
}
});
})();
//</nowiki>
3t7dswj4il7biagzxdrqonru5iach76
739293
739292
2026-04-24T21:47:58Z
Sam Sailor
26820
Test
739293
javascript
text/javascript
//<nowiki>
(function() {
mw.util.addCSS(`
.ambox-Orphan { display: block !important; }
.comrade-container {
box-shadow: 0 1px 3px rgba(0,0,0,0.1); border-radius: 2px;
padding: 10px 15px; margin: 10px 0; display: flex;
flex-direction: column; align-items: flex-start; gap: 8px;
border: 1px solid #a2a9b1; background: #fcfcfc; line-height: 1.5;
}
.comrade-header { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.comrade-content { display: flex; flex-direction: column; gap: 5px; width: 100%; }
.comrade-success { background-color: #d5f5e3 !important; border-left: 5px solid #27ae60 !important; color: #1b5e20 !important; }
.comrade-nudge { background: #fff9ea; border-left: 5px solid #f0ad4e; }
.comrade-info { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-status { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-container button:not(.comrade-dismiss) {
background: #f8f9fa; border: 1px solid #a2a9b1; border-radius: 2px;
padding: 2px 10px; cursor: pointer; font-weight: bold; font-size: 0.95em; margin-left: 10px;
}
.comrade-container button:not(.comrade-dismiss):hover { border-color: #36c; color: #36c; background: #fff; }
.comrade-dismiss {
background: transparent; border: none; color: #d33; font-weight: bold;
cursor: pointer; font-size: 0.9em;
}
.comrade-dismiss:hover { text-decoration: underline; }
.comrade-code-block { background: #f1f1f1; padding: 4px 8px; border-radius: 3px; font-family: monospace; display: inline-block; margin-top: 4px; }
`);
function appendDismiss($el) {
$('<button>').addClass('comrade-dismiss').text('Dismiss').on('click', function() {
$(this).closest('.comrade-container').fadeOut();
}).appendTo($el.find('.comrade-header'));
$("#siteSub").after($el);
}
async function checkNamingPrecision(currentTitle, isHuman) {
const disambigMatch = currentTitle.match(/^(.+?)(?:, | \()/);
if (isHuman || !disambigMatch) return;
const baseTitle = disambigMatch[1].trim();
const api = new mw.Api();
try {
const baseRes = await api.get({
action: 'query',
titles: baseTitle,
redirects: 1,
formatversion: 2
});
const page = baseRes.query.pages[0];
const baseExists = !page.missing;
let baseRedirectsToSelf = false;
if (baseRes.query.redirects) {
baseRedirectsToSelf = baseRes.query.redirects.some(r => r.to === currentTitle);
}
const searchRes = await api.get({
action: 'query',
list: 'allpages',
apprefix: baseTitle,
aplimit: 50,
formatversion: 2
});
const competitors = searchRes.query.allpages.filter(p => {
const t = p.title;
return t !== currentTitle && t !== baseTitle && (t.startsWith(baseTitle + ', ') || t.startsWith(baseTitle + ' ('));
});
if (!baseExists || baseRedirectsToSelf) {
const $container = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (competitors.length === 0) {
$header.append($('<strong>').text('Precision:'));
const reason = baseRedirectsToSelf ? "already redirects here" : "is free";
$content.append($('<span>').append(`The base name `, $('<code>').text(baseTitle), ` ${reason}. Consider moving this article.`));
const moveUrl = mw.util.getUrl('Special:MovePage/' + currentTitle, {
wpNewTitle: baseTitle,
wpReason: 'Unnecessary disambiguation; base name is free/redirects here per [[WP:PRECISION]]'
});
$content.append($('<button>').text(`Move to ${baseTitle}`).on('click', () => window.open(moveUrl, '_blank')));
} else {
$header.append($('<strong>').text('Observation:'));
const state = baseRedirectsToSelf ? 'redirect to this page' : 'redlink';
$content.append($('<span>').append(`Base name `, $('<code>').text(baseTitle), ` has ${competitors.length} competitors, but it is currently a ${state}.`));
$content.append($('<span>').text('Consider if a disambiguation page is needed.'));
}
$container.append($header, $content);
appendDismiss($container);
}
} catch (e) {
console.warn("Comrade: Precision check failed", e);
}
}
async function checkAndRenderRedirect(redirTitle, targetTitle, rCategory, labelText, customWikitext) {
const api = new mw.Api();
const res = await api.get({
action: 'query',
titles: redirTitle,
formatversion: 2
});
if (res.query.pages[0].missing) {
const safeId = "comrade-redir-" + redirTitle.replace(/[^a-z0-9]/gi, '-');
const $container = $('<div>').addClass('comrade-container comrade-info').attr('id', safeId);
const $header = $('<div>').addClass('comrade-header');
const summary = `Created redirect from ${labelText.toLowerCase()} to [[${targetTitle}]]`;
const $btn = $('<button>').text('Create redirect').on('click', () => createModernRedirect(redirTitle, targetTitle, rCategory, summary, safeId, customWikitext));
$header.append($('<div>').append($('<strong>').text(`${labelText}: `), `Create redirect from `, $('<code>').text(redirTitle), $btn));
$container.append($header);
appendDismiss($container);
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
if (qid) {
try {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
});
const res = await fetch(url).then(r => r.json());
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
}
} catch (e) {
console.warn("Comrade: Wikidata API call failed.");
}
}
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
if (domain.includes(".") && url.pathname === "/") {
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
try {
const response = await fetch(citationURL);
if (response.ok) {
await checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
}
} catch (e) {
console.warn("Comrade: Citation API check failed.");
}
}
} catch (e) {
console.warn("Comrade: Error parsing URL object.");
}
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan, resolvedTitle) {
const api = new mw.Api();
const candidates = resolvedTitle ? [resolvedTitle] : [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
redirects: 0
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const foundTitle = page.title;
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) break;
const $container = $('<div>').addClass('comrade-container');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (isOrphan) {
$container.addClass('comrade-nudge');
$header.append($('<strong>').text('Comrade nudge:'));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
}
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(foundTitle),
target: '_blank'
}).text(foundTitle), "; should it be?"));
const snippet = '* {{anbl|' + currentTitle + '}}';
const $snippetWrapper = $('<div>').css({
'display': 'flex',
'align-items': 'center',
'gap': '10px',
'margin-top': '4px'
});
const $instructionSpan = $('<span>').append('If the Short description is good, consider adding ', $('<code>').text(snippet).css({
'background-color': '#f8f9fa',
'border': '1px solid #eaecf0',
'border-radius': '2px',
'padding': '1px 4px'
}));
const $copyBtn = $('<button>').text('Copy & insert').css('margin-left', '0').on('click', function() {
navigator.clipboard.writeText(snippet).then(() => {
const $this = $(this);
const originalText = $this.text();
$this.text('Copied!').prop('disabled', true);
const editUrl = mw.util.getUrl(foundTitle, {
action: 'edit',
summary: snippet
});
window.open(editUrl, '_blank');
setTimeout(() => {
$this.text(originalText).prop('disabled', false);
}, 1500);
});
});
$snippetWrapper.append($instructionSpan, $copyBtn);
$content.append($snippetWrapper);
if (!hasShortDesc) {
$content.append($('<span>').css({
'color': '#d33',
'font-weight': 'bold',
'margin-top': '5px'
}).text('⚠️ Missing short description: Please add a concise one before listing.'));
}
$container.append($header, $content);
appendDismiss($container);
break;
}
}
}
}
async function createModernRedirect(redirTitle, targetTitle, rCategory, customSummary, elementId, customWikitext) {
const api = new mw.Api();
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
try {
await api.postWithEditToken({
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
});
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
} catch (e) {
mw.notify("Failed to create redirect.", {
type: 'error'
});
}
}
function getSmartSortKey(fullName, entity) {
const countryIds = entity.claims?.P27?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const locationIds = entity.claims?.P17?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const placeIds = [...(entity.claims?.P19 || []), ...(entity.claims?.P119 || [])].map(c => c.mainsnak?.datavalue?.value?.id).filter(Boolean);
const allRelevantIds = [...countryIds, ...locationIds, ...placeIds];
const birthYear = parseInt(entity.claims?.P569?.[0]?.mainsnak?.datavalue?.value?.time?.match(/[+-](\d{4})/)?.[1]) || null;
if (fullName.startsWith("Saint ")) return fullName.replace("Saint ", "");
const arabicParticles = /\b(Abu|Abd|Abdel|Abdul|ben|bin|bint)\b/i;
if (fullName.match(arabicParticles)) {
if (birthYear && birthYear > 1900) {
const parts = fullName.split(' ');
const binIndex = parts.findIndex(p => p.toLowerCase() === 'bin' || p.toLowerCase() === 'ben');
if (binIndex > 0) return parts.slice(binIndex).join(' ') + ', ' + parts.slice(0, binIndex).join(' ');
} else {
return fullName;
}
}
const eastAsianQids = ["Q148", "Q184", "Q884", "Q424", "Q881", "Q17"];
if (allRelevantIds.some(id => eastAsianQids.includes(id))) {
if (allRelevantIds.includes("Q17") && birthYear && birthYear > 1885) return westernSort(fullName);
const parts = fullName.split(' ');
if (parts.length > 1) return `${parts[0]}, ${parts.slice(1).join(' ')}`;
}
return westernSort(fullName);
}
function westernSort(name) {
let cleanName = name;
if (name.startsWith("O'")) cleanName = name.replace("O'", "O");
const parts = cleanName.split(' ');
if (parts.length < 2) return cleanName;
const surname = parts.pop();
if (["Jr.", "Jr", "III", "II", "Sr.", "Sr"].includes(surname)) {
const actualSurname = parts.pop();
return `${actualSurname}, ${parts.join(' ')} ${surname}`;
}
return `${surname}, ${parts.join(' ')}`;
}
async function performDeorphan() {
const api = new mw.Api();
try {
await api.edit(mw.config.get('wgPageName'), function(rev) {
let text = rev.content;
const summary = 'Article has backlinks; removed [[Template:Orphan|{{Orphan}}]]';
text = text.replace(/\{\{Orphan\s*(?:\|[^}]*)?\}\}\s*/gi, '');
text = text.replace(/(\{\{Multiple[ _]issues\s*)\|\s*\|/gi, '$1|');
text = replaceMI(text);
text = text.replace(/\n{3,}/g, '\n\n').trim();
return {
text,
summary,
minor: true
};
});
mw.notify('Orphan tag removed!', {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
setTimeout(() => location.reload(), 700);
} catch (e) {
mw.notify('Failed to remove orphan tag.', {
type: 'error'
});
}
}
function replaceMI(text) {
const miRegex = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miRegex.exec(text)) !== null) {
const start = match.index;
result += text.slice(lastIndex, start);
let depth = 0;
let i = start;
while (i < text.length) {
if (text[i] === '{' && text[i + 1] === '{') {
depth++;
i += 2;
} else if (text[i] === '}' && text[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
const end = i;
const full = text.slice(start, end);
const pipeIdx = full.indexOf('|');
const inner = full.slice(pipeIdx + 1, full.length - 2).trim();
const topLevel = extractTopLevelTemplates(inner);
if (topLevel.length === 0) {
result += '';
} else if (topLevel.length === 1) {
result += topLevel[0].trim();
} else {
result += full;
}
lastIndex = end;
}
result += text.slice(lastIndex);
return result;
}
function extractTopLevelTemplates(inner) {
const templates = [];
let i = 0;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
let depth = 0;
const start = i;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
depth++;
i += 2;
} else if (inner[i] === '}' && inner[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
templates.push(inner.slice(start, i));
} else {
i++;
}
}
return templates;
}
async function performSortCorrection(newName, type) {
const api = new mw.Api();
const title = mw.config.get("wgPageName");
try {
const data = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2
});
let content = data.query.pages[0].revisions[0].content;
const dsRegex = /\{\{(?:DEFAULTSORT|Defaultsort):[^}]+\}\}/i;
const newTag = `{{DEFAULTSORT:${newName}}}`;
let actionDesc = type === "add" ? "added" : "corrected";
if (dsRegex.test(content)) {
content = content.replace(dsRegex, newTag);
} else {
const catRegex = /\[\[Category:/i;
if (catRegex.test(content)) {
content = content.replace(catRegex, `${newTag}\n[[Category:`);
} else {
content += `\n\n${newTag}`;
}
}
await api.postWithEditToken({
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName}`,
nocreate: true
});
mw.notify(`DEFAULTSORT ${actionDesc} to ${newName}!`, {
title: 'Comrade Success',
type: 'success'
});
setTimeout(() => location.reload(), 700);
} catch (err) {
mw.notify('Failed to save DEFAULTSORT.', {
type: 'error'
});
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
function renderSortNudge(target, reason) {
const $dsNudge = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Correct to: ${target}`).on('click', () => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
async function fetchWikidataNameLabels(entity) {
const familyIds = entity.claims?.P734?.map(c => c.mainsnak.datavalue.value.id) || [];
const givenIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
const allNameIds = [...new Set([...familyIds, ...givenIds])];
if (allNameIds.length === 0) return {
givens: [],
surnames: []
};
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: allNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
});
const nameData = await fetch(url).then(r => r.json());
return {
givens: givenIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean),
surnames: familyIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean)
};
}
function fixNameCasing(part, referenceName) {
return part.split(' ').map(word => {
const match = referenceName.match(new RegExp(`\\b${word}\\b`, 'i'));
if (match) return match[0];
return referenceName.split(' ').find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase())) || word;
}).join(' ');
}
function getLongNameFromWikitext(wikitext) {
const firstLine = wikitext.split('\n').find(l => l.includes("'''"));
if (!firstLine) return null;
const boldMatch = firstLine.match(/'''(.+?)'''/);
if (!boldMatch) return null;
return boldMatch[1].replace(/\s*([“"«《„‹「『'].+?[”"»》”›」』']|\([^)\n]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
}
async function init() {
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") return;
const api = new mw.Api();
const qid = mw.config.get("wgWikibaseItemId");
try {
const [pageData, backlinkData] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
api.get({
action: 'query',
list: 'backlinks',
blfilterredir: 'nonredirects',
bllimit: 50,
blnamespace: 0,
bltitle: mw.config.get("wgPageName"),
formatversion: 2
})
]);
const page = pageData.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const currentTitle = mw.config.get("wgTitle");
const cleanTitle = stripDiacritics(currentTitle);
const isOrphanTagged = /\{\{\s*(Orphan|Do-attempt|Lonely|Orp|Orphaned article)/i.test(wikitext) || /\| *orphan *=/i.test(wikitext);
const backLinkCount = backlinkData.query.backlinks.length;
if (isOrphanTagged && backLinkCount >= 1) {
const $container = $('<div>').addClass('comrade-container comrade-status');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text('Remove orphan tag').on('click', () => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) await checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
await checkDomainRedirect(qid, currentTitle);
let isHuman = false;
let entity = null;
if (qid) {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
});
const wikiData = await fetch(url).then(r => r.json());
entity = wikiData.entities[qid];
isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
}
await checkNamingPrecision(currentTitle, isHuman);
if (qid && entity) {
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const nameParts = tClean.split(' ');
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
const isOrphan = isOrphanTagged && backLinkCount < 1;
const primarySurname = hasFamilyNameHatnote ? nameParts[0] : (getSmartSortKey(tClean, entity).split(',')[0] || nameParts[nameParts.length - 1]);
let targetSortName = "";
if (isHuman) {
const {
givens,
surnames
} = await fetchWikidataNameLabels(entity);
const refName = entity.labels?.en?.value || currentTitle;
if (dsMatch) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixNameCasing(lastPart, refName)}, ${fixNameCasing(firstPart, refName)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field.");
} else if (givens.length > 0 && lastPart.split(' ').length > 1) {
const misplacedGiven = lastPart.split(' ').find(part => givens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastPart.split(' ').filter(p => p !== misplacedGiven).join(' ');
targetSortName = `${fixNameCasing(newSurname, refName)}, ${fixNameCasing(misplacedGiven + ' ' + firstPart, refName)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
}
}
} else {
targetSortName = hasFamilyNameHatnote ? `${nameParts[0]}, ${nameParts.slice(1).join(' ')}` : getSmartSortKey(tClean, entity);
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).on('click', () => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
const longName = getLongNameFromWikitext(wikitext);
if (longName && longName.length > tClean.length) {
let sortKey = hasFamilyNameHatnote ? `${longName.split(' ')[0]}, ${longName.split(' ').slice(1).join(' ')}` : getSmartSortKey(longName, entity);
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
await checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
if (targetSortName) {
let targetPage = currentTitle.includes(' (') ? currentTitle.split(' (')[0] : currentTitle;
let rCat = targetPage !== currentTitle ? "R from ambiguous sort name" : "R from sort name";
if (targetSortName.includes(',')) {
const p = targetSortName.split(',');
rCat += `|${p[0].trim().charAt(0).toUpperCase()}|${p[1].trim().charAt(0).toUpperCase()}`;
}
await checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name");
}
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
surnames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
givens.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) finalGivenNames.add(gn);
});
if (finalFamilyNames.size === 0 && nameParts.length >= 2) finalFamilyNames.add(nameParts[nameParts.length - 1]);
if (finalGivenNames.size === 0 && nameParts.length >= 2) finalGivenNames.add(nameParts[0]);
const getPriorityTarget = async (name, type) => {
const titles = type === 'surname' ? [`List of people with surname ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`] : [`List of people with given name ${name}`, `${name} (given name)`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
return titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
};
for (const name of finalGivenNames) {
if (name.toLowerCase() === primarySurname.toLowerCase()) continue;
const target = await getPriorityTarget(name, 'given');
if (target) await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
for (const name of finalFamilyNames) {
const titles = [name, `${name} (surname)`, `List of people with surname ${name}`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
const target = titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
if (target) await checkNameList(name, 'surname', currentTitle, hasShortDesc, isOrphan, target);
}
}
}
} catch (err) {
console.error("Comrade error:", err);
}
}
init();
})();
(function() {
if (window.IllWill) return;
const STRIKEOUT = true;
const HIDE = 'hide';
const IllWill = {
Version: "1.1.1",
Attribution: 'created by <a target="_blank" href="/wiki/User:Cobaltcigs">cobaltcigs</a>',
Summary: "Added interlanguage links using IllWill.js",
DataApi: "https://www.wikidata.org/w/api.php",
LocalLang: mw.config.get("wgPageContentLanguage"),
Disambiguator: / \(.*\)$/,
SiteLinks: {},
Config: {
MaxLanguages: 5,
IgnoreScientific: STRIKEOUT,
AutoDiff: true
},
css: `
#illwill { clear: both; margin-bottom: 1.5em; border: 1px solid #a2a9b1; padding: 15px; background: #f8f9fa; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.ill-sitelinks a { display: inline-block; margin-bottom: 3px; font-size: 0.9em; color: #36c; }
.ill-size-tag { font-size: 0.8em; color: #72777d; margin-left: 6px; white-space: nowrap; }
.illwill-ignore { text-decoration: line-through; color: #72777d; font-size: 0.85em; }
.ill-x { width: 35px; text-align: center; }
.ill-input, .ill-output { font-family: 'Courier New', monospace; white-space: pre-wrap; font-size: 0.9em; background: #fff; border: 1px solid #eaecf0; padding: 4px; border-radius: 2px; }
#ill-buttons { text-align: right; padding: 10px; border-top: 1px solid #a2a9b1; margin-top: 10px; }
#ill-buttons button { margin-left: 10px; cursor: pointer; padding: 6px 16px; border-radius: 2px; border: 1px solid #a2a9b1; transition: all 0.2s; }
#ill-ok { background: #36c; color: #fff; border-color: #36c !important; font-weight: bold; }
#ill-ok:hover { background: #447ff5; }
#ill-ok:disabled { background: #eaecf0; color: #72777d; border-color: #c8ccd1 !important; cursor: not-allowed; opacity: 0.7; }
#ill-table { width: 100%; border-collapse: collapse; margin-top: 10px; background: white; }
#ill-table th { background: #f2f2f2; text-align: left; padding: 8px; border: 1px solid #a2a9b1; }
#ill-table td { padding: 8px; border: 1px solid #a2a9b1; vertical-align: top; }
#ill-table caption { background: #eaffea; font-weight: bold; padding: 10px; border: 1px solid #a2a9b1; border-bottom: none; font-size: 1.1em; }
#ill-help { font-size: 0.85em; color: #54595d; padding: 10px; line-height: 1.4; }
.ill-loading-inline { font-style: italic; color: #72777d; font-size: 0.9em; display: block; margin: 5px 0; }
`,
setup() {
if (!["edit", "submit"].includes(mw.config.get('wgAction'))) return;
mw.loader.addStyleTag(this.css);
const link = mw.util.addPortletLink('p-cactions', '#', 'IllWill', 'ca-illwill', "Add interlanguage links via Wikidata");
if (link) {
link.addEventListener('click', (e) => {
e.preventDefault();
this.makePanel();
});
}
},
norm(s) {
if (!s) return "";
const t = s.trim().replace(/[\s_]+/g, " ");
return t.charAt(0).toUpperCase() + t.slice(1);
},
async getRedLinks($textbox) {
const txt = $textbox.textSelection('getContents') || "";
const wl = txt.match(/\[\[[^[\]\n]+\]\]/g);
if (!wl) return {
reds: [],
txt
};
const api = new mw.Api();
const textToParse = wl.join("").replace(/\|[^\]]+/g, "");
try {
const data = await api.post({
action: 'parse',
text: textToParse,
contentmodel: 'wikitext',
formatversion: 2
});
const tempDiv = document.createElement('div');
tempDiv.innerHTML = data.parse.text;
const reds = Array.from(tempDiv.querySelectorAll("a.new")).map(x => {
const urlParams = new URLSearchParams(x.href.split('?')[1]);
return urlParams.get('title')?.replace(/_/g, " ");
}).filter(Boolean);
return {
reds: [...new Set(reds)],
txt
};
} catch (e) {
return {
reds: [],
txt
};
}
},
async makePanel() {
const $editform = $('#editform');
const $textbox = $('#wpTextbox1');
$('#illwill').remove();
$editform.hide();
const $wrapper = $('<div id="illwill">Loading redlinks...</div>').insertBefore($editform);
const {
reds,
txt
} = await this.getRedLinks($textbox);
const wtLinks = [];
reds.forEach(title => {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s/g, "[\\s_]+");
const re = new RegExp("\\[\\[\\s*" + escaped + "\\s*(?:\\|[^\\[\\]]+)?\\]\\]", "ig");
const match = txt.match(re);
if (match && !wtLinks.some(z => z.title === title)) {
wtLinks.push({
link: match[0],
title
});
}
});
if (!wtLinks.length) {
const $back = $('<button>Quit</button>').on('click', () => {
$wrapper.remove();
$editform.show();
});
$wrapper.empty().append($('<strong>No valid redlinks found in text.</strong> '), $back);
return;
}
const $table = $(`
<table id="ill-table" class="wikitable">
<caption>IllWill.js (v${this.Version})</caption>
<thead>
<tr>
<th class="ill-x"><input type="checkbox" id="ill-select-all" disabled></th>
<th>Input link</th>
<th>Possible Wikidata matches</th>
<th>Sitelinks (top ${this.Config.MaxLanguages} by size)</th>
<th>Resulting wikitext</th>
</tr>
</thead>
<tbody></tbody>
<tfoot>
<tr>
<td colspan="3" id="ill-help">Carefully examine possible Wikidata matches.</td>
<td colspan="2" id="ill-buttons">
<button id="ill-cancel">Cancel</button>
<button id="ill-ok" disabled>Show changes</button>
</td>
</tr>
</tfoot>
</table>
`);
const $tbody = $table.find('tbody');
wtLinks.forEach((item, i) => {
$tbody.append(`
<tr id="ill-row-${i}" data-index="${i}">
<td class="ill-x"><input type="checkbox" class="ill-row-check" disabled></td>
<td class="ill-input">${this.escapeHTML(item.link)}</td>
<td class="ill-wd-item"><span class="ill-loading-inline">Searching...</span></td>
<td class="ill-sitelinks"></td>
<td class="ill-output"></td>
</tr>
`);
});
$wrapper.empty().append($table);
const chunkSize = 5;
for (let i = 0; i < wtLinks.length; i += chunkSize) {
const chunk = wtLinks.slice(i, i + chunkSize);
await Promise.all(chunk.map((item, idx) => this.fetchWikidataOptimized(item.title, i + idx)));
}
$table.on('click', '#ill-cancel', () => {
$editform.show();
$wrapper.remove();
});
$table.on('click', '#ill-ok', () => this.onOkButton($editform, $wrapper, $textbox));
$table.on('change', '#ill-select-all', (e) => {
const isChecked = $(e.target).is(':checked');
$table.find('.ill-row-check:not(:disabled)').each(function() {
$(this).prop('checked', isChecked).trigger('change');
});
});
$table.on('change', '.ill-row-check', (e) => {
this.onCheckbox(e);
this.updateOkButtonState($table);
});
$table.on('change', 'input[type="radio"]', (e) => {
this.showSiteLinks(e);
});
},
async fetchWikidataOptimized(title, index) {
const query = title.replace(this.Disambiguator, "");
const url = `${this.DataApi}?action=wbsearchentities&search=${encodeURIComponent(query)}&language=${this.LocalLang}&limit=5&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
const results = data.search;
const $cell = $(`#ill-row-${index} .ill-wd-item`);
if (!results || !results.length) {
$cell.html('<span class="ill-na">(no results)</span>');
return;
}
const entityIds = results.map(r => r.id);
await this.fetchEntities(entityIds, index, $cell);
} catch (e) {
$(`#ill-row-${index} .ill-wd-item`).text("Search failed.");
}
},
async fetchEntities(ids, rowIndex, $cell) {
const url = `${this.DataApi}?action=wbgetentities&ids=${ids.join('|')}&props=labels|descriptions|claims|sitelinks&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
$cell.empty();
ids.forEach(qNum => {
const entity = data.entities[qNum];
if (!entity) return;
const isScientific = entity.claims?.P31?.some(s => s.mainsnak?.datavalue?.value?.id === "Q13442814");
if (isScientific && this.Config.IgnoreScientific === HIDE) return;
const desc = entity.descriptions?.[this.LocalLang]?.value || entity.descriptions?.en?.value || "";
const label = entity.labels?.[this.LocalLang]?.value || entity.labels?.en?.value || qNum;
const isStrikeout = isScientific && this.Config.IgnoreScientific === STRIKEOUT;
const $div = $(`
<div class="${isStrikeout ? 'illwill-ignore' : ''}">
<input type="radio" name="selection-${rowIndex}" value="${qNum}" id="radio-${qNum}" ${isStrikeout ? 'disabled' : ''}>
<label for="radio-${qNum}">
<a href="https://wikidata.org/wiki/${qNum}" target="_blank">${this.escapeHTML(label)}</a>:
<span class="ill-desc">${desc ? this.escapeHTML(desc) : '<i>no description</i>'}</span>
</label>
</div>
`);
$cell.append($div);
this.SiteLinks[qNum] = entity.sitelinks || {};
});
} catch (e) {
$cell.text("Detail fetch failed.");
}
},
updateOkButtonState($table) {
const $rows = $table.find('.ill-row-check:not(:disabled)');
const $checkedRows = $rows.filter(':checked');
const anyEnabled = $rows.length > 0;
$table.find('#ill-ok').prop('disabled', $checkedRows.length === 0);
const $master = $table.find('#ill-select-all');
$master.prop('disabled', !anyEnabled);
if (anyEnabled && $rows.length === $checkedRows.length) {
$master.prop('checked', true);
} else {
$master.prop('checked', false);
}
},
async showSiteLinks(e) {
const isManualToggling = e.fromCheckbox || false;
const qNum = e.target.value;
const $row = $(e.target).closest('tr');
const sitelinks = this.SiteLinks[qNum];
const $checkbox = $row.find('.ill-row-check');
const $sitelinkCell = $row.find('.ill-sitelinks');
const $outputCell = $row.find('.ill-output');
const $table = $('#ill-table');
const effectiveMax = Math.min(Math.max(this.Config.MaxLanguages, 3), 6);
const validKeys = Object.keys(sitelinks).filter(k => k.endsWith('wiki') && !k.includes('commons'));
$sitelinkCell.html('<span class="ill-loading-inline">Measuring articles...</span>');
$outputCell.empty();
if (!isManualToggling) {
$checkbox.prop('disabled', true).prop('checked', false);
}
const candidates = validKeys.slice(0, 25);
const sizeMap = [];
await Promise.all(candidates.map(async (key) => {
const lang = key.replace('wiki', '').replace(/_/g, '-');
const title = sitelinks[key].title;
const endpoint = `https://${lang}.wikipedia.org/w/api.php?action=query&titles=${encodeURIComponent(title)}&prop=info&format=json&origin=*`;
try {
const res = await fetch(endpoint).then(r => r.json());
const page = Object.values(res.query.pages)[0];
sizeMap.push({
lang,
title,
length: page.length || 0
});
} catch (err) {
sizeMap.push({
lang,
title,
length: 0
});
}
}));
sizeMap.sort((a, b) => b.length - a.length);
const topLinks = sizeMap.slice(0, effectiveMax);
$sitelinkCell.html(topLinks.map(item => {
const kbValue = Math.round(item.length / 1024);
return `<div><a href="https://${item.lang}.wikipedia.org/wiki/${encodeURIComponent(item.title)}" target="_blank">${item.lang}:${this.escapeHTML(item.title)}</a> <span class="ill-size-tag">(${kbValue} kB)</span></div>`;
}).join(""));
const inputWikitext = $row.find('.ill-input').text();
const match = inputWikitext.match(/\[\[(.*?)\]\]/);
if (match) {
const parts = match[1].split('|');
const targetPage = parts[0].trim();
const surfaceName = (parts[1] || parts[0]).trim();
const illLangs = topLinks.map(item => {
const isSameTitle = this.norm(item.title) === this.norm(targetPage);
return `|${item.lang}|${isSameTitle ? '' : item.title}`;
}).join("");
const ltParam = (this.norm(surfaceName) === this.norm(targetPage)) ? "" : `|lt=${surfaceName}`;
$outputCell.text(`{{ill|${targetPage}${ltParam}${illLangs}}}`);
}
$checkbox.prop('disabled', !topLinks.length);
if (!isManualToggling) {
$checkbox.prop('checked', !!topLinks.length);
}
this.updateOkButtonState($table);
},
onCheckbox(e) {
const $row = $(e.target).closest('tr');
const $radio = $row.find('input[type="radio"]:checked');
const isChecked = $(e.target).is(':checked');
if (isChecked) {
if ($radio.length) {
this.showSiteLinks({
target: $radio[0],
fromCheckbox: true
});
}
} else {
$row.find('.ill-sitelinks, .ill-output').empty();
}
},
onOkButton($editform, $wrapper, $textbox) {
let text = $textbox.textSelection('getContents');
let changed = false;
$wrapper.find('.ill-row-check:checked').each(function() {
const $row = $(this).closest('tr');
const input = $row.find('.ill-input').text();
const output = $row.find('.ill-output').text();
if (output) {
const escapedInput = input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
text = text.replace(new RegExp(escapedInput, "gi"), output);
changed = true;
}
});
if (changed) {
$('#wpSummary').val((i, v) => (v ? v + "; " : "") + this.Summary);
$textbox.textSelection('setContents', text);
$wrapper.remove();
$editform.show();
if (this.Config.AutoDiff) $('#wpDiff').click();
}
},
escapeHTML(str) {
return str.replace(/[&<>"']/g, m => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
})[m]);
}
};
window.IllWill = IllWill;
mw.loader.using(['mediawiki.util', 'mediawiki.api', 'jquery.textSelection'], () => {
$(() => IllWill.setup());
});
})();
(function() {
'use strict';
const config = {
enableNS14: true,
rootWhitelists: ['Materials'],
userWhitelist: ""
};
const categoryDataCache = {};
let scanProgress = 0;
let totalToScan = 0;
async function verifyTreeMembership() {
if (!config.enableNS14) return false;
const api = new mw.Api();
const currentTitle = mw.config.get("wgPageName").replace(/_/g, ' ');
const fullRoots = config.rootWhitelists.concat(config.userWhitelist.split(',').map(s => s.trim()).filter(s => s)).map(cat => (cat.startsWith('Category:') ? cat : `Category:${cat}`));
if (fullRoots.includes(currentTitle)) return true;
let categoriesToTable = mw.config.get('wgCategories') || [];
let checked = new Set();
while (categoriesToTable.length > 0) {
if (categoriesToTable.some(cat => fullRoots.includes('Category:' + cat))) {
return true;
}
categoriesToTable.forEach(cat => checked.add(cat));
try {
const res = await api.get({
formatversion: 2,
prop: 'categories',
titles: categoriesToTable.map(c => 'Category:' + c),
cllimit: 'max'
});
let nextBatch = [];
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.categories) {
page.categories.forEach(parent => {
const parentName = parent.title.substring('Category:'.length);
if (!checked.has(parentName)) {
nextBatch.push(parentName);
}
});
}
});
}
categoriesToTable = [...new Set(nextBatch)];
} catch (e) {
return false;
}
}
return false;
}
function getChemboxBlock(wikitext) {
const chemboxRegex = /\{\{\s*Chembox[\s_]*(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const match = wikitext.match(chemboxRegex);
return match ? match[0] : null;
}
function getFormulaFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const formulaMatch = chembox.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
let rawFormula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
rawFormula = rawFormula.split(/\s+/)[0];
return rawFormula.split(',')[0].trim();
}
const propRegex = /\{\{\s*Chembox[\s_]+Properties(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const propSection = chembox.match(propRegex);
if (propSection) {
const content = propSection[0];
const allElements = ['Ac', 'Ag', 'Al', 'Am', 'Ar', 'As', 'At', 'Au', 'B', 'Ba', 'Be', 'Bh', 'Bi', 'Bk', 'Br', 'C', 'Ca', 'Cd', 'Ce', 'Cf', 'Cl', 'Cm', 'Cn', 'Co', 'Cr', 'Cs', 'Cu', 'D', 'Db', 'Ds', 'Dy', 'Er', 'Es', 'Eu', 'F', 'Fe', 'Fl', 'Fm', 'Fr', 'Ga', 'Gd', 'Ge', 'H', 'He', 'Hf', 'Hg', 'Ho', 'Hs', 'I', 'In', 'Ir', 'K', 'Kr', 'La', 'Li', 'Lr', 'Lu', 'Lv', 'Mc', 'Md', 'Mg', 'Mn', 'Mo', 'Mt', 'N', 'Na', 'Nb', 'Nd', 'Ne', 'Nh', 'Ni', 'No', 'Np', 'O', 'Og', 'Os', 'P', 'Pa', 'Pb', 'Pd', 'Pm', 'Po', 'Pr', 'Pt', 'Pu', 'Ra', 'Rb', 'Re', 'Rf', 'Rg', 'Rh', 'Rn', 'Ru', 'S', 'Sb', 'Sc', 'Se', 'Sg', 'Si', 'Sm', 'Sn', 'Sr', 'Ta', 'Tb', 'Tc', 'Te', 'Th', 'Ti', 'Tl', 'Tm', 'Ts', 'U', 'V', 'W', 'Xe', 'Y', 'Yb', 'Zn', 'Zr'];
let data = {};
allElements.forEach(el => {
const elRegex = new RegExp(`\\|\\s*${el}\\s*=\\s*(\\d*\\.?\\d+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch && elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
});
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
elementsPresent.filter(el => !(hasC && (el === "C" || el === "H"))).sort().forEach(el => {
formulaArray.push(el + data[el]);
});
return formulaArray.join('');
}
}
return null;
}
function getCASFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const idRegex = /\{\{\s*Chembox[\s_]+Identifiers(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const idSection = chembox.match(idRegex);
if (!idSection) return null;
const content = idSection[0];
const nbsp = String.fromCharCode(160);
const casMatch = content.match(/\|\s*CASNo\d*\b(?!\s*_)\s*=\s*([^|}\n]+)/i);
if (casMatch) {
let val = casMatch[1].replace(/\{\{[^}]+\}\}/g, "").replace(/\u00AD/g, "").split(" ").join("");
while (val.indexOf(nbsp) !== -1) val = val.replace(nbsp, "");
while (val.indexOf(" ") !== -1) val = val.replace(" ", "");
const cleanedCas = val.trim();
return /^\d{2,7}-\d{2}-\d$/.test(cleanedCas) ? cleanedCas : null;
}
return null;
}
async function getWikidataValue() {
const qid = mw.config.get('wgWikidataItemId');
if (!qid) return null;
const api = new mw.ForeignApi('https://www.wikidata.org/w/api.php');
const res = await api.get({
action: 'wbgetentities',
ids: qid,
props: 'claims',
format: 'json',
formatversion: 2
});
const claims = res.entities[qid].claims;
const data = {};
if (claims.P274) data.formula = claims.P274[0].mainsnak.datavalue.value;
if (claims.P231) data.cas = claims.P231[0].mainsnak.datavalue.value;
return data;
}
async function startBackgroundCategoryScan() {
const api = new mw.Api();
const titles = $('#mw-pages li a').map((i, el) => $(el).attr('title') || $(el).text()).get();
totalToScan = titles.length;
if (totalToScan === 0) return;
const chunks = arrayChunk(titles, 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: chunk,
rvprop: 'content',
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const f = getFormulaFromWikitext(wikitext);
const c = getCASFromWikitext(wikitext);
if (f || c) categoryDataCache[page.title] = {
formula: f,
cas: c
};
});
}
scanProgress += chunk.length;
$('#caschemassist-cat-btn').text(`Scanning... ${scanProgress}/${totalToScan}`);
}
$('#caschemassist-cat-btn').text('Show missing CAS/Chem').prop('disabled', false);
}
async function renderCategoryAudit() {
const api = new mw.Api();
const missingCheckTitles = [];
Object.values(categoryDataCache).forEach(data => {
if (data.formula) missingCheckTitles.push(data.formula);
if (data.cas) missingCheckTitles.push(data.cas);
});
if (missingCheckTitles.length === 0) {
mw.notify("No valid Chembox data found in this category.");
return;
}
const existenceMap = {};
const chunks = arrayChunk([...new Set(missingCheckTitles)], 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
titles: chunk,
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(p => {
existenceMap[p.title] = !p.missing;
});
}
}
$('#mw-pages li').each(function() {
const title = $(this).find('a').attr('title') || $(this).find('a').text().trim();
const data = categoryDataCache[title];
if (!data) return;
if (data.formula && !existenceMap[data.formula]) {
$(this).append($('<span>').css({
'color': 'red',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [${data.formula}]`));
}
if (data.cas && !existenceMap[data.cas]) {
$(this).append($('<span>').css({
'color': 'darkred',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [CAS: ${data.cas}]`));
}
});
$('#caschemassist-cat-btn').text('Audit complete').prop('disabled', true);
}
async function handleSingleArticle() {
const api = new mw.Api();
const currentTitle = mw.config.get("wgTitle");
const [wikiRes, wikidata] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
getWikidataValue()
]);
const page = wikiRes.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const formula = getFormulaFromWikitext(wikitext);
const cas = getCASFromWikitext(wikitext);
if (wikidata) {
let mismatches = [];
if (formula && wikidata.formula && formula !== wikidata.formula) {
mismatches.push(`Formula mismatch: Chembox "${formula}" vs Wikidata "${wikidata.formula}"`);
}
if (cas && wikidata.cas && cas !== wikidata.cas) {
mismatches.push(`CAS mismatch: Chembox "${cas}" vs Wikidata "${wikidata.cas}"`);
}
if (mismatches.length > 0) {
const $warning = $('<div>').css({
'background': '#fff2f2',
'border': '2px solid #d33',
'padding': '10px',
'margin-bottom': '1em',
'color': '#b32424'
}).append($('<strong>').text('⚠️ Data Mismatch Detected: '), mismatches.join(' | '));
$('#mw-content-text').prepend($warning);
}
}
async function checkAndRender(target, label, customWikitext) {
const check = await api.get({
action: 'query',
titles: target,
formatversion: 2
});
if (check.query.pages[0].missing) {
const $container = $('<div>').css({
'background': '#f8f9fa',
'border': '1px solid #a2a9b1',
'padding': '10px',
'margin-bottom': '1em'
});
const $btn = $('<button>').text(`Create redirect: ${target}`).click(async function() {
await api.postWithEditToken({
action: 'edit',
title: target,
text: customWikitext,
summary: `Creating redirect to [[${currentTitle}]] (${label})`,
createonly: true
});
mw.notify(`Created redirect: ${target}`);
$container.fadeOut(400, function() {
$(this).remove();
});
});
$container.append($('<strong>').text(`${label}: `), `Missing redirect. `, $btn);
$('#mw-content-text').prepend($container);
}
}
if (formula && formula !== currentTitle) {
const formulaWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from chemical formula}}\n}}`;
await checkAndRender(formula, "Chemical formula", formulaWikitext);
}
if (cas && cas !== currentTitle) {
const casWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from CAS Registry Number}}\n{{R unprintworthy}}\n}}`;
await checkAndRender(cas, "CAS number", casWikitext);
}
}
function arrayChunk(arr, size) {
const result = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
$(document).ready(async function() {
const ns = mw.config.get("wgNamespaceNumber");
if (ns === 14) {
if (await verifyTreeMembership()) {
const $catContainer = $('#mw-pages');
if ($catContainer.length) {
const $btn = $('<button>').text('Initializing scan...').attr('id', 'caschemassist-cat-btn').addClass('mw-ui-button').css({
'margin': '0 10px 10px 0',
'float': 'right',
'position': 'relative',
'z-index': '100'
}).prop('disabled', true).click(renderCategoryAudit);
$catContainer.prepend($btn);
startBackgroundCategoryScan();
}
}
} else if (ns === 0 && mw.config.get("wgAction") === "view") {
handleSingleArticle();
}
});
})();
//</nowiki>
ebhx4p5u9s7ydpy1yb1gxbd1m7x93w5
739295
739293
2026-04-25T06:29:50Z
Sam Sailor
26820
Test
739295
javascript
text/javascript
//<nowiki>
(function() {
mw.util.addCSS(`
.ambox-Orphan { display: block !important; }
.comrade-container {
box-shadow: 0 1px 3px rgba(0,0,0,0.1); border-radius: 2px;
padding: 10px 15px; margin: 10px 0; display: flex;
flex-direction: column; align-items: flex-start; gap: 8px;
border: 1px solid #a2a9b1; background: #fcfcfc; line-height: 1.5;
}
.comrade-header { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.comrade-content { display: flex; flex-direction: column; gap: 5px; width: 100%; }
.comrade-success { background-color: #d5f5e3 !important; border-left: 5px solid #27ae60 !important; color: #1b5e20 !important; }
.comrade-nudge { background: #fff9ea; border-left: 5px solid #f0ad4e; }
.comrade-info { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-status { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-container button:not(.comrade-dismiss) {
background: #f8f9fa; border: 1px solid #a2a9b1; border-radius: 2px;
padding: 2px 10px; cursor: pointer; font-weight: bold; font-size: 0.95em; margin-left: 10px;
}
.comrade-container button:not(.comrade-dismiss):hover { border-color: #36c; color: #36c; background: #fff; }
.comrade-dismiss {
background: transparent; border: none; color: #d33; font-weight: bold;
cursor: pointer; font-size: 0.9em;
}
.comrade-dismiss:hover { text-decoration: underline; }
.comrade-code-block { background: #f1f1f1; padding: 4px 8px; border-radius: 3px; font-family: monospace; display: inline-block; margin-top: 4px; }
`);
function appendDismiss($el) {
$('<button>').addClass('comrade-dismiss').text('Dismiss').on('click', function() {
$(this).closest('.comrade-container').fadeOut();
}).appendTo($el.find('.comrade-header'));
$("#siteSub").after($el);
}
async function checkNamingPrecision(currentTitle, isHuman) {
const disambigMatch = currentTitle.match(/^(.+?)(?:, | \()/);
if (isHuman || !disambigMatch) return;
const baseTitle = disambigMatch[1].trim();
const api = new mw.Api();
try {
const baseRes = await api.get({
action: 'query',
titles: baseTitle,
redirects: 1,
formatversion: 2
});
const page = baseRes.query.pages[0];
const baseExists = !page.missing;
let baseRedirectsToSelf = false;
if (baseRes.query.redirects) {
baseRedirectsToSelf = baseRes.query.redirects.some(r => r.to === currentTitle);
}
const searchRes = await api.get({
action: 'query',
list: 'allpages',
apprefix: baseTitle,
aplimit: 50,
formatversion: 2
});
const competitors = searchRes.query.allpages.filter(p => {
const t = p.title;
return t !== currentTitle && t !== baseTitle && (t.startsWith(baseTitle + ', ') || t.startsWith(baseTitle + ' ('));
});
if (!baseExists || baseRedirectsToSelf) {
const $container = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (competitors.length === 0) {
$header.append($('<strong>').text('Precision:'));
const reason = baseRedirectsToSelf ? "already redirects here" : "is free";
$content.append($('<span>').append(`The base name `, $('<code>').text(baseTitle), ` ${reason}. Consider moving this article.`));
const moveUrl = mw.util.getUrl('Special:MovePage/' + currentTitle, {
wpNewTitle: baseTitle,
wpReason: 'Unnecessary disambiguation; base name is free/redirects here per [[WP:PRECISION]]'
});
$content.append($('<button>').text(`Move to ${baseTitle}`).on('click', () => window.open(moveUrl, '_blank')));
} else {
$header.append($('<strong>').text('Observation:'));
const state = baseRedirectsToSelf ? 'redirect to this page' : 'redlink';
$content.append($('<span>').append(`Base name `, $('<code>').text(baseTitle), ` has ${competitors.length} competitors, but it is currently a ${state}.`));
$content.append($('<span>').text('Consider if a disambiguation page is needed.'));
}
$container.append($header, $content);
appendDismiss($container);
}
} catch (e) {
console.warn("Comrade: Precision check failed", e);
}
}
async function checkAndRenderRedirect(redirTitle, targetTitle, rCategory, labelText, customWikitext) {
const api = new mw.Api();
const res = await api.get({
action: 'query',
titles: redirTitle,
formatversion: 2
});
if (res.query.pages[0].missing) {
const safeId = "comrade-redir-" + redirTitle.replace(/[^a-z0-9]/gi, '-');
const $container = $('<div>').addClass('comrade-container comrade-info').attr('id', safeId);
const $header = $('<div>').addClass('comrade-header');
const summary = `Created redirect from ${labelText.toLowerCase()} to [[${targetTitle}]]`;
const $btn = $('<button>').text('Create redirect').on('click', () => createModernRedirect(redirTitle, targetTitle, rCategory, summary, safeId, customWikitext));
$header.append($('<div>').append($('<strong>').text(`${labelText}: `), `Create redirect from `, $('<code>').text(redirTitle), $btn));
$container.append($header);
appendDismiss($container);
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
if (qid) {
try {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
});
const res = await fetch(url).then(r => r.json());
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
}
} catch (e) {
console.warn("Comrade: Wikidata API call failed.");
}
}
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
if (domain.includes(".") && url.pathname === "/") {
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
try {
const response = await fetch(citationURL);
if (response.ok) {
await checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
}
} catch (e) {
console.warn("Comrade: Citation API check failed.");
}
}
} catch (e) {
console.warn("Comrade: Error parsing URL object.");
}
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan, resolvedTitle) {
const api = new mw.Api();
const candidates = resolvedTitle ? [resolvedTitle] : [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
redirects: 0
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const foundTitle = page.title;
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) break;
const $container = $('<div>').addClass('comrade-container');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (isOrphan) {
$container.addClass('comrade-nudge');
$header.append($('<strong>').text('Comrade nudge:'));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
}
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(foundTitle),
target: '_blank'
}).text(foundTitle), "; should it be?"));
const snippet = '* {{anbl|' + currentTitle + '}}';
const $snippetWrapper = $('<div>').css({
'display': 'flex',
'align-items': 'center',
'gap': '10px',
'margin-top': '4px'
});
const $instructionSpan = $('<span>').append('If the Short description is good, consider adding ', $('<code>').text(snippet).css({
'background-color': '#f8f9fa',
'border': '1px solid #eaecf0',
'border-radius': '2px',
'padding': '1px 4px'
}));
const $copyBtn = $('<button>').text('Copy & insert').css('margin-left', '0').on('click', function() {
navigator.clipboard.writeText(snippet).then(() => {
const $this = $(this);
const originalText = $this.text();
$this.text('Copied!').prop('disabled', true);
const editUrl = mw.util.getUrl(foundTitle, {
action: 'edit',
summary: snippet
});
window.open(editUrl, '_blank');
setTimeout(() => {
$this.text(originalText).prop('disabled', false);
}, 1500);
});
});
$snippetWrapper.append($instructionSpan, $copyBtn);
$content.append($snippetWrapper);
if (!hasShortDesc) {
$content.append($('<span>').css({
'color': '#d33',
'font-weight': 'bold',
'margin-top': '5px'
}).text('⚠️ Missing short description: Please add a concise one before listing.'));
}
$container.append($header, $content);
appendDismiss($container);
break;
}
}
}
}
async function checkQualityConsistency() {
const oresClass = getOresPrediction();
if (!oresClass || oresClass === 'Stub') return;
const talkTitle = 'Talk:' + mw.config.get('wgPageName');
const api = new mw.Api();
try {
const talkRes = await api.get({
action: 'query',
prop: 'revisions',
titles: talkTitle,
rvprop: 'content',
formatversion: 2
});
const page = talkRes.query.pages[0];
if (page.missing) return;
const talkWikitext = page.revisions[0].content;
const isStubRated = /\|class\s*=\s*Stub/i.test(talkWikitext);
if (isStubRated) {
const $container = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Promote to ${oresClass}`).on('click', () => performPromotion(oresClass));
$header.append($('<div>').append($('<strong>').text('Quality Mismatch:'), ` ORES predicts `, $('<b>').text(oresClass), ` but talk page says Stub. `, $btn));
$container.append($header);
appendDismiss($container);
}
} catch (e) {
console.error("Comrade quality check failed", e);
}
}
async function createModernRedirect(redirTitle, targetTitle, rCategory, customSummary, elementId, customWikitext) {
const api = new mw.Api();
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
try {
await api.postWithEditToken({
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
});
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
} catch (e) {
mw.notify("Failed to create redirect.", {
type: 'error'
});
}
}
function getOresPrediction() {
// Dependency: [[m:User:EpochFail/ArticleQuality-system.js]] renders the prediction inside .article_quality
const text = $('.article_quality').text();
if (!text) return null;
if (text.includes('Start')) return 'Start';
if (text.includes('C')) return 'C';
if (text.includes('B')) return 'B';
return null;
}
function getSmartSortKey(fullName, entity) {
const countryIds = entity.claims?.P27?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const locationIds = entity.claims?.P17?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const placeIds = [...(entity.claims?.P19 || []), ...(entity.claims?.P119 || [])].map(c => c.mainsnak?.datavalue?.value?.id).filter(Boolean);
const allRelevantIds = [...countryIds, ...locationIds, ...placeIds];
const birthYear = parseInt(entity.claims?.P569?.[0]?.mainsnak?.datavalue?.value?.time?.match(/[+-](\d{4})/)?.[1]) || null;
if (fullName.startsWith("Saint ")) return fullName.replace("Saint ", "");
const arabicParticles = /\b(Abu|Abd|Abdel|Abdul|ben|bin|bint)\b/i;
if (fullName.match(arabicParticles)) {
if (birthYear && birthYear > 1900) {
const parts = fullName.split(' ');
const binIndex = parts.findIndex(p => p.toLowerCase() === 'bin' || p.toLowerCase() === 'ben');
if (binIndex > 0) return parts.slice(binIndex).join(' ') + ', ' + parts.slice(0, binIndex).join(' ');
} else {
return fullName;
}
}
const eastAsianQids = ["Q148", "Q184", "Q884", "Q424", "Q881", "Q17"];
if (allRelevantIds.some(id => eastAsianQids.includes(id))) {
if (allRelevantIds.includes("Q17") && birthYear && birthYear > 1885) return westernSort(fullName);
const parts = fullName.split(' ');
if (parts.length > 1) return `${parts[0]}, ${parts.slice(1).join(' ')}`;
}
return westernSort(fullName);
}
function westernSort(name) {
let cleanName = name;
if (name.startsWith("O'")) cleanName = name.replace("O'", "O");
const parts = cleanName.split(' ');
if (parts.length < 2) return cleanName;
const surname = parts.pop();
if (["Jr.", "Jr", "III", "II", "Sr.", "Sr"].includes(surname)) {
const actualSurname = parts.pop();
return `${actualSurname}, ${parts.join(' ')} ${surname}`;
}
return `${surname}, ${parts.join(' ')}`;
}
async function performDeorphan() {
const api = new mw.Api();
try {
await api.edit(mw.config.get('wgPageName'), function(rev) {
let text = rev.content;
const summary = 'Article has backlinks; removed [[Template:Orphan|{{Orphan}}]]';
text = text.replace(/\{\{Orphan\s*(?:\|[^}]*)?\}\}\s*/gi, '');
text = text.replace(/(\{\{Multiple[ _]issues\s*)\|\s*\|/gi, '$1|');
text = replaceMI(text);
text = text.replace(/\n{3,}/g, '\n\n').trim();
return {
text,
summary,
minor: true
};
});
mw.notify('Orphan tag removed!', {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
setTimeout(() => location.reload(), 700);
} catch (e) {
mw.notify('Failed to remove orphan tag.', {
type: 'error'
});
}
}
async function performPromotion(newClass) {
const api = new mw.Api();
const pageName = mw.config.get('wgPageName');
const talkName = 'Talk:' + pageName;
try {
const talkData = await api.get({
action: 'query',
prop: 'revisions',
titles: talkName,
rvprop: 'content',
formatversion: 2
});
let talkWikitext = talkData.query.pages[0].revisions[0].content;
talkWikitext = talkWikitext.replace(/(\|class\s*=\s*)Stub/i, `$1${newClass}`);
await api.postWithEditToken({
action: 'edit',
title: talkName,
text: talkWikitext,
summary: `Promoting article to ${newClass} class per ORES prediction [[WP:ORES]]`
});
const artData = await api.get({
action: 'query',
prop: 'revisions',
titles: pageName,
rvprop: 'content',
formatversion: 2
});
let artWikitext = artData.query.pages[0].revisions[0].content;
const stubRegex = /\{\{[^}]*?-stub\}\}\s*\n?/gi;
const genericStubRegex = /\{\{stub\}\}\s*/gi;
artWikitext = artWikitext.replace(stubRegex, '').replace(genericStubRegex, '');
await api.postWithEditToken({
action: 'edit',
title: pageName,
text: artWikitext,
summary: `Removing stub tags; article promoted to ${newClass}`
});
location.reload();
} catch (e) {
alert("Promotion failed. Check console for details.");
console.error(e);
}
}
function replaceMI(text) {
const miRegex = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miRegex.exec(text)) !== null) {
const start = match.index;
result += text.slice(lastIndex, start);
let depth = 0;
let i = start;
while (i < text.length) {
if (text[i] === '{' && text[i + 1] === '{') {
depth++;
i += 2;
} else if (text[i] === '}' && text[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
const end = i;
const full = text.slice(start, end);
const pipeIdx = full.indexOf('|');
const inner = full.slice(pipeIdx + 1, full.length - 2).trim();
const topLevel = extractTopLevelTemplates(inner);
if (topLevel.length === 0) {
result += '';
} else if (topLevel.length === 1) {
result += topLevel[0].trim();
} else {
result += full;
}
lastIndex = end;
}
result += text.slice(lastIndex);
return result;
}
function extractTopLevelTemplates(inner) {
const templates = [];
let i = 0;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
let depth = 0;
const start = i;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
depth++;
i += 2;
} else if (inner[i] === '}' && inner[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
templates.push(inner.slice(start, i));
} else {
i++;
}
}
return templates;
}
async function performSortCorrection(newName, type) {
const api = new mw.Api();
const title = mw.config.get("wgPageName");
try {
const data = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2
});
let content = data.query.pages[0].revisions[0].content;
const dsRegex = /\{\{(?:DEFAULTSORT|Defaultsort):[^}]+\}\}/i;
const newTag = `{{DEFAULTSORT:${newName}}}`;
let actionDesc = type === "add" ? "added" : "corrected";
if (dsRegex.test(content)) {
content = content.replace(dsRegex, newTag);
} else {
const catRegex = /\[\[Category:/i;
if (catRegex.test(content)) {
content = content.replace(catRegex, `${newTag}\n[[Category:`);
} else {
content += `\n\n${newTag}`;
}
}
await api.postWithEditToken({
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName}`,
nocreate: true
});
mw.notify(`DEFAULTSORT ${actionDesc} to ${newName}!`, {
title: 'Comrade Success',
type: 'success'
});
setTimeout(() => location.reload(), 700);
} catch (err) {
mw.notify('Failed to save DEFAULTSORT.', {
type: 'error'
});
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
function renderSortNudge(target, reason) {
const $dsNudge = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Correct to: ${target}`).on('click', () => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
async function fetchWikidataNameLabels(entity) {
const familyIds = entity.claims?.P734?.map(c => c.mainsnak.datavalue.value.id) || [];
const givenIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
const allNameIds = [...new Set([...familyIds, ...givenIds])];
if (allNameIds.length === 0) return {
givens: [],
surnames: []
};
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: allNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
});
const nameData = await fetch(url).then(r => r.json());
return {
givens: givenIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean),
surnames: familyIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean)
};
}
function fixNameCasing(part, referenceName) {
return part.split(' ').map(word => {
const match = referenceName.match(new RegExp(`\\b${word}\\b`, 'i'));
if (match) return match[0];
return referenceName.split(' ').find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase())) || word;
}).join(' ');
}
function getLongNameFromWikitext(wikitext) {
const firstLine = wikitext.split('\n').find(l => l.includes("'''"));
if (!firstLine) return null;
const boldMatch = firstLine.match(/'''(.+?)'''/);
if (!boldMatch) return null;
return boldMatch[1].replace(/\s*([“"«《„‹「『'].+?[”"»》”›」』']|\([^)\n]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
}
async function init() {
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") return;
const api = new mw.Api();
const qid = mw.config.get("wgWikibaseItemId");
try {
const [pageData, backlinkData] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
api.get({
action: 'query',
list: 'backlinks',
blfilterredir: 'nonredirects',
bllimit: 50,
blnamespace: 0,
bltitle: mw.config.get("wgPageName"),
formatversion: 2
})
]);
const page = pageData.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const currentTitle = mw.config.get("wgTitle");
const cleanTitle = stripDiacritics(currentTitle);
const isOrphanTagged = /\{\{\s*(Orphan|Do-attempt|Lonely|Orp|Orphaned article)/i.test(wikitext) || /\| *orphan *=/i.test(wikitext);
const backLinkCount = backlinkData.query.backlinks.length;
if (isOrphanTagged && backLinkCount >= 1) {
const $container = $('<div>').addClass('comrade-container comrade-status');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text('Remove orphan tag').on('click', () => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) await checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
await checkDomainRedirect(qid, currentTitle);
let isHuman = false;
let entity = null;
if (qid) {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
});
const wikiData = await fetch(url).then(r => r.json());
entity = wikiData.entities[qid];
isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
}
await checkNamingPrecision(currentTitle, isHuman);
if (qid && entity) {
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const nameParts = tClean.split(' ');
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
const isOrphan = isOrphanTagged && backLinkCount < 1;
const primarySurname = hasFamilyNameHatnote ? nameParts[0] : (getSmartSortKey(tClean, entity).split(',')[0] || nameParts[nameParts.length - 1]);
let targetSortName = "";
if (isHuman) {
const {
givens,
surnames
} = await fetchWikidataNameLabels(entity);
const refName = entity.labels?.en?.value || currentTitle;
if (dsMatch) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixNameCasing(lastPart, refName)}, ${fixNameCasing(firstPart, refName)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field.");
} else if (givens.length > 0 && lastPart.split(' ').length > 1) {
const misplacedGiven = lastPart.split(' ').find(part => givens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastPart.split(' ').filter(p => p !== misplacedGiven).join(' ');
targetSortName = `${fixNameCasing(newSurname, refName)}, ${fixNameCasing(misplacedGiven + ' ' + firstPart, refName)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
}
}
} else {
targetSortName = hasFamilyNameHatnote ? `${nameParts[0]}, ${nameParts.slice(1).join(' ')}` : getSmartSortKey(tClean, entity);
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).on('click', () => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
const longName = getLongNameFromWikitext(wikitext);
if (longName && longName.length > tClean.length) {
let sortKey = hasFamilyNameHatnote ? `${longName.split(' ')[0]}, ${longName.split(' ').slice(1).join(' ')}` : getSmartSortKey(longName, entity);
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
await checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
if (targetSortName) {
let targetPage = currentTitle.includes(' (') ? currentTitle.split(' (')[0] : currentTitle;
let rCat = targetPage !== currentTitle ? "R from ambiguous sort name" : "R from sort name";
if (targetSortName.includes(',')) {
const p = targetSortName.split(',');
rCat += `|${p[0].trim().charAt(0).toUpperCase()}|${p[1].trim().charAt(0).toUpperCase()}`;
}
await checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name");
}
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
surnames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
givens.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) finalGivenNames.add(gn);
});
if (finalFamilyNames.size === 0 && nameParts.length >= 2) finalFamilyNames.add(nameParts[nameParts.length - 1]);
if (finalGivenNames.size === 0 && nameParts.length >= 2) finalGivenNames.add(nameParts[0]);
const getPriorityTarget = async (name, type) => {
const titles = type === 'surname' ? [`List of people with surname ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`] : [`List of people with given name ${name}`, `${name} (given name)`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
return titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
};
for (const name of finalGivenNames) {
if (name.toLowerCase() === primarySurname.toLowerCase()) continue;
const target = await getPriorityTarget(name, 'given');
if (target) await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
for (const name of finalFamilyNames) {
const titles = [name, `${name} (surname)`, `List of people with surname ${name}`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
const target = titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
if (target) await checkNameList(name, 'surname', currentTitle, hasShortDesc, isOrphan, target);
}
}
const oresWait = setInterval(() => {
if ($('.article_quality').length) {
clearInterval(oresWait);
checkQualityConsistency();
}
}, 500);
setTimeout(() => clearInterval(oresWait), 5000);
}
} catch (err) {
console.error("Comrade error:", err);
}
}
init();
})();
(function() {
if (window.IllWill) return;
const STRIKEOUT = true;
const HIDE = 'hide';
const IllWill = {
Version: "1.1.1",
Attribution: 'created by <a target="_blank" href="/wiki/User:Cobaltcigs">cobaltcigs</a>',
Summary: "Added interlanguage links using IllWill.js",
DataApi: "https://www.wikidata.org/w/api.php",
LocalLang: mw.config.get("wgPageContentLanguage"),
Disambiguator: / \(.*\)$/,
SiteLinks: {},
Config: {
MaxLanguages: 5,
IgnoreScientific: STRIKEOUT,
AutoDiff: true
},
css: `
#illwill { clear: both; margin-bottom: 1.5em; border: 1px solid #a2a9b1; padding: 15px; background: #f8f9fa; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.ill-sitelinks a { display: inline-block; margin-bottom: 3px; font-size: 0.9em; color: #36c; }
.ill-size-tag { font-size: 0.8em; color: #72777d; margin-left: 6px; white-space: nowrap; }
.illwill-ignore { text-decoration: line-through; color: #72777d; font-size: 0.85em; }
.ill-x { width: 35px; text-align: center; }
.ill-input, .ill-output { font-family: 'Courier New', monospace; white-space: pre-wrap; font-size: 0.9em; background: #fff; border: 1px solid #eaecf0; padding: 4px; border-radius: 2px; }
#ill-buttons { text-align: right; padding: 10px; border-top: 1px solid #a2a9b1; margin-top: 10px; }
#ill-buttons button { margin-left: 10px; cursor: pointer; padding: 6px 16px; border-radius: 2px; border: 1px solid #a2a9b1; transition: all 0.2s; }
#ill-ok { background: #36c; color: #fff; border-color: #36c !important; font-weight: bold; }
#ill-ok:hover { background: #447ff5; }
#ill-ok:disabled { background: #eaecf0; color: #72777d; border-color: #c8ccd1 !important; cursor: not-allowed; opacity: 0.7; }
#ill-table { width: 100%; border-collapse: collapse; margin-top: 10px; background: white; }
#ill-table th { background: #f2f2f2; text-align: left; padding: 8px; border: 1px solid #a2a9b1; }
#ill-table td { padding: 8px; border: 1px solid #a2a9b1; vertical-align: top; }
#ill-table caption { background: #eaffea; font-weight: bold; padding: 10px; border: 1px solid #a2a9b1; border-bottom: none; font-size: 1.1em; }
#ill-help { font-size: 0.85em; color: #54595d; padding: 10px; line-height: 1.4; }
.ill-loading-inline { font-style: italic; color: #72777d; font-size: 0.9em; display: block; margin: 5px 0; }
`,
setup() {
if (!["edit", "submit"].includes(mw.config.get('wgAction'))) return;
mw.loader.addStyleTag(this.css);
const link = mw.util.addPortletLink('p-cactions', '#', 'IllWill', 'ca-illwill', "Add interlanguage links via Wikidata");
if (link) {
link.addEventListener('click', (e) => {
e.preventDefault();
this.makePanel();
});
}
},
norm(s) {
if (!s) return "";
const t = s.trim().replace(/[\s_]+/g, " ");
return t.charAt(0).toUpperCase() + t.slice(1);
},
async getRedLinks($textbox) {
const txt = $textbox.textSelection('getContents') || "";
const wl = txt.match(/\[\[[^[\]\n]+\]\]/g);
if (!wl) return {
reds: [],
txt
};
const api = new mw.Api();
const textToParse = wl.join("").replace(/\|[^\]]+/g, "");
try {
const data = await api.post({
action: 'parse',
text: textToParse,
contentmodel: 'wikitext',
formatversion: 2
});
const tempDiv = document.createElement('div');
tempDiv.innerHTML = data.parse.text;
const reds = Array.from(tempDiv.querySelectorAll("a.new")).map(x => {
const urlParams = new URLSearchParams(x.href.split('?')[1]);
return urlParams.get('title')?.replace(/_/g, " ");
}).filter(Boolean);
return {
reds: [...new Set(reds)],
txt
};
} catch (e) {
return {
reds: [],
txt
};
}
},
async makePanel() {
const $editform = $('#editform');
const $textbox = $('#wpTextbox1');
$('#illwill').remove();
$editform.hide();
const $wrapper = $('<div id="illwill">Loading redlinks...</div>').insertBefore($editform);
const {
reds,
txt
} = await this.getRedLinks($textbox);
const wtLinks = [];
reds.forEach(title => {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s/g, "[\\s_]+");
const re = new RegExp("\\[\\[\\s*" + escaped + "\\s*(?:\\|[^\\[\\]]+)?\\]\\]", "ig");
const match = txt.match(re);
if (match && !wtLinks.some(z => z.title === title)) {
wtLinks.push({
link: match[0],
title
});
}
});
if (!wtLinks.length) {
const $back = $('<button>Quit</button>').on('click', () => {
$wrapper.remove();
$editform.show();
});
$wrapper.empty().append($('<strong>No valid redlinks found in text.</strong> '), $back);
return;
}
const $table = $(`
<table id="ill-table" class="wikitable">
<caption>IllWill.js (v${this.Version})</caption>
<thead>
<tr>
<th class="ill-x"><input type="checkbox" id="ill-select-all" disabled></th>
<th>Input link</th>
<th>Possible Wikidata matches</th>
<th>Sitelinks (top ${this.Config.MaxLanguages} by size)</th>
<th>Resulting wikitext</th>
</tr>
</thead>
<tbody></tbody>
<tfoot>
<tr>
<td colspan="3" id="ill-help">Carefully examine possible Wikidata matches.</td>
<td colspan="2" id="ill-buttons">
<button id="ill-cancel">Cancel</button>
<button id="ill-ok" disabled>Show changes</button>
</td>
</tr>
</tfoot>
</table>
`);
const $tbody = $table.find('tbody');
wtLinks.forEach((item, i) => {
$tbody.append(`
<tr id="ill-row-${i}" data-index="${i}">
<td class="ill-x"><input type="checkbox" class="ill-row-check" disabled></td>
<td class="ill-input">${this.escapeHTML(item.link)}</td>
<td class="ill-wd-item"><span class="ill-loading-inline">Searching...</span></td>
<td class="ill-sitelinks"></td>
<td class="ill-output"></td>
</tr>
`);
});
$wrapper.empty().append($table);
const chunkSize = 5;
for (let i = 0; i < wtLinks.length; i += chunkSize) {
const chunk = wtLinks.slice(i, i + chunkSize);
await Promise.all(chunk.map((item, idx) => this.fetchWikidataOptimized(item.title, i + idx)));
}
$table.on('click', '#ill-cancel', () => {
$editform.show();
$wrapper.remove();
});
$table.on('click', '#ill-ok', () => this.onOkButton($editform, $wrapper, $textbox));
$table.on('change', '#ill-select-all', (e) => {
const isChecked = $(e.target).is(':checked');
$table.find('.ill-row-check:not(:disabled)').each(function() {
$(this).prop('checked', isChecked).trigger('change');
});
});
$table.on('change', '.ill-row-check', (e) => {
this.onCheckbox(e);
this.updateOkButtonState($table);
});
$table.on('change', 'input[type="radio"]', (e) => {
this.showSiteLinks(e);
});
},
async fetchWikidataOptimized(title, index) {
const query = title.replace(this.Disambiguator, "");
const url = `${this.DataApi}?action=wbsearchentities&search=${encodeURIComponent(query)}&language=${this.LocalLang}&limit=5&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
const results = data.search;
const $cell = $(`#ill-row-${index} .ill-wd-item`);
if (!results || !results.length) {
$cell.html('<span class="ill-na">(no results)</span>');
return;
}
const entityIds = results.map(r => r.id);
await this.fetchEntities(entityIds, index, $cell);
} catch (e) {
$(`#ill-row-${index} .ill-wd-item`).text("Search failed.");
}
},
async fetchEntities(ids, rowIndex, $cell) {
const url = `${this.DataApi}?action=wbgetentities&ids=${ids.join('|')}&props=labels|descriptions|claims|sitelinks&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
$cell.empty();
ids.forEach(qNum => {
const entity = data.entities[qNum];
if (!entity) return;
const isScientific = entity.claims?.P31?.some(s => s.mainsnak?.datavalue?.value?.id === "Q13442814");
if (isScientific && this.Config.IgnoreScientific === HIDE) return;
const desc = entity.descriptions?.[this.LocalLang]?.value || entity.descriptions?.en?.value || "";
const label = entity.labels?.[this.LocalLang]?.value || entity.labels?.en?.value || qNum;
const isStrikeout = isScientific && this.Config.IgnoreScientific === STRIKEOUT;
const $div = $(`
<div class="${isStrikeout ? 'illwill-ignore' : ''}">
<input type="radio" name="selection-${rowIndex}" value="${qNum}" id="radio-${qNum}" ${isStrikeout ? 'disabled' : ''}>
<label for="radio-${qNum}">
<a href="https://wikidata.org/wiki/${qNum}" target="_blank">${this.escapeHTML(label)}</a>:
<span class="ill-desc">${desc ? this.escapeHTML(desc) : '<i>no description</i>'}</span>
</label>
</div>
`);
$cell.append($div);
this.SiteLinks[qNum] = entity.sitelinks || {};
});
} catch (e) {
$cell.text("Detail fetch failed.");
}
},
updateOkButtonState($table) {
const $rows = $table.find('.ill-row-check:not(:disabled)');
const $checkedRows = $rows.filter(':checked');
const anyEnabled = $rows.length > 0;
$table.find('#ill-ok').prop('disabled', $checkedRows.length === 0);
const $master = $table.find('#ill-select-all');
$master.prop('disabled', !anyEnabled);
if (anyEnabled && $rows.length === $checkedRows.length) {
$master.prop('checked', true);
} else {
$master.prop('checked', false);
}
},
async showSiteLinks(e) {
const isManualToggling = e.fromCheckbox || false;
const qNum = e.target.value;
const $row = $(e.target).closest('tr');
const sitelinks = this.SiteLinks[qNum];
const $checkbox = $row.find('.ill-row-check');
const $sitelinkCell = $row.find('.ill-sitelinks');
const $outputCell = $row.find('.ill-output');
const $table = $('#ill-table');
const effectiveMax = Math.min(Math.max(this.Config.MaxLanguages, 3), 6);
const validKeys = Object.keys(sitelinks).filter(k => k.endsWith('wiki') && !k.includes('commons'));
$sitelinkCell.html('<span class="ill-loading-inline">Measuring articles...</span>');
$outputCell.empty();
if (!isManualToggling) {
$checkbox.prop('disabled', true).prop('checked', false);
}
const candidates = validKeys.slice(0, 25);
const sizeMap = [];
await Promise.all(candidates.map(async (key) => {
const lang = key.replace('wiki', '').replace(/_/g, '-');
const title = sitelinks[key].title;
const endpoint = `https://${lang}.wikipedia.org/w/api.php?action=query&titles=${encodeURIComponent(title)}&prop=info&format=json&origin=*`;
try {
const res = await fetch(endpoint).then(r => r.json());
const page = Object.values(res.query.pages)[0];
sizeMap.push({
lang,
title,
length: page.length || 0
});
} catch (err) {
sizeMap.push({
lang,
title,
length: 0
});
}
}));
sizeMap.sort((a, b) => b.length - a.length);
const topLinks = sizeMap.slice(0, effectiveMax);
$sitelinkCell.html(topLinks.map(item => {
const kbValue = Math.round(item.length / 1024);
return `<div><a href="https://${item.lang}.wikipedia.org/wiki/${encodeURIComponent(item.title)}" target="_blank">${item.lang}:${this.escapeHTML(item.title)}</a> <span class="ill-size-tag">(${kbValue} kB)</span></div>`;
}).join(""));
const inputWikitext = $row.find('.ill-input').text();
const match = inputWikitext.match(/\[\[(.*?)\]\]/);
if (match) {
const parts = match[1].split('|');
const targetPage = parts[0].trim();
const surfaceName = (parts[1] || parts[0]).trim();
const illLangs = topLinks.map(item => {
const isSameTitle = this.norm(item.title) === this.norm(targetPage);
return `|${item.lang}|${isSameTitle ? '' : item.title}`;
}).join("");
const ltParam = (this.norm(surfaceName) === this.norm(targetPage)) ? "" : `|lt=${surfaceName}`;
$outputCell.text(`{{ill|${targetPage}${ltParam}${illLangs}}}`);
}
$checkbox.prop('disabled', !topLinks.length);
if (!isManualToggling) {
$checkbox.prop('checked', !!topLinks.length);
}
this.updateOkButtonState($table);
},
onCheckbox(e) {
const $row = $(e.target).closest('tr');
const $radio = $row.find('input[type="radio"]:checked');
const isChecked = $(e.target).is(':checked');
if (isChecked) {
if ($radio.length) {
this.showSiteLinks({
target: $radio[0],
fromCheckbox: true
});
}
} else {
$row.find('.ill-sitelinks, .ill-output').empty();
}
},
onOkButton($editform, $wrapper, $textbox) {
let text = $textbox.textSelection('getContents');
let changed = false;
$wrapper.find('.ill-row-check:checked').each(function() {
const $row = $(this).closest('tr');
const input = $row.find('.ill-input').text();
const output = $row.find('.ill-output').text();
if (output) {
const escapedInput = input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
text = text.replace(new RegExp(escapedInput, "gi"), output);
changed = true;
}
});
if (changed) {
$('#wpSummary').val((i, v) => (v ? v + "; " : "") + this.Summary);
$textbox.textSelection('setContents', text);
$wrapper.remove();
$editform.show();
if (this.Config.AutoDiff) $('#wpDiff').click();
}
},
escapeHTML(str) {
return str.replace(/[&<>"']/g, m => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
})[m]);
}
};
window.IllWill = IllWill;
mw.loader.using(['mediawiki.util', 'mediawiki.api', 'jquery.textSelection'], () => {
$(() => IllWill.setup());
});
})();
(function() {
'use strict';
const config = {
enableNS14: true,
rootWhitelists: ['Materials'],
userWhitelist: ""
};
const categoryDataCache = {};
let scanProgress = 0;
let totalToScan = 0;
async function verifyTreeMembership() {
if (!config.enableNS14) return false;
const api = new mw.Api();
const currentTitle = mw.config.get("wgPageName").replace(/_/g, ' ');
const fullRoots = config.rootWhitelists.concat(config.userWhitelist.split(',').map(s => s.trim()).filter(s => s)).map(cat => (cat.startsWith('Category:') ? cat : `Category:${cat}`));
if (fullRoots.includes(currentTitle)) return true;
let categoriesToTable = mw.config.get('wgCategories') || [];
let checked = new Set();
while (categoriesToTable.length > 0) {
if (categoriesToTable.some(cat => fullRoots.includes('Category:' + cat))) {
return true;
}
categoriesToTable.forEach(cat => checked.add(cat));
try {
const res = await api.get({
formatversion: 2,
prop: 'categories',
titles: categoriesToTable.map(c => 'Category:' + c),
cllimit: 'max'
});
let nextBatch = [];
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.categories) {
page.categories.forEach(parent => {
const parentName = parent.title.substring('Category:'.length);
if (!checked.has(parentName)) {
nextBatch.push(parentName);
}
});
}
});
}
categoriesToTable = [...new Set(nextBatch)];
} catch (e) {
return false;
}
}
return false;
}
function getChemboxBlock(wikitext) {
const chemboxRegex = /\{\{\s*Chembox[\s_]*(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const match = wikitext.match(chemboxRegex);
return match ? match[0] : null;
}
function getFormulaFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const formulaMatch = chembox.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
let rawFormula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
rawFormula = rawFormula.split(/\s+/)[0];
return rawFormula.split(',')[0].trim();
}
const propRegex = /\{\{\s*Chembox[\s_]+Properties(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const propSection = chembox.match(propRegex);
if (propSection) {
const content = propSection[0];
const allElements = ['Ac', 'Ag', 'Al', 'Am', 'Ar', 'As', 'At', 'Au', 'B', 'Ba', 'Be', 'Bh', 'Bi', 'Bk', 'Br', 'C', 'Ca', 'Cd', 'Ce', 'Cf', 'Cl', 'Cm', 'Cn', 'Co', 'Cr', 'Cs', 'Cu', 'D', 'Db', 'Ds', 'Dy', 'Er', 'Es', 'Eu', 'F', 'Fe', 'Fl', 'Fm', 'Fr', 'Ga', 'Gd', 'Ge', 'H', 'He', 'Hf', 'Hg', 'Ho', 'Hs', 'I', 'In', 'Ir', 'K', 'Kr', 'La', 'Li', 'Lr', 'Lu', 'Lv', 'Mc', 'Md', 'Mg', 'Mn', 'Mo', 'Mt', 'N', 'Na', 'Nb', 'Nd', 'Ne', 'Nh', 'Ni', 'No', 'Np', 'O', 'Og', 'Os', 'P', 'Pa', 'Pb', 'Pd', 'Pm', 'Po', 'Pr', 'Pt', 'Pu', 'Ra', 'Rb', 'Re', 'Rf', 'Rg', 'Rh', 'Rn', 'Ru', 'S', 'Sb', 'Sc', 'Se', 'Sg', 'Si', 'Sm', 'Sn', 'Sr', 'Ta', 'Tb', 'Tc', 'Te', 'Th', 'Ti', 'Tl', 'Tm', 'Ts', 'U', 'V', 'W', 'Xe', 'Y', 'Yb', 'Zn', 'Zr'];
let data = {};
allElements.forEach(el => {
const elRegex = new RegExp(`\\|\\s*${el}\\s*=\\s*(\\d*\\.?\\d+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch && elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
});
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
elementsPresent.filter(el => !(hasC && (el === "C" || el === "H"))).sort().forEach(el => {
formulaArray.push(el + data[el]);
});
return formulaArray.join('');
}
}
return null;
}
function getCASFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const idRegex = /\{\{\s*Chembox[\s_]+Identifiers(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const idSection = chembox.match(idRegex);
if (!idSection) return null;
const content = idSection[0];
const nbsp = String.fromCharCode(160);
const casMatch = content.match(/\|\s*CASNo\d*\b(?!\s*_)\s*=\s*([^|}\n]+)/i);
if (casMatch) {
let val = casMatch[1].replace(/\{\{[^}]+\}\}/g, "").replace(/\u00AD/g, "").split(" ").join("");
while (val.indexOf(nbsp) !== -1) val = val.replace(nbsp, "");
while (val.indexOf(" ") !== -1) val = val.replace(" ", "");
const cleanedCas = val.trim();
return /^\d{2,7}-\d{2}-\d$/.test(cleanedCas) ? cleanedCas : null;
}
return null;
}
async function getWikidataValue() {
const qid = mw.config.get('wgWikidataItemId');
if (!qid) return null;
const api = new mw.ForeignApi('https://www.wikidata.org/w/api.php');
const res = await api.get({
action: 'wbgetentities',
ids: qid,
props: 'claims',
format: 'json',
formatversion: 2
});
const claims = res.entities[qid].claims;
const data = {};
if (claims.P274) data.formula = claims.P274[0].mainsnak.datavalue.value;
if (claims.P231) data.cas = claims.P231[0].mainsnak.datavalue.value;
return data;
}
async function startBackgroundCategoryScan() {
const api = new mw.Api();
const titles = $('#mw-pages li a').map((i, el) => $(el).attr('title') || $(el).text()).get();
totalToScan = titles.length;
if (totalToScan === 0) return;
const chunks = arrayChunk(titles, 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: chunk,
rvprop: 'content',
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const f = getFormulaFromWikitext(wikitext);
const c = getCASFromWikitext(wikitext);
if (f || c) categoryDataCache[page.title] = {
formula: f,
cas: c
};
});
}
scanProgress += chunk.length;
$('#caschemassist-cat-btn').text(`Scanning... ${scanProgress}/${totalToScan}`);
}
$('#caschemassist-cat-btn').text('Show missing CAS/Chem').prop('disabled', false);
}
async function renderCategoryAudit() {
const api = new mw.Api();
const missingCheckTitles = [];
Object.values(categoryDataCache).forEach(data => {
if (data.formula) missingCheckTitles.push(data.formula);
if (data.cas) missingCheckTitles.push(data.cas);
});
if (missingCheckTitles.length === 0) {
mw.notify("No valid Chembox data found in this category.");
return;
}
const existenceMap = {};
const chunks = arrayChunk([...new Set(missingCheckTitles)], 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
titles: chunk,
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(p => {
existenceMap[p.title] = !p.missing;
});
}
}
$('#mw-pages li').each(function() {
const title = $(this).find('a').attr('title') || $(this).find('a').text().trim();
const data = categoryDataCache[title];
if (!data) return;
if (data.formula && !existenceMap[data.formula]) {
$(this).append($('<span>').css({
'color': 'red',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [${data.formula}]`));
}
if (data.cas && !existenceMap[data.cas]) {
$(this).append($('<span>').css({
'color': 'darkred',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [CAS: ${data.cas}]`));
}
});
$('#caschemassist-cat-btn').text('Audit complete').prop('disabled', true);
}
async function handleSingleArticle() {
const api = new mw.Api();
const currentTitle = mw.config.get("wgTitle");
const [wikiRes, wikidata] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
getWikidataValue()
]);
const page = wikiRes.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const formula = getFormulaFromWikitext(wikitext);
const cas = getCASFromWikitext(wikitext);
if (wikidata) {
let mismatches = [];
if (formula && wikidata.formula && formula !== wikidata.formula) {
mismatches.push(`Formula mismatch: Chembox "${formula}" vs Wikidata "${wikidata.formula}"`);
}
if (cas && wikidata.cas && cas !== wikidata.cas) {
mismatches.push(`CAS mismatch: Chembox "${cas}" vs Wikidata "${wikidata.cas}"`);
}
if (mismatches.length > 0) {
const $warning = $('<div>').css({
'background': '#fff2f2',
'border': '2px solid #d33',
'padding': '10px',
'margin-bottom': '1em',
'color': '#b32424'
}).append($('<strong>').text('⚠️ Data Mismatch Detected: '), mismatches.join(' | '));
$('#mw-content-text').prepend($warning);
}
}
async function checkAndRender(target, label, customWikitext) {
const check = await api.get({
action: 'query',
titles: target,
formatversion: 2
});
if (check.query.pages[0].missing) {
const $container = $('<div>').css({
'background': '#f8f9fa',
'border': '1px solid #a2a9b1',
'padding': '10px',
'margin-bottom': '1em'
});
const $btn = $('<button>').text(`Create redirect: ${target}`).click(async function() {
await api.postWithEditToken({
action: 'edit',
title: target,
text: customWikitext,
summary: `Creating redirect to [[${currentTitle}]] (${label})`,
createonly: true
});
mw.notify(`Created redirect: ${target}`);
$container.fadeOut(400, function() {
$(this).remove();
});
});
$container.append($('<strong>').text(`${label}: `), `Missing redirect. `, $btn);
$('#mw-content-text').prepend($container);
}
}
if (formula && formula !== currentTitle) {
const formulaWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from chemical formula}}\n}}`;
await checkAndRender(formula, "Chemical formula", formulaWikitext);
}
if (cas && cas !== currentTitle) {
const casWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from CAS Registry Number}}\n{{R unprintworthy}}\n}}`;
await checkAndRender(cas, "CAS number", casWikitext);
}
}
function arrayChunk(arr, size) {
const result = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
$(document).ready(async function() {
const ns = mw.config.get("wgNamespaceNumber");
if (ns === 14) {
if (await verifyTreeMembership()) {
const $catContainer = $('#mw-pages');
if ($catContainer.length) {
const $btn = $('<button>').text('Initializing scan...').attr('id', 'caschemassist-cat-btn').addClass('mw-ui-button').css({
'margin': '0 10px 10px 0',
'float': 'right',
'position': 'relative',
'z-index': '100'
}).prop('disabled', true).click(renderCategoryAudit);
$catContainer.prepend($btn);
startBackgroundCategoryScan();
}
}
} else if (ns === 0 && mw.config.get("wgAction") === "view") {
handleSingleArticle();
}
});
})();
//</nowiki>
to5pw03xjwxr1vpuv2hx27bm7xpnc8r
739297
739295
2026-04-25T07:26:54Z
Sam Sailor
26820
Test
739297
javascript
text/javascript
//<nowiki>
(function() {
mw.util.addCSS(`
.ambox-Orphan { display: block !important; }
.comrade-container {
box-shadow: 0 1px 3px rgba(0,0,0,0.1); border-radius: 2px;
padding: 10px 15px; margin: 10px 0; display: flex;
flex-direction: column; align-items: flex-start; gap: 8px;
border: 1px solid #a2a9b1; background: #fcfcfc; line-height: 1.5;
}
.comrade-header { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.comrade-content { display: flex; flex-direction: column; gap: 5px; width: 100%; }
.comrade-success { background-color: #d5f5e3 !important; border-left: 5px solid #27ae60 !important; color: #1b5e20 !important; }
.comrade-nudge { background: #fff9ea; border-left: 5px solid #f0ad4e; }
.comrade-info { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-status { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-container button:not(.comrade-dismiss) {
background: #f8f9fa; border: 1px solid #a2a9b1; border-radius: 2px;
padding: 2px 10px; cursor: pointer; font-weight: bold; font-size: 0.95em; margin-left: 10px;
}
.comrade-container button:not(.comrade-dismiss):hover { border-color: #36c; color: #36c; background: #fff; }
.comrade-dismiss {
background: transparent; border: none; color: #d33; font-weight: bold;
cursor: pointer; font-size: 0.9em;
}
.comrade-dismiss:hover { text-decoration: underline; }
.comrade-code-block { background: #f1f1f1; padding: 4px 8px; border-radius: 3px; font-family: monospace; display: inline-block; margin-top: 4px; }
`);
function appendDismiss($el) {
$('<button>').addClass('comrade-dismiss').text('Dismiss').on('click', function() {
$(this).closest('.comrade-container').fadeOut();
}).appendTo($el.find('.comrade-header'));
$("#siteSub").after($el);
}
async function checkNamingPrecision(currentTitle, isHuman) {
const disambigMatch = currentTitle.match(/^(.+?)(?:, | \()/);
if (isHuman || !disambigMatch) return;
const baseTitle = disambigMatch[1].trim();
const api = new mw.Api();
try {
const baseRes = await api.get({
action: 'query',
titles: baseTitle,
redirects: 1,
formatversion: 2
});
const page = baseRes.query.pages[0];
const baseExists = !page.missing;
let baseRedirectsToSelf = false;
if (baseRes.query.redirects) {
baseRedirectsToSelf = baseRes.query.redirects.some(r => r.to === currentTitle);
}
const searchRes = await api.get({
action: 'query',
list: 'allpages',
apprefix: baseTitle,
aplimit: 50,
formatversion: 2
});
const competitors = searchRes.query.allpages.filter(p => {
const t = p.title;
return t !== currentTitle && t !== baseTitle && (t.startsWith(baseTitle + ', ') || t.startsWith(baseTitle + ' ('));
});
if (!baseExists || baseRedirectsToSelf) {
const $container = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (competitors.length === 0) {
$header.append($('<strong>').text('Precision:'));
const reason = baseRedirectsToSelf ? "already redirects here" : "is free";
$content.append($('<span>').append(`The base name `, $('<code>').text(baseTitle), ` ${reason}. Consider moving this article.`));
const moveUrl = mw.util.getUrl('Special:MovePage/' + currentTitle, {
wpNewTitle: baseTitle,
wpReason: 'Unnecessary disambiguation; base name is free/redirects here per [[WP:PRECISION]]'
});
$content.append($('<button>').text(`Move to ${baseTitle}`).on('click', () => window.open(moveUrl, '_blank')));
} else {
$header.append($('<strong>').text('Observation:'));
const state = baseRedirectsToSelf ? 'redirect to this page' : 'redlink';
$content.append($('<span>').append(`Base name `, $('<code>').text(baseTitle), ` has ${competitors.length} competitors, but it is currently a ${state}.`));
$content.append($('<span>').text('Consider if a disambiguation page is needed.'));
}
$container.append($header, $content);
appendDismiss($container);
}
} catch (e) {
console.warn("Comrade: Precision check failed", e);
}
}
async function checkAndRenderRedirect(redirTitle, targetTitle, rCategory, labelText, customWikitext) {
const api = new mw.Api();
const res = await api.get({
action: 'query',
titles: redirTitle,
formatversion: 2
});
if (res.query.pages[0].missing) {
const safeId = "comrade-redir-" + redirTitle.replace(/[^a-z0-9]/gi, '-');
const $container = $('<div>').addClass('comrade-container comrade-info').attr('id', safeId);
const $header = $('<div>').addClass('comrade-header');
const summary = `Created redirect from ${labelText.toLowerCase()} to [[${targetTitle}]]`;
const $btn = $('<button>').text('Create redirect').on('click', () => createModernRedirect(redirTitle, targetTitle, rCategory, summary, safeId, customWikitext));
$header.append($('<div>').append($('<strong>').text(`${labelText}: `), `Create redirect from `, $('<code>').text(redirTitle), $btn));
$container.append($header);
appendDismiss($container);
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
if (qid) {
try {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
});
const res = await fetch(url).then(r => r.json());
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
}
} catch (e) {
console.warn("Comrade: Wikidata API call failed.");
}
}
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
if (domain.includes(".") && url.pathname === "/") {
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
try {
const response = await fetch(citationURL);
if (response.ok) {
await checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
}
} catch (e) {
console.warn("Comrade: Citation API check failed.");
}
}
} catch (e) {
console.warn("Comrade: Error parsing URL object.");
}
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan, resolvedTitle) {
const api = new mw.Api();
const candidates = resolvedTitle ? [resolvedTitle] : [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
redirects: 0
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const foundTitle = page.title;
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) break;
const $container = $('<div>').addClass('comrade-container');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (isOrphan) {
$container.addClass('comrade-nudge');
$header.append($('<strong>').text('Comrade nudge:'));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
}
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(foundTitle),
target: '_blank'
}).text(foundTitle), "; should it be?"));
const snippet = '* {{anbl|' + currentTitle + '}}';
const $snippetWrapper = $('<div>').css({
'display': 'flex',
'align-items': 'center',
'gap': '10px',
'margin-top': '4px'
});
const $instructionSpan = $('<span>').append('If the Short description is good, consider adding ', $('<code>').text(snippet).css({
'background-color': '#f8f9fa',
'border': '1px solid #eaecf0',
'border-radius': '2px',
'padding': '1px 4px'
}));
const $copyBtn = $('<button>').text('Copy & insert').css('margin-left', '0').on('click', function() {
navigator.clipboard.writeText(snippet).then(() => {
const $this = $(this);
const originalText = $this.text();
$this.text('Copied!').prop('disabled', true);
const editUrl = mw.util.getUrl(foundTitle, {
action: 'edit',
summary: snippet
});
window.open(editUrl, '_blank');
setTimeout(() => {
$this.text(originalText).prop('disabled', false);
}, 1500);
});
});
$snippetWrapper.append($instructionSpan, $copyBtn);
$content.append($snippetWrapper);
if (!hasShortDesc) {
$content.append($('<span>').css({
'color': '#d33',
'font-weight': 'bold',
'margin-top': '5px'
}).text('⚠️ Missing short description: Please add a concise one before listing.'));
}
$container.append($header, $content);
appendDismiss($container);
break;
}
}
}
}
async function checkQualityConsistency() {
const oresClass = getOresPrediction();
if (!oresClass || oresClass === 'Stub') return;
const talkTitle = 'Talk:' + mw.config.get('wgPageName');
const api = new mw.Api();
try {
const [talkRes, pageRes] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: talkTitle,
rvprop: 'content',
formatversion: 2
}),
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
})
]);
const talkPage = talkRes.query.pages[0];
const wikitext = pageRes.query.pages[0].revisions[0].content;
const hasStubTag = /\{\{[^}]*stub\}\}/i.test(wikitext);
const $raterLink = $('#ca-rater, #t-rater');
const isRaterAvailable = (typeof window.rater !== 'undefined' || $raterLink.length > 0);
let talkClass = null;
if (!talkPage.missing) {
const classMatch = talkPage.revisions[0].content.match(/\|class\s*=\s*([^|}\s]+)/i);
talkClass = classMatch ? classMatch[1].charAt(0).toUpperCase() + classMatch[1].slice(1).toLowerCase() : null;
}
const isStubRated = talkClass === 'Stub';
const lingeringStubTag = (talkClass && talkClass !== 'Stub' && oresClass !== 'Stub' && hasStubTag);
if (talkPage.missing || isStubRated || lingeringStubTag) {
const $container = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>');
let message = `ORES suggests <b>${oresClass}</b>. `;
let $btn;
if (talkPage.missing) {
message = `Talk page missing; ORES suggests <b>${oresClass}</b>. `;
if (isRaterAvailable) {
$btn = $('<button>').text('Open Rater').on('click', () => $raterLink.find('a')[0].click());
} else {
$btn = $('<button>').text('Install Rater').on('click', () => window.open('https://en.wikipedia.org/wiki/User:Evad37/rater', '_blank'));
message += "Consider installing <i>Rater</i> for easy assessment.";
}
} else if (lingeringStubTag && !isStubRated) {
message = `Article is ${talkClass}-class, but stub tags remain. `;
$btn = $('<button>').text('Remove stub tags').on('click', () => performStubRemoval());
} else if (isStubRated) {
message = `Talk page says Stub, but ORES predicts <b>${oresClass}</b>. `;
$btn = $('<button>').text(`Promote to ${oresClass}`).on('click', () => performPromotion(oresClass));
}
$header.append($content.append($('<strong>').text('Quality:'), " ", message, $btn));
$container.append($header);
appendDismiss($container);
}
} catch (e) {
console.error("Comrade quality check failed", e);
}
}
async function performStubRemoval() {
const api = new mw.Api();
try {
const pageData = await api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
});
let wikitext = pageData.query.pages[0].revisions[0].content;
const newWikitext = wikitext.replace(/\{\{[^}]*stub\}\}\n?/gi, '').trim();
if (wikitext === newWikitext) {
mw.notify("No stub tags found to remove.");
return;
}
await api.postWithToken('csrf', {
action: 'edit',
title: mw.config.get("wgPageName"),
text: newWikitext,
summary: `Removing lingering stub tags per ORES/Talk assessment`,
nocreate: true
});
location.reload();
} catch (err) {
console.error("Comrade failed to remove stub tags:", err);
mw.notify("Error removing stub tags.", {
type: 'error'
});
}
}
async function createModernRedirect(redirTitle, targetTitle, rCategory, customSummary, elementId, customWikitext) {
const api = new mw.Api();
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
try {
await api.postWithEditToken({
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
});
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
} catch (e) {
mw.notify("Failed to create redirect.", {
type: 'error'
});
}
}
function getOresPrediction() {
// Dependency: [[m:User:EpochFail/ArticleQuality-system.js]] renders the prediction inside .article_quality
const text = $('.article_quality').text();
if (!text) return null;
if (text.includes('Start')) return 'Start';
if (text.includes('C')) return 'C';
if (text.includes('B')) return 'B';
return null;
}
function getSmartSortKey(fullName, entity) {
const countryIds = entity.claims?.P27?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const locationIds = entity.claims?.P17?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const placeIds = [...(entity.claims?.P19 || []), ...(entity.claims?.P119 || [])].map(c => c.mainsnak?.datavalue?.value?.id).filter(Boolean);
const allRelevantIds = [...countryIds, ...locationIds, ...placeIds];
const birthYear = parseInt(entity.claims?.P569?.[0]?.mainsnak?.datavalue?.value?.time?.match(/[+-](\d{4})/)?.[1]) || null;
if (fullName.startsWith("Saint ")) return fullName.replace("Saint ", "");
const arabicParticles = /\b(Abu|Abd|Abdel|Abdul|ben|bin|bint)\b/i;
if (fullName.match(arabicParticles)) {
if (birthYear && birthYear > 1900) {
const parts = fullName.split(' ');
const binIndex = parts.findIndex(p => p.toLowerCase() === 'bin' || p.toLowerCase() === 'ben');
if (binIndex > 0) return parts.slice(binIndex).join(' ') + ', ' + parts.slice(0, binIndex).join(' ');
} else {
return fullName;
}
}
const eastAsianQids = ["Q148", "Q184", "Q884", "Q424", "Q881", "Q17"];
if (allRelevantIds.some(id => eastAsianQids.includes(id))) {
if (allRelevantIds.includes("Q17") && birthYear && birthYear > 1885) return westernSort(fullName);
const parts = fullName.split(' ');
if (parts.length > 1) return `${parts[0]}, ${parts.slice(1).join(' ')}`;
}
return westernSort(fullName);
}
function westernSort(name) {
let cleanName = name;
if (name.startsWith("O'")) cleanName = name.replace("O'", "O");
const parts = cleanName.split(' ');
if (parts.length < 2) return cleanName;
const surname = parts.pop();
if (["Jr.", "Jr", "III", "II", "Sr.", "Sr"].includes(surname)) {
const actualSurname = parts.pop();
return `${actualSurname}, ${parts.join(' ')} ${surname}`;
}
return `${surname}, ${parts.join(' ')}`;
}
async function performDeorphan() {
const api = new mw.Api();
try {
await api.edit(mw.config.get('wgPageName'), function(rev) {
let text = rev.content;
const summary = 'Article has backlinks; removed [[Template:Orphan|{{Orphan}}]]';
text = text.replace(/\{\{Orphan\s*(?:\|[^}]*)?\}\}\s*/gi, '');
text = text.replace(/(\{\{Multiple[ _]issues\s*)\|\s*\|/gi, '$1|');
text = replaceMI(text);
text = text.replace(/\n{3,}/g, '\n\n').trim();
return {
text,
summary,
minor: true
};
});
mw.notify('Orphan tag removed!', {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
setTimeout(() => location.reload(), 700);
} catch (e) {
mw.notify('Failed to remove orphan tag.', {
type: 'error'
});
}
}
async function performPromotion(newClass) {
const api = new mw.Api();
const pageName = mw.config.get('wgPageName');
const talkName = 'Talk:' + pageName;
try {
const talkData = await api.get({
action: 'query',
prop: 'revisions',
titles: talkName,
rvprop: 'content',
formatversion: 2
});
let talkWikitext = talkData.query.pages[0].revisions[0].content;
talkWikitext = talkWikitext.replace(/(\|class\s*=\s*)Stub/i, `$1${newClass}`);
await api.postWithEditToken({
action: 'edit',
title: talkName,
text: talkWikitext,
summary: `Promoting article to ${newClass} class per ORES prediction [[WP:ORES]]`
});
const artData = await api.get({
action: 'query',
prop: 'revisions',
titles: pageName,
rvprop: 'content',
formatversion: 2
});
let artWikitext = artData.query.pages[0].revisions[0].content;
const stubRegex = /\{\{[^}]*?-stub\}\}\s*\n?/gi;
const genericStubRegex = /\{\{stub\}\}\s*/gi;
artWikitext = artWikitext.replace(stubRegex, '').replace(genericStubRegex, '');
await api.postWithEditToken({
action: 'edit',
title: pageName,
text: artWikitext,
summary: `Removing stub tags; article promoted to ${newClass}`
});
location.reload();
} catch (e) {
alert("Promotion failed. Check console for details.");
console.error(e);
}
}
function replaceMI(text) {
const miRegex = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miRegex.exec(text)) !== null) {
const start = match.index;
result += text.slice(lastIndex, start);
let depth = 0;
let i = start;
while (i < text.length) {
if (text[i] === '{' && text[i + 1] === '{') {
depth++;
i += 2;
} else if (text[i] === '}' && text[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
const end = i;
const full = text.slice(start, end);
const pipeIdx = full.indexOf('|');
const inner = full.slice(pipeIdx + 1, full.length - 2).trim();
const topLevel = extractTopLevelTemplates(inner);
if (topLevel.length === 0) {
result += '';
} else if (topLevel.length === 1) {
result += topLevel[0].trim();
} else {
result += full;
}
lastIndex = end;
}
result += text.slice(lastIndex);
return result;
}
function extractTopLevelTemplates(inner) {
const templates = [];
let i = 0;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
let depth = 0;
const start = i;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
depth++;
i += 2;
} else if (inner[i] === '}' && inner[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
templates.push(inner.slice(start, i));
} else {
i++;
}
}
return templates;
}
async function performSortCorrection(newName, type) {
const api = new mw.Api();
const title = mw.config.get("wgPageName");
try {
const data = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2
});
let content = data.query.pages[0].revisions[0].content;
const dsRegex = /\{\{(?:DEFAULTSORT|Defaultsort):[^}]+\}\}/i;
const newTag = `{{DEFAULTSORT:${newName}}}`;
let actionDesc = type === "add" ? "added" : "corrected";
if (dsRegex.test(content)) {
content = content.replace(dsRegex, newTag);
} else {
const catRegex = /\[\[Category:/i;
if (catRegex.test(content)) {
content = content.replace(catRegex, `${newTag}\n[[Category:`);
} else {
content += `\n\n${newTag}`;
}
}
await api.postWithEditToken({
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName}`,
nocreate: true
});
mw.notify(`DEFAULTSORT ${actionDesc} to ${newName}!`, {
title: 'Comrade Success',
type: 'success'
});
setTimeout(() => location.reload(), 700);
} catch (err) {
mw.notify('Failed to save DEFAULTSORT.', {
type: 'error'
});
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
function renderSortNudge(target, reason) {
const $dsNudge = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Correct to: ${target}`).on('click', () => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
async function fetchWikidataNameLabels(entity) {
const familyIds = entity.claims?.P734?.map(c => c.mainsnak.datavalue.value.id) || [];
const givenIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
const allNameIds = [...new Set([...familyIds, ...givenIds])];
if (allNameIds.length === 0) return {
givens: [],
surnames: []
};
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: allNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
});
const nameData = await fetch(url).then(r => r.json());
return {
givens: givenIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean),
surnames: familyIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean)
};
}
function fixNameCasing(part, referenceName) {
return part.split(' ').map(word => {
const match = referenceName.match(new RegExp(`\\b${word}\\b`, 'i'));
if (match) return match[0];
return referenceName.split(' ').find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase())) || word;
}).join(' ');
}
function getLongNameFromWikitext(wikitext) {
const firstLine = wikitext.split('\n').find(l => l.includes("'''"));
if (!firstLine) return null;
const boldMatch = firstLine.match(/'''(.+?)'''/);
if (!boldMatch) return null;
return boldMatch[1].replace(/\s*([“"«《„‹「『'].+?[”"»》”›」』']|\([^)\n]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
}
async function init() {
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") return;
const api = new mw.Api();
const qid = mw.config.get("wgWikibaseItemId");
try {
const [pageData, backlinkData] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
api.get({
action: 'query',
list: 'backlinks',
blfilterredir: 'nonredirects',
bllimit: 50,
blnamespace: 0,
bltitle: mw.config.get("wgPageName"),
formatversion: 2
})
]);
const page = pageData.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const currentTitle = mw.config.get("wgTitle");
const cleanTitle = stripDiacritics(currentTitle);
const isOrphanTagged = /\{\{\s*(Orphan|Do-attempt|Lonely|Orp|Orphaned article)/i.test(wikitext) || /\| *orphan *=/i.test(wikitext);
const backLinkCount = backlinkData.query.backlinks.length;
if (isOrphanTagged && backLinkCount >= 1) {
const $container = $('<div>').addClass('comrade-container comrade-status');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text('Remove orphan tag').on('click', () => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) await checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
await checkDomainRedirect(qid, currentTitle);
let isHuman = false;
let entity = null;
if (qid) {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
});
const wikiData = await fetch(url).then(r => r.json());
entity = wikiData.entities[qid];
isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
}
await checkNamingPrecision(currentTitle, isHuman);
if (qid && entity) {
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const nameParts = tClean.split(' ');
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
const isOrphan = isOrphanTagged && backLinkCount < 1;
const primarySurname = hasFamilyNameHatnote ? nameParts[0] : (getSmartSortKey(tClean, entity).split(',')[0] || nameParts[nameParts.length - 1]);
let targetSortName = "";
if (isHuman) {
const {
givens,
surnames
} = await fetchWikidataNameLabels(entity);
const refName = entity.labels?.en?.value || currentTitle;
if (dsMatch) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixNameCasing(lastPart, refName)}, ${fixNameCasing(firstPart, refName)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field.");
} else if (givens.length > 0 && lastPart.split(' ').length > 1) {
const misplacedGiven = lastPart.split(' ').find(part => givens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastPart.split(' ').filter(p => p !== misplacedGiven).join(' ');
targetSortName = `${fixNameCasing(newSurname, refName)}, ${fixNameCasing(misplacedGiven + ' ' + firstPart, refName)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
}
}
} else {
targetSortName = hasFamilyNameHatnote ? `${nameParts[0]}, ${nameParts.slice(1).join(' ')}` : getSmartSortKey(tClean, entity);
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).on('click', () => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
const longName = getLongNameFromWikitext(wikitext);
if (longName && longName.length > tClean.length) {
let sortKey = hasFamilyNameHatnote ? `${longName.split(' ')[0]}, ${longName.split(' ').slice(1).join(' ')}` : getSmartSortKey(longName, entity);
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
await checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
if (targetSortName) {
let targetPage = currentTitle.includes(' (') ? currentTitle.split(' (')[0] : currentTitle;
let rCat = targetPage !== currentTitle ? "R from ambiguous sort name" : "R from sort name";
if (targetSortName.includes(',')) {
const p = targetSortName.split(',');
rCat += `|${p[0].trim().charAt(0).toUpperCase()}|${p[1].trim().charAt(0).toUpperCase()}`;
}
await checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name");
}
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
surnames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
givens.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) finalGivenNames.add(gn);
});
if (finalFamilyNames.size === 0 && nameParts.length >= 2) finalFamilyNames.add(nameParts[nameParts.length - 1]);
if (finalGivenNames.size === 0 && nameParts.length >= 2) finalGivenNames.add(nameParts[0]);
const getPriorityTarget = async (name, type) => {
const titles = type === 'surname' ? [`List of people with surname ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`] : [`List of people with given name ${name}`, `${name} (given name)`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
return titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
};
for (const name of finalGivenNames) {
if (name.toLowerCase() === primarySurname.toLowerCase()) continue;
const target = await getPriorityTarget(name, 'given');
if (target) await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
for (const name of finalFamilyNames) {
const titles = [name, `${name} (surname)`, `List of people with surname ${name}`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
const target = titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
if (target) await checkNameList(name, 'surname', currentTitle, hasShortDesc, isOrphan, target);
}
}
const oresWait = setInterval(() => {
if ($('.article_quality').length) {
clearInterval(oresWait);
checkQualityConsistency();
}
}, 500);
setTimeout(() => clearInterval(oresWait), 5000);
}
} catch (err) {
console.error("Comrade error:", err);
}
}
init();
})();
(function() {
if (window.IllWill) return;
const STRIKEOUT = true;
const HIDE = 'hide';
const IllWill = {
Version: "1.1.1",
Attribution: 'created by <a target="_blank" href="/wiki/User:Cobaltcigs">cobaltcigs</a>',
Summary: "Added interlanguage links using IllWill.js",
DataApi: "https://www.wikidata.org/w/api.php",
LocalLang: mw.config.get("wgPageContentLanguage"),
Disambiguator: / \(.*\)$/,
SiteLinks: {},
Config: {
MaxLanguages: 5,
IgnoreScientific: STRIKEOUT,
AutoDiff: true
},
css: `
#illwill { clear: both; margin-bottom: 1.5em; border: 1px solid #a2a9b1; padding: 15px; background: #f8f9fa; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.ill-sitelinks a { display: inline-block; margin-bottom: 3px; font-size: 0.9em; color: #36c; }
.ill-size-tag { font-size: 0.8em; color: #72777d; margin-left: 6px; white-space: nowrap; }
.illwill-ignore { text-decoration: line-through; color: #72777d; font-size: 0.85em; }
.ill-x { width: 35px; text-align: center; }
.ill-input, .ill-output { font-family: 'Courier New', monospace; white-space: pre-wrap; font-size: 0.9em; background: #fff; border: 1px solid #eaecf0; padding: 4px; border-radius: 2px; }
#ill-buttons { text-align: right; padding: 10px; border-top: 1px solid #a2a9b1; margin-top: 10px; }
#ill-buttons button { margin-left: 10px; cursor: pointer; padding: 6px 16px; border-radius: 2px; border: 1px solid #a2a9b1; transition: all 0.2s; }
#ill-ok { background: #36c; color: #fff; border-color: #36c !important; font-weight: bold; }
#ill-ok:hover { background: #447ff5; }
#ill-ok:disabled { background: #eaecf0; color: #72777d; border-color: #c8ccd1 !important; cursor: not-allowed; opacity: 0.7; }
#ill-table { width: 100%; border-collapse: collapse; margin-top: 10px; background: white; }
#ill-table th { background: #f2f2f2; text-align: left; padding: 8px; border: 1px solid #a2a9b1; }
#ill-table td { padding: 8px; border: 1px solid #a2a9b1; vertical-align: top; }
#ill-table caption { background: #eaffea; font-weight: bold; padding: 10px; border: 1px solid #a2a9b1; border-bottom: none; font-size: 1.1em; }
#ill-help { font-size: 0.85em; color: #54595d; padding: 10px; line-height: 1.4; }
.ill-loading-inline { font-style: italic; color: #72777d; font-size: 0.9em; display: block; margin: 5px 0; }
`,
setup() {
if (!["edit", "submit"].includes(mw.config.get('wgAction'))) return;
mw.loader.addStyleTag(this.css);
const link = mw.util.addPortletLink('p-cactions', '#', 'IllWill', 'ca-illwill', "Add interlanguage links via Wikidata");
if (link) {
link.addEventListener('click', (e) => {
e.preventDefault();
this.makePanel();
});
}
},
norm(s) {
if (!s) return "";
const t = s.trim().replace(/[\s_]+/g, " ");
return t.charAt(0).toUpperCase() + t.slice(1);
},
async getRedLinks($textbox) {
const txt = $textbox.textSelection('getContents') || "";
const wl = txt.match(/\[\[[^[\]\n]+\]\]/g);
if (!wl) return {
reds: [],
txt
};
const api = new mw.Api();
const textToParse = wl.join("").replace(/\|[^\]]+/g, "");
try {
const data = await api.post({
action: 'parse',
text: textToParse,
contentmodel: 'wikitext',
formatversion: 2
});
const tempDiv = document.createElement('div');
tempDiv.innerHTML = data.parse.text;
const reds = Array.from(tempDiv.querySelectorAll("a.new")).map(x => {
const urlParams = new URLSearchParams(x.href.split('?')[1]);
return urlParams.get('title')?.replace(/_/g, " ");
}).filter(Boolean);
return {
reds: [...new Set(reds)],
txt
};
} catch (e) {
return {
reds: [],
txt
};
}
},
async makePanel() {
const $editform = $('#editform');
const $textbox = $('#wpTextbox1');
$('#illwill').remove();
$editform.hide();
const $wrapper = $('<div id="illwill">Loading redlinks...</div>').insertBefore($editform);
const {
reds,
txt
} = await this.getRedLinks($textbox);
const wtLinks = [];
reds.forEach(title => {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s/g, "[\\s_]+");
const re = new RegExp("\\[\\[\\s*" + escaped + "\\s*(?:\\|[^\\[\\]]+)?\\]\\]", "ig");
const match = txt.match(re);
if (match && !wtLinks.some(z => z.title === title)) {
wtLinks.push({
link: match[0],
title
});
}
});
if (!wtLinks.length) {
const $back = $('<button>Quit</button>').on('click', () => {
$wrapper.remove();
$editform.show();
});
$wrapper.empty().append($('<strong>No valid redlinks found in text.</strong> '), $back);
return;
}
const $table = $(`
<table id="ill-table" class="wikitable">
<caption>IllWill.js (v${this.Version})</caption>
<thead>
<tr>
<th class="ill-x"><input type="checkbox" id="ill-select-all" disabled></th>
<th>Input link</th>
<th>Possible Wikidata matches</th>
<th>Sitelinks (top ${this.Config.MaxLanguages} by size)</th>
<th>Resulting wikitext</th>
</tr>
</thead>
<tbody></tbody>
<tfoot>
<tr>
<td colspan="3" id="ill-help">Carefully examine possible Wikidata matches.</td>
<td colspan="2" id="ill-buttons">
<button id="ill-cancel">Cancel</button>
<button id="ill-ok" disabled>Show changes</button>
</td>
</tr>
</tfoot>
</table>
`);
const $tbody = $table.find('tbody');
wtLinks.forEach((item, i) => {
$tbody.append(`
<tr id="ill-row-${i}" data-index="${i}">
<td class="ill-x"><input type="checkbox" class="ill-row-check" disabled></td>
<td class="ill-input">${this.escapeHTML(item.link)}</td>
<td class="ill-wd-item"><span class="ill-loading-inline">Searching...</span></td>
<td class="ill-sitelinks"></td>
<td class="ill-output"></td>
</tr>
`);
});
$wrapper.empty().append($table);
const chunkSize = 5;
for (let i = 0; i < wtLinks.length; i += chunkSize) {
const chunk = wtLinks.slice(i, i + chunkSize);
await Promise.all(chunk.map((item, idx) => this.fetchWikidataOptimized(item.title, i + idx)));
}
$table.on('click', '#ill-cancel', () => {
$editform.show();
$wrapper.remove();
});
$table.on('click', '#ill-ok', () => this.onOkButton($editform, $wrapper, $textbox));
$table.on('change', '#ill-select-all', (e) => {
const isChecked = $(e.target).is(':checked');
$table.find('.ill-row-check:not(:disabled)').each(function() {
$(this).prop('checked', isChecked).trigger('change');
});
});
$table.on('change', '.ill-row-check', (e) => {
this.onCheckbox(e);
this.updateOkButtonState($table);
});
$table.on('change', 'input[type="radio"]', (e) => {
this.showSiteLinks(e);
});
},
async fetchWikidataOptimized(title, index) {
const query = title.replace(this.Disambiguator, "");
const url = `${this.DataApi}?action=wbsearchentities&search=${encodeURIComponent(query)}&language=${this.LocalLang}&limit=5&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
const results = data.search;
const $cell = $(`#ill-row-${index} .ill-wd-item`);
if (!results || !results.length) {
$cell.html('<span class="ill-na">(no results)</span>');
return;
}
const entityIds = results.map(r => r.id);
await this.fetchEntities(entityIds, index, $cell);
} catch (e) {
$(`#ill-row-${index} .ill-wd-item`).text("Search failed.");
}
},
async fetchEntities(ids, rowIndex, $cell) {
const url = `${this.DataApi}?action=wbgetentities&ids=${ids.join('|')}&props=labels|descriptions|claims|sitelinks&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
$cell.empty();
ids.forEach(qNum => {
const entity = data.entities[qNum];
if (!entity) return;
const isScientific = entity.claims?.P31?.some(s => s.mainsnak?.datavalue?.value?.id === "Q13442814");
if (isScientific && this.Config.IgnoreScientific === HIDE) return;
const desc = entity.descriptions?.[this.LocalLang]?.value || entity.descriptions?.en?.value || "";
const label = entity.labels?.[this.LocalLang]?.value || entity.labels?.en?.value || qNum;
const isStrikeout = isScientific && this.Config.IgnoreScientific === STRIKEOUT;
const $div = $(`
<div class="${isStrikeout ? 'illwill-ignore' : ''}">
<input type="radio" name="selection-${rowIndex}" value="${qNum}" id="radio-${qNum}" ${isStrikeout ? 'disabled' : ''}>
<label for="radio-${qNum}">
<a href="https://wikidata.org/wiki/${qNum}" target="_blank">${this.escapeHTML(label)}</a>:
<span class="ill-desc">${desc ? this.escapeHTML(desc) : '<i>no description</i>'}</span>
</label>
</div>
`);
$cell.append($div);
this.SiteLinks[qNum] = entity.sitelinks || {};
});
} catch (e) {
$cell.text("Detail fetch failed.");
}
},
updateOkButtonState($table) {
const $rows = $table.find('.ill-row-check:not(:disabled)');
const $checkedRows = $rows.filter(':checked');
const anyEnabled = $rows.length > 0;
$table.find('#ill-ok').prop('disabled', $checkedRows.length === 0);
const $master = $table.find('#ill-select-all');
$master.prop('disabled', !anyEnabled);
if (anyEnabled && $rows.length === $checkedRows.length) {
$master.prop('checked', true);
} else {
$master.prop('checked', false);
}
},
async showSiteLinks(e) {
const isManualToggling = e.fromCheckbox || false;
const qNum = e.target.value;
const $row = $(e.target).closest('tr');
const sitelinks = this.SiteLinks[qNum];
const $checkbox = $row.find('.ill-row-check');
const $sitelinkCell = $row.find('.ill-sitelinks');
const $outputCell = $row.find('.ill-output');
const $table = $('#ill-table');
const effectiveMax = Math.min(Math.max(this.Config.MaxLanguages, 3), 6);
const validKeys = Object.keys(sitelinks).filter(k => k.endsWith('wiki') && !k.includes('commons'));
$sitelinkCell.html('<span class="ill-loading-inline">Measuring articles...</span>');
$outputCell.empty();
if (!isManualToggling) {
$checkbox.prop('disabled', true).prop('checked', false);
}
const candidates = validKeys.slice(0, 25);
const sizeMap = [];
await Promise.all(candidates.map(async (key) => {
const lang = key.replace('wiki', '').replace(/_/g, '-');
const title = sitelinks[key].title;
const endpoint = `https://${lang}.wikipedia.org/w/api.php?action=query&titles=${encodeURIComponent(title)}&prop=info&format=json&origin=*`;
try {
const res = await fetch(endpoint).then(r => r.json());
const page = Object.values(res.query.pages)[0];
sizeMap.push({
lang,
title,
length: page.length || 0
});
} catch (err) {
sizeMap.push({
lang,
title,
length: 0
});
}
}));
sizeMap.sort((a, b) => b.length - a.length);
const topLinks = sizeMap.slice(0, effectiveMax);
$sitelinkCell.html(topLinks.map(item => {
const kbValue = Math.round(item.length / 1024);
return `<div><a href="https://${item.lang}.wikipedia.org/wiki/${encodeURIComponent(item.title)}" target="_blank">${item.lang}:${this.escapeHTML(item.title)}</a> <span class="ill-size-tag">(${kbValue} kB)</span></div>`;
}).join(""));
const inputWikitext = $row.find('.ill-input').text();
const match = inputWikitext.match(/\[\[(.*?)\]\]/);
if (match) {
const parts = match[1].split('|');
const targetPage = parts[0].trim();
const surfaceName = (parts[1] || parts[0]).trim();
const illLangs = topLinks.map(item => {
const isSameTitle = this.norm(item.title) === this.norm(targetPage);
return `|${item.lang}|${isSameTitle ? '' : item.title}`;
}).join("");
const ltParam = (this.norm(surfaceName) === this.norm(targetPage)) ? "" : `|lt=${surfaceName}`;
$outputCell.text(`{{ill|${targetPage}${ltParam}${illLangs}}}`);
}
$checkbox.prop('disabled', !topLinks.length);
if (!isManualToggling) {
$checkbox.prop('checked', !!topLinks.length);
}
this.updateOkButtonState($table);
},
onCheckbox(e) {
const $row = $(e.target).closest('tr');
const $radio = $row.find('input[type="radio"]:checked');
const isChecked = $(e.target).is(':checked');
if (isChecked) {
if ($radio.length) {
this.showSiteLinks({
target: $radio[0],
fromCheckbox: true
});
}
} else {
$row.find('.ill-sitelinks, .ill-output').empty();
}
},
onOkButton($editform, $wrapper, $textbox) {
let text = $textbox.textSelection('getContents');
let changed = false;
$wrapper.find('.ill-row-check:checked').each(function() {
const $row = $(this).closest('tr');
const input = $row.find('.ill-input').text();
const output = $row.find('.ill-output').text();
if (output) {
const escapedInput = input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
text = text.replace(new RegExp(escapedInput, "gi"), output);
changed = true;
}
});
if (changed) {
$('#wpSummary').val((i, v) => (v ? v + "; " : "") + this.Summary);
$textbox.textSelection('setContents', text);
$wrapper.remove();
$editform.show();
if (this.Config.AutoDiff) $('#wpDiff').click();
}
},
escapeHTML(str) {
return str.replace(/[&<>"']/g, m => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
})[m]);
}
};
window.IllWill = IllWill;
mw.loader.using(['mediawiki.util', 'mediawiki.api', 'jquery.textSelection'], () => {
$(() => IllWill.setup());
});
})();
(function() {
'use strict';
const config = {
enableNS14: true,
rootWhitelists: ['Materials'],
userWhitelist: ""
};
const categoryDataCache = {};
let scanProgress = 0;
let totalToScan = 0;
async function verifyTreeMembership() {
if (!config.enableNS14) return false;
const api = new mw.Api();
const currentTitle = mw.config.get("wgPageName").replace(/_/g, ' ');
const fullRoots = config.rootWhitelists.concat(config.userWhitelist.split(',').map(s => s.trim()).filter(s => s)).map(cat => (cat.startsWith('Category:') ? cat : `Category:${cat}`));
if (fullRoots.includes(currentTitle)) return true;
let categoriesToTable = mw.config.get('wgCategories') || [];
let checked = new Set();
while (categoriesToTable.length > 0) {
if (categoriesToTable.some(cat => fullRoots.includes('Category:' + cat))) {
return true;
}
categoriesToTable.forEach(cat => checked.add(cat));
try {
const res = await api.get({
formatversion: 2,
prop: 'categories',
titles: categoriesToTable.map(c => 'Category:' + c),
cllimit: 'max'
});
let nextBatch = [];
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.categories) {
page.categories.forEach(parent => {
const parentName = parent.title.substring('Category:'.length);
if (!checked.has(parentName)) {
nextBatch.push(parentName);
}
});
}
});
}
categoriesToTable = [...new Set(nextBatch)];
} catch (e) {
return false;
}
}
return false;
}
function getChemboxBlock(wikitext) {
const chemboxRegex = /\{\{\s*Chembox[\s_]*(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const match = wikitext.match(chemboxRegex);
return match ? match[0] : null;
}
function getFormulaFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const formulaMatch = chembox.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
let rawFormula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
rawFormula = rawFormula.split(/\s+/)[0];
return rawFormula.split(',')[0].trim();
}
const propRegex = /\{\{\s*Chembox[\s_]+Properties(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const propSection = chembox.match(propRegex);
if (propSection) {
const content = propSection[0];
const allElements = ['Ac', 'Ag', 'Al', 'Am', 'Ar', 'As', 'At', 'Au', 'B', 'Ba', 'Be', 'Bh', 'Bi', 'Bk', 'Br', 'C', 'Ca', 'Cd', 'Ce', 'Cf', 'Cl', 'Cm', 'Cn', 'Co', 'Cr', 'Cs', 'Cu', 'D', 'Db', 'Ds', 'Dy', 'Er', 'Es', 'Eu', 'F', 'Fe', 'Fl', 'Fm', 'Fr', 'Ga', 'Gd', 'Ge', 'H', 'He', 'Hf', 'Hg', 'Ho', 'Hs', 'I', 'In', 'Ir', 'K', 'Kr', 'La', 'Li', 'Lr', 'Lu', 'Lv', 'Mc', 'Md', 'Mg', 'Mn', 'Mo', 'Mt', 'N', 'Na', 'Nb', 'Nd', 'Ne', 'Nh', 'Ni', 'No', 'Np', 'O', 'Og', 'Os', 'P', 'Pa', 'Pb', 'Pd', 'Pm', 'Po', 'Pr', 'Pt', 'Pu', 'Ra', 'Rb', 'Re', 'Rf', 'Rg', 'Rh', 'Rn', 'Ru', 'S', 'Sb', 'Sc', 'Se', 'Sg', 'Si', 'Sm', 'Sn', 'Sr', 'Ta', 'Tb', 'Tc', 'Te', 'Th', 'Ti', 'Tl', 'Tm', 'Ts', 'U', 'V', 'W', 'Xe', 'Y', 'Yb', 'Zn', 'Zr'];
let data = {};
allElements.forEach(el => {
const elRegex = new RegExp(`\\|\\s*${el}\\s*=\\s*(\\d*\\.?\\d+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch && elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
});
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
elementsPresent.filter(el => !(hasC && (el === "C" || el === "H"))).sort().forEach(el => {
formulaArray.push(el + data[el]);
});
return formulaArray.join('');
}
}
return null;
}
function getCASFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const idRegex = /\{\{\s*Chembox[\s_]+Identifiers(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const idSection = chembox.match(idRegex);
if (!idSection) return null;
const content = idSection[0];
const nbsp = String.fromCharCode(160);
const casMatch = content.match(/\|\s*CASNo\d*\b(?!\s*_)\s*=\s*([^|}\n]+)/i);
if (casMatch) {
let val = casMatch[1].replace(/\{\{[^}]+\}\}/g, "").replace(/\u00AD/g, "").split(" ").join("");
while (val.indexOf(nbsp) !== -1) val = val.replace(nbsp, "");
while (val.indexOf(" ") !== -1) val = val.replace(" ", "");
const cleanedCas = val.trim();
return /^\d{2,7}-\d{2}-\d$/.test(cleanedCas) ? cleanedCas : null;
}
return null;
}
async function getWikidataValue() {
const qid = mw.config.get('wgWikidataItemId');
if (!qid) return null;
const api = new mw.ForeignApi('https://www.wikidata.org/w/api.php');
const res = await api.get({
action: 'wbgetentities',
ids: qid,
props: 'claims',
format: 'json',
formatversion: 2
});
const claims = res.entities[qid].claims;
const data = {};
if (claims.P274) data.formula = claims.P274[0].mainsnak.datavalue.value;
if (claims.P231) data.cas = claims.P231[0].mainsnak.datavalue.value;
return data;
}
async function startBackgroundCategoryScan() {
const api = new mw.Api();
const titles = $('#mw-pages li a').map((i, el) => $(el).attr('title') || $(el).text()).get();
totalToScan = titles.length;
if (totalToScan === 0) return;
const chunks = arrayChunk(titles, 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: chunk,
rvprop: 'content',
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const f = getFormulaFromWikitext(wikitext);
const c = getCASFromWikitext(wikitext);
if (f || c) categoryDataCache[page.title] = {
formula: f,
cas: c
};
});
}
scanProgress += chunk.length;
$('#caschemassist-cat-btn').text(`Scanning... ${scanProgress}/${totalToScan}`);
}
$('#caschemassist-cat-btn').text('Show missing CAS/Chem').prop('disabled', false);
}
async function renderCategoryAudit() {
const api = new mw.Api();
const missingCheckTitles = [];
Object.values(categoryDataCache).forEach(data => {
if (data.formula) missingCheckTitles.push(data.formula);
if (data.cas) missingCheckTitles.push(data.cas);
});
if (missingCheckTitles.length === 0) {
mw.notify("No valid Chembox data found in this category.");
return;
}
const existenceMap = {};
const chunks = arrayChunk([...new Set(missingCheckTitles)], 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
titles: chunk,
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(p => {
existenceMap[p.title] = !p.missing;
});
}
}
$('#mw-pages li').each(function() {
const title = $(this).find('a').attr('title') || $(this).find('a').text().trim();
const data = categoryDataCache[title];
if (!data) return;
if (data.formula && !existenceMap[data.formula]) {
$(this).append($('<span>').css({
'color': 'red',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [${data.formula}]`));
}
if (data.cas && !existenceMap[data.cas]) {
$(this).append($('<span>').css({
'color': 'darkred',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [CAS: ${data.cas}]`));
}
});
$('#caschemassist-cat-btn').text('Audit complete').prop('disabled', true);
}
async function handleSingleArticle() {
const api = new mw.Api();
const currentTitle = mw.config.get("wgTitle");
const [wikiRes, wikidata] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
getWikidataValue()
]);
const page = wikiRes.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const formula = getFormulaFromWikitext(wikitext);
const cas = getCASFromWikitext(wikitext);
if (wikidata) {
let mismatches = [];
if (formula && wikidata.formula && formula !== wikidata.formula) {
mismatches.push(`Formula mismatch: Chembox "${formula}" vs Wikidata "${wikidata.formula}"`);
}
if (cas && wikidata.cas && cas !== wikidata.cas) {
mismatches.push(`CAS mismatch: Chembox "${cas}" vs Wikidata "${wikidata.cas}"`);
}
if (mismatches.length > 0) {
const $warning = $('<div>').css({
'background': '#fff2f2',
'border': '2px solid #d33',
'padding': '10px',
'margin-bottom': '1em',
'color': '#b32424'
}).append($('<strong>').text('⚠️ Data Mismatch Detected: '), mismatches.join(' | '));
$('#mw-content-text').prepend($warning);
}
}
async function checkAndRender(target, label, customWikitext) {
const check = await api.get({
action: 'query',
titles: target,
formatversion: 2
});
if (check.query.pages[0].missing) {
const $container = $('<div>').css({
'background': '#f8f9fa',
'border': '1px solid #a2a9b1',
'padding': '10px',
'margin-bottom': '1em'
});
const $btn = $('<button>').text(`Create redirect: ${target}`).click(async function() {
await api.postWithEditToken({
action: 'edit',
title: target,
text: customWikitext,
summary: `Creating redirect to [[${currentTitle}]] (${label})`,
createonly: true
});
mw.notify(`Created redirect: ${target}`);
$container.fadeOut(400, function() {
$(this).remove();
});
});
$container.append($('<strong>').text(`${label}: `), `Missing redirect. `, $btn);
$('#mw-content-text').prepend($container);
}
}
if (formula && formula !== currentTitle) {
const formulaWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from chemical formula}}\n}}`;
await checkAndRender(formula, "Chemical formula", formulaWikitext);
}
if (cas && cas !== currentTitle) {
const casWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from CAS Registry Number}}\n{{R unprintworthy}}\n}}`;
await checkAndRender(cas, "CAS number", casWikitext);
}
}
function arrayChunk(arr, size) {
const result = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
$(document).ready(async function() {
const ns = mw.config.get("wgNamespaceNumber");
if (ns === 14) {
if (await verifyTreeMembership()) {
const $catContainer = $('#mw-pages');
if ($catContainer.length) {
const $btn = $('<button>').text('Initializing scan...').attr('id', 'caschemassist-cat-btn').addClass('mw-ui-button').css({
'margin': '0 10px 10px 0',
'float': 'right',
'position': 'relative',
'z-index': '100'
}).prop('disabled', true).click(renderCategoryAudit);
$catContainer.prepend($btn);
startBackgroundCategoryScan();
}
}
} else if (ns === 0 && mw.config.get("wgAction") === "view") {
handleSingleArticle();
}
});
})();
//</nowiki>
n5pobrki0exkq0fj6gsabnk6c238bpb
739298
739297
2026-04-25T07:50:12Z
Sam Sailor
26820
Test
739298
javascript
text/javascript
//<nowiki>
(function() {
mw.util.addCSS(`
.ambox-Orphan { display: block !important; }
.comrade-container {
box-shadow: 0 1px 3px rgba(0,0,0,0.1); border-radius: 2px;
padding: 10px 15px; margin: 10px 0; display: flex;
flex-direction: column; align-items: flex-start; gap: 8px;
border: 1px solid #a2a9b1; background: #fcfcfc; line-height: 1.5;
}
.comrade-header { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.comrade-content { display: flex; flex-direction: column; gap: 5px; width: 100%; }
.comrade-success { background-color: #d5f5e3 !important; border-left: 5px solid #27ae60 !important; color: #1b5e20 !important; }
.comrade-nudge { background: #fff9ea; border-left: 5px solid #f0ad4e; }
.comrade-info { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-status { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-container button:not(.comrade-dismiss) {
background: #f8f9fa; border: 1px solid #a2a9b1; border-radius: 2px;
padding: 2px 10px; cursor: pointer; font-weight: bold; font-size: 0.95em; margin-left: 10px;
}
.comrade-container button:not(.comrade-dismiss):hover { border-color: #36c; color: #36c; background: #fff; }
.comrade-dismiss {
background: transparent; border: none; color: #d33; font-weight: bold;
cursor: pointer; font-size: 0.9em;
}
.comrade-dismiss:hover { text-decoration: underline; }
.comrade-code-block { background: #f1f1f1; padding: 4px 8px; border-radius: 3px; font-family: monospace; display: inline-block; margin-top: 4px; }
`);
function appendDismiss($el) {
$('<button>').addClass('comrade-dismiss').text('Dismiss').on('click', function() {
$(this).closest('.comrade-container').fadeOut();
}).appendTo($el.find('.comrade-header'));
$("#siteSub").after($el);
}
async function checkNamingPrecision(currentTitle, isHuman) {
const disambigMatch = currentTitle.match(/^(.+?)(?:, | \()/);
if (isHuman || !disambigMatch) return;
const baseTitle = disambigMatch[1].trim();
const api = new mw.Api();
try {
const baseRes = await api.get({
action: 'query',
titles: baseTitle,
redirects: 1,
formatversion: 2
});
const page = baseRes.query.pages[0];
const baseExists = !page.missing;
let baseRedirectsToSelf = false;
if (baseRes.query.redirects) {
baseRedirectsToSelf = baseRes.query.redirects.some(r => r.to === currentTitle);
}
const searchRes = await api.get({
action: 'query',
list: 'allpages',
apprefix: baseTitle,
aplimit: 50,
formatversion: 2
});
const competitors = searchRes.query.allpages.filter(p => {
const t = p.title;
return t !== currentTitle && t !== baseTitle && (t.startsWith(baseTitle + ', ') || t.startsWith(baseTitle + ' ('));
});
if (!baseExists || baseRedirectsToSelf) {
const $container = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (competitors.length === 0) {
$header.append($('<strong>').text('Precision:'));
const reason = baseRedirectsToSelf ? "already redirects here" : "is free";
$content.append($('<span>').append(`The base name `, $('<code>').text(baseTitle), ` ${reason}. Consider moving this article.`));
const moveUrl = mw.util.getUrl('Special:MovePage/' + currentTitle, {
wpNewTitle: baseTitle,
wpReason: 'Unnecessary disambiguation; base name is free/redirects here per [[WP:PRECISION]]'
});
$content.append($('<button>').text(`Move to ${baseTitle}`).on('click', () => window.open(moveUrl, '_blank')));
} else {
$header.append($('<strong>').text('Observation:'));
const state = baseRedirectsToSelf ? 'redirect to this page' : 'redlink';
$content.append($('<span>').append(`Base name `, $('<code>').text(baseTitle), ` has ${competitors.length} competitors, but it is currently a ${state}.`));
$content.append($('<span>').text('Consider if a disambiguation page is needed.'));
}
$container.append($header, $content);
appendDismiss($container);
}
} catch (e) {
console.warn("Comrade: Precision check failed", e);
}
}
async function checkAndRenderRedirect(redirTitle, targetTitle, rCategory, labelText, customWikitext) {
const api = new mw.Api();
const res = await api.get({
action: 'query',
titles: redirTitle,
formatversion: 2
});
if (res.query.pages[0].missing) {
const safeId = "comrade-redir-" + redirTitle.replace(/[^a-z0-9]/gi, '-');
const $container = $('<div>').addClass('comrade-container comrade-info').attr('id', safeId);
const $header = $('<div>').addClass('comrade-header');
const summary = `Created redirect from ${labelText.toLowerCase()} to [[${targetTitle}]]`;
const $btn = $('<button>').text('Create redirect').on('click', () => createModernRedirect(redirTitle, targetTitle, rCategory, summary, safeId, customWikitext));
$header.append($('<div>').append($('<strong>').text(`${labelText}: `), `Create redirect from `, $('<code>').text(redirTitle), $btn));
$container.append($header);
appendDismiss($container);
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
if (qid) {
try {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
});
const res = await fetch(url).then(r => r.json());
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
}
} catch (e) {
console.warn("Comrade: Wikidata API call failed.");
}
}
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
if (domain.includes(".") && url.pathname === "/") {
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
try {
const response = await fetch(citationURL);
if (response.ok) {
await checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
}
} catch (e) {
console.warn("Comrade: Citation API check failed.");
}
}
} catch (e) {
console.warn("Comrade: Error parsing URL object.");
}
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan, resolvedTitle) {
const api = new mw.Api();
const candidates = resolvedTitle ? [resolvedTitle] : [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
redirects: 0
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const foundTitle = page.title;
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) break;
const $container = $('<div>').addClass('comrade-container');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (isOrphan) {
$container.addClass('comrade-nudge');
$header.append($('<strong>').text('Comrade nudge:'));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
}
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(foundTitle),
target: '_blank'
}).text(foundTitle), "; should it be?"));
const snippet = '* {{anbl|' + currentTitle + '}}';
const $snippetWrapper = $('<div>').css({
'display': 'flex',
'align-items': 'center',
'gap': '10px',
'margin-top': '4px'
});
const $instructionSpan = $('<span>').append('If the Short description is good, consider adding ', $('<code>').text(snippet).css({
'background-color': '#f8f9fa',
'border': '1px solid #eaecf0',
'border-radius': '2px',
'padding': '1px 4px'
}));
const $copyBtn = $('<button>').text('Copy & insert').css('margin-left', '0').on('click', function() {
navigator.clipboard.writeText(snippet).then(() => {
const $this = $(this);
const originalText = $this.text();
$this.text('Copied!').prop('disabled', true);
const editUrl = mw.util.getUrl(foundTitle, {
action: 'edit',
summary: snippet
});
window.open(editUrl, '_blank');
setTimeout(() => {
$this.text(originalText).prop('disabled', false);
}, 1500);
});
});
$snippetWrapper.append($instructionSpan, $copyBtn);
$content.append($snippetWrapper);
if (!hasShortDesc) {
$content.append($('<span>').css({
'color': '#d33',
'font-weight': 'bold',
'margin-top': '5px'
}).text('⚠️ Missing short description: Please add a concise one before listing.'));
}
$container.append($header, $content);
appendDismiss($container);
break;
}
}
}
}
async function checkQualityConsistency() {
const oresClass = getOresPrediction();
if (!oresClass || oresClass === 'Stub') return;
const talkTitle = 'Talk:' + mw.config.get('wgPageName');
const api = new mw.Api();
try {
const [talkRes, pageRes] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: talkTitle,
rvprop: 'content',
formatversion: 2
}),
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
})
]);
const talkPage = talkRes.query.pages[0];
const wikitext = pageRes.query.pages[0].revisions[0].content;
const hasStubTag = /\{\{[^}]*stub\}\}/i.test(wikitext);
const $raterLink = $('#ca-rater, #t-rater');
const isRaterAvailable = (typeof window.rater !== 'undefined' || $raterLink.length > 0);
let talkClass = null;
if (!talkPage.missing) {
const classMatch = talkPage.revisions[0].content.match(/\|class\s*=\s*([^|}\s]+)/i);
talkClass = classMatch ? classMatch[1].charAt(0).toUpperCase() + classMatch[1].slice(1).toLowerCase() : null;
}
const isStubRated = talkClass === 'Stub';
const lingeringStubTag = (talkClass && talkClass !== 'Stub' && oresClass !== 'Stub' && hasStubTag);
if (talkPage.missing || isStubRated || lingeringStubTag) {
const $container = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>');
let message = `ORES suggests <b>${oresClass}</b>. `;
let $btn;
if (talkPage.missing) {
message = `Talk page missing; ORES suggests <b>${oresClass}</b>. `;
if (isRaterAvailable) {
$btn = $('<button>').text('Open Rater').on('click', () => $raterLink.find('a')[0].click());
} else {
$btn = $('<button>').text('Install Rater').on('click', () => window.open('https://en.wikipedia.org/wiki/User:Evad37/rater', '_blank'));
message += "Consider installing <i>Rater</i> for easy assessment.";
}
} else if (lingeringStubTag && !isStubRated) {
message = `Article is ${talkClass}-class, but stub tags remain. `;
$btn = $('<button>').text('Remove stub tags').on('click', () => performStubRemoval());
} else if (isStubRated) {
message = `Talk page says Stub, but ORES predicts <b>${oresClass}</b>. `;
const $btnOres = $('<button>').text(`Promote to ${oresClass}`).on('click', () => performPromotion(oresClass));
if (oresClass === 'C') {
const $btnStart = $('<button>').text(`Promote to Start`).on('click', () => performPromotion('Start'));
$header.append($content.append($('<strong>').text('Quality:'), " ", message, $btnOres, " or ", $btnStart));
} else if (oresClass === 'B') {
const $btnC = $('<button>').text(`Promote to C`).on('click', () => performPromotion('C'));
$header.append($content.append($('<strong>').text('Quality:'), " ", message, $btnOres, " or ", $btnC));
} else {
$header.append($content.append($('<strong>').text('Quality:'), " ", message, $btnOres));
}
}
$header.append($content.append($('<strong>').text('Quality:'), " ", message, $btn));
$container.append($header);
appendDismiss($container);
}
} catch (e) {
console.error("Comrade quality check failed", e);
}
}
async function performStubRemoval() {
const api = new mw.Api();
try {
const pageData = await api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
});
let wikitext = pageData.query.pages[0].revisions[0].content;
const newWikitext = wikitext.replace(/\{\{[^}]*stub\}\}\n?/gi, '').trim();
if (wikitext === newWikitext) {
mw.notify("No stub tags found to remove.");
return;
}
await api.postWithToken('csrf', {
action: 'edit',
title: mw.config.get("wgPageName"),
text: newWikitext,
summary: `Removing lingering stub tags per ORES/Talk assessment`,
nocreate: true
});
location.reload();
} catch (err) {
console.error("Comrade failed to remove stub tags:", err);
mw.notify("Error removing stub tags.", {
type: 'error'
});
}
}
async function createModernRedirect(redirTitle, targetTitle, rCategory, customSummary, elementId, customWikitext) {
const api = new mw.Api();
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
try {
await api.postWithEditToken({
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
});
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
} catch (e) {
mw.notify("Failed to create redirect.", {
type: 'error'
});
}
}
function getOresPrediction() {
// Dependency: [[m:User:EpochFail/ArticleQuality-system.js]] renders the prediction inside .article_quality
const text = $('.article_quality').text();
if (!text) return null;
if (text.includes('Start')) return 'Start';
if (text.includes('C')) return 'C';
if (text.includes('B')) return 'B';
return null;
}
function getSmartSortKey(fullName, entity) {
const countryIds = entity.claims?.P27?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const locationIds = entity.claims?.P17?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const placeIds = [...(entity.claims?.P19 || []), ...(entity.claims?.P119 || [])].map(c => c.mainsnak?.datavalue?.value?.id).filter(Boolean);
const allRelevantIds = [...countryIds, ...locationIds, ...placeIds];
const birthYear = parseInt(entity.claims?.P569?.[0]?.mainsnak?.datavalue?.value?.time?.match(/[+-](\d{4})/)?.[1]) || null;
if (fullName.startsWith("Saint ")) return fullName.replace("Saint ", "");
const arabicParticles = /\b(Abu|Abd|Abdel|Abdul|ben|bin|bint)\b/i;
if (fullName.match(arabicParticles)) {
if (birthYear && birthYear > 1900) {
const parts = fullName.split(' ');
const binIndex = parts.findIndex(p => p.toLowerCase() === 'bin' || p.toLowerCase() === 'ben');
if (binIndex > 0) return parts.slice(binIndex).join(' ') + ', ' + parts.slice(0, binIndex).join(' ');
} else {
return fullName;
}
}
const eastAsianQids = ["Q148", "Q184", "Q884", "Q424", "Q881", "Q17"];
if (allRelevantIds.some(id => eastAsianQids.includes(id))) {
if (allRelevantIds.includes("Q17") && birthYear && birthYear > 1885) return westernSort(fullName);
const parts = fullName.split(' ');
if (parts.length > 1) return `${parts[0]}, ${parts.slice(1).join(' ')}`;
}
return westernSort(fullName);
}
function westernSort(name) {
let cleanName = name;
if (name.startsWith("O'")) cleanName = name.replace("O'", "O");
const parts = cleanName.split(' ');
if (parts.length < 2) return cleanName;
const surname = parts.pop();
if (["Jr.", "Jr", "III", "II", "Sr.", "Sr"].includes(surname)) {
const actualSurname = parts.pop();
return `${actualSurname}, ${parts.join(' ')} ${surname}`;
}
return `${surname}, ${parts.join(' ')}`;
}
async function performDeorphan() {
const api = new mw.Api();
try {
await api.edit(mw.config.get('wgPageName'), function(rev) {
let text = rev.content;
const summary = 'Article has backlinks; removed [[Template:Orphan|{{Orphan}}]]';
text = text.replace(/\{\{Orphan\s*(?:\|[^}]*)?\}\}\s*/gi, '');
text = text.replace(/(\{\{Multiple[ _]issues\s*)\|\s*\|/gi, '$1|');
text = replaceMI(text);
text = text.replace(/\n{3,}/g, '\n\n').trim();
return {
text,
summary,
minor: true
};
});
mw.notify('Orphan tag removed!', {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
setTimeout(() => location.reload(), 700);
} catch (e) {
mw.notify('Failed to remove orphan tag.', {
type: 'error'
});
}
}
async function performPromotion(newClass) {
const api = new mw.Api();
const pageName = mw.config.get('wgPageName');
const talkName = 'Talk:' + pageName;
try {
const talkData = await api.get({
action: 'query',
prop: 'revisions',
titles: talkName,
rvprop: 'content',
formatversion: 2
});
let talkWikitext = talkData.query.pages[0].revisions[0].content;
talkWikitext = talkWikitext.replace(/(\|class\s*=\s*)Stub/i, `$1${newClass}`);
await api.postWithEditToken({
action: 'edit',
title: talkName,
text: talkWikitext,
summary: `Promoting article to ${newClass} class per ORES prediction [[WP:ORES]]`
});
const artData = await api.get({
action: 'query',
prop: 'revisions',
titles: pageName,
rvprop: 'content',
formatversion: 2
});
let artWikitext = artData.query.pages[0].revisions[0].content;
const stubRegex = /\{\{[^}]*?-stub\}\}\s*\n?/gi;
const genericStubRegex = /\{\{stub\}\}\s*/gi;
artWikitext = artWikitext.replace(stubRegex, '').replace(genericStubRegex, '');
await api.postWithEditToken({
action: 'edit',
title: pageName,
text: artWikitext,
summary: `Removing stub tags; article promoted to ${newClass}`
});
location.reload();
} catch (e) {
alert("Promotion failed. Check console for details.");
console.error(e);
}
}
function replaceMI(text) {
const miRegex = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miRegex.exec(text)) !== null) {
const start = match.index;
result += text.slice(lastIndex, start);
let depth = 0;
let i = start;
while (i < text.length) {
if (text[i] === '{' && text[i + 1] === '{') {
depth++;
i += 2;
} else if (text[i] === '}' && text[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
const end = i;
const full = text.slice(start, end);
const pipeIdx = full.indexOf('|');
const inner = full.slice(pipeIdx + 1, full.length - 2).trim();
const topLevel = extractTopLevelTemplates(inner);
if (topLevel.length === 0) {
result += '';
} else if (topLevel.length === 1) {
result += topLevel[0].trim();
} else {
result += full;
}
lastIndex = end;
}
result += text.slice(lastIndex);
return result;
}
function extractTopLevelTemplates(inner) {
const templates = [];
let i = 0;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
let depth = 0;
const start = i;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
depth++;
i += 2;
} else if (inner[i] === '}' && inner[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
templates.push(inner.slice(start, i));
} else {
i++;
}
}
return templates;
}
async function performSortCorrection(newName, type) {
const api = new mw.Api();
const title = mw.config.get("wgPageName");
try {
const data = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2
});
let content = data.query.pages[0].revisions[0].content;
const dsRegex = /\{\{(?:DEFAULTSORT|Defaultsort):[^}]+\}\}/i;
const newTag = `{{DEFAULTSORT:${newName}}}`;
let actionDesc = type === "add" ? "added" : "corrected";
if (dsRegex.test(content)) {
content = content.replace(dsRegex, newTag);
} else {
const catRegex = /\[\[Category:/i;
if (catRegex.test(content)) {
content = content.replace(catRegex, `${newTag}\n[[Category:`);
} else {
content += `\n\n${newTag}`;
}
}
await api.postWithEditToken({
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName}`,
nocreate: true
});
mw.notify(`DEFAULTSORT ${actionDesc} to ${newName}!`, {
title: 'Comrade Success',
type: 'success'
});
setTimeout(() => location.reload(), 700);
} catch (err) {
mw.notify('Failed to save DEFAULTSORT.', {
type: 'error'
});
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
function renderSortNudge(target, reason) {
const $dsNudge = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Correct to: ${target}`).on('click', () => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
async function fetchWikidataNameLabels(entity) {
const familyIds = entity.claims?.P734?.map(c => c.mainsnak.datavalue.value.id) || [];
const givenIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
const allNameIds = [...new Set([...familyIds, ...givenIds])];
if (allNameIds.length === 0) return {
givens: [],
surnames: []
};
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: allNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
});
const nameData = await fetch(url).then(r => r.json());
return {
givens: givenIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean),
surnames: familyIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean)
};
}
function fixNameCasing(part, referenceName) {
return part.split(' ').map(word => {
const match = referenceName.match(new RegExp(`\\b${word}\\b`, 'i'));
if (match) return match[0];
return referenceName.split(' ').find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase())) || word;
}).join(' ');
}
function getLongNameFromWikitext(wikitext) {
const firstLine = wikitext.split('\n').find(l => l.includes("'''"));
if (!firstLine) return null;
const boldMatch = firstLine.match(/'''(.+?)'''/);
if (!boldMatch) return null;
return boldMatch[1].replace(/\s*([“"«《„‹「『'].+?[”"»》”›」』']|\([^)\n]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
}
async function init() {
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") return;
const api = new mw.Api();
const qid = mw.config.get("wgWikibaseItemId");
try {
const [pageData, backlinkData] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
api.get({
action: 'query',
list: 'backlinks',
blfilterredir: 'nonredirects',
bllimit: 50,
blnamespace: 0,
bltitle: mw.config.get("wgPageName"),
formatversion: 2
})
]);
const page = pageData.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const currentTitle = mw.config.get("wgTitle");
const cleanTitle = stripDiacritics(currentTitle);
const isOrphanTagged = /\{\{\s*(Orphan|Do-attempt|Lonely|Orp|Orphaned article)/i.test(wikitext) || /\| *orphan *=/i.test(wikitext);
const backLinkCount = backlinkData.query.backlinks.length;
if (isOrphanTagged && backLinkCount >= 1) {
const $container = $('<div>').addClass('comrade-container comrade-status');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text('Remove orphan tag').on('click', () => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) await checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
await checkDomainRedirect(qid, currentTitle);
let isHuman = false;
let entity = null;
if (qid) {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
});
const wikiData = await fetch(url).then(r => r.json());
entity = wikiData.entities[qid];
isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
}
await checkNamingPrecision(currentTitle, isHuman);
if (qid && entity) {
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const nameParts = tClean.split(' ');
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
const isOrphan = isOrphanTagged && backLinkCount < 1;
const primarySurname = hasFamilyNameHatnote ? nameParts[0] : (getSmartSortKey(tClean, entity).split(',')[0] || nameParts[nameParts.length - 1]);
let targetSortName = "";
if (isHuman) {
const {
givens,
surnames
} = await fetchWikidataNameLabels(entity);
const refName = entity.labels?.en?.value || currentTitle;
if (dsMatch) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixNameCasing(lastPart, refName)}, ${fixNameCasing(firstPart, refName)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field.");
} else if (givens.length > 0 && lastPart.split(' ').length > 1) {
const misplacedGiven = lastPart.split(' ').find(part => givens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastPart.split(' ').filter(p => p !== misplacedGiven).join(' ');
targetSortName = `${fixNameCasing(newSurname, refName)}, ${fixNameCasing(misplacedGiven + ' ' + firstPart, refName)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
}
}
} else {
targetSortName = hasFamilyNameHatnote ? `${nameParts[0]}, ${nameParts.slice(1).join(' ')}` : getSmartSortKey(tClean, entity);
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).on('click', () => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
const longName = getLongNameFromWikitext(wikitext);
if (longName && longName.length > tClean.length) {
let sortKey = hasFamilyNameHatnote ? `${longName.split(' ')[0]}, ${longName.split(' ').slice(1).join(' ')}` : getSmartSortKey(longName, entity);
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
await checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
if (targetSortName) {
let targetPage = currentTitle.includes(' (') ? currentTitle.split(' (')[0] : currentTitle;
let rCat = targetPage !== currentTitle ? "R from ambiguous sort name" : "R from sort name";
if (targetSortName.includes(',')) {
const p = targetSortName.split(',');
rCat += `|${p[0].trim().charAt(0).toUpperCase()}|${p[1].trim().charAt(0).toUpperCase()}`;
}
await checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name");
}
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
surnames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
givens.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) finalGivenNames.add(gn);
});
if (finalFamilyNames.size === 0 && nameParts.length >= 2) finalFamilyNames.add(nameParts[nameParts.length - 1]);
if (finalGivenNames.size === 0 && nameParts.length >= 2) finalGivenNames.add(nameParts[0]);
const getPriorityTarget = async (name, type) => {
const titles = type === 'surname' ? [`List of people with surname ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`] : [`List of people with given name ${name}`, `${name} (given name)`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
return titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
};
for (const name of finalGivenNames) {
if (name.toLowerCase() === primarySurname.toLowerCase()) continue;
const target = await getPriorityTarget(name, 'given');
if (target) await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
for (const name of finalFamilyNames) {
const titles = [name, `${name} (surname)`, `List of people with surname ${name}`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
const target = titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
if (target) await checkNameList(name, 'surname', currentTitle, hasShortDesc, isOrphan, target);
}
}
const oresWait = setInterval(() => {
if ($('.article_quality').length) {
clearInterval(oresWait);
checkQualityConsistency();
}
}, 500);
setTimeout(() => clearInterval(oresWait), 5000);
}
} catch (err) {
console.error("Comrade error:", err);
}
}
init();
})();
(function() {
if (window.IllWill) return;
const STRIKEOUT = true;
const HIDE = 'hide';
const IllWill = {
Version: "1.1.1",
Attribution: 'created by <a target="_blank" href="/wiki/User:Cobaltcigs">cobaltcigs</a>',
Summary: "Added interlanguage links using IllWill.js",
DataApi: "https://www.wikidata.org/w/api.php",
LocalLang: mw.config.get("wgPageContentLanguage"),
Disambiguator: / \(.*\)$/,
SiteLinks: {},
Config: {
MaxLanguages: 5,
IgnoreScientific: STRIKEOUT,
AutoDiff: true
},
css: `
#illwill { clear: both; margin-bottom: 1.5em; border: 1px solid #a2a9b1; padding: 15px; background: #f8f9fa; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.ill-sitelinks a { display: inline-block; margin-bottom: 3px; font-size: 0.9em; color: #36c; }
.ill-size-tag { font-size: 0.8em; color: #72777d; margin-left: 6px; white-space: nowrap; }
.illwill-ignore { text-decoration: line-through; color: #72777d; font-size: 0.85em; }
.ill-x { width: 35px; text-align: center; }
.ill-input, .ill-output { font-family: 'Courier New', monospace; white-space: pre-wrap; font-size: 0.9em; background: #fff; border: 1px solid #eaecf0; padding: 4px; border-radius: 2px; }
#ill-buttons { text-align: right; padding: 10px; border-top: 1px solid #a2a9b1; margin-top: 10px; }
#ill-buttons button { margin-left: 10px; cursor: pointer; padding: 6px 16px; border-radius: 2px; border: 1px solid #a2a9b1; transition: all 0.2s; }
#ill-ok { background: #36c; color: #fff; border-color: #36c !important; font-weight: bold; }
#ill-ok:hover { background: #447ff5; }
#ill-ok:disabled { background: #eaecf0; color: #72777d; border-color: #c8ccd1 !important; cursor: not-allowed; opacity: 0.7; }
#ill-table { width: 100%; border-collapse: collapse; margin-top: 10px; background: white; }
#ill-table th { background: #f2f2f2; text-align: left; padding: 8px; border: 1px solid #a2a9b1; }
#ill-table td { padding: 8px; border: 1px solid #a2a9b1; vertical-align: top; }
#ill-table caption { background: #eaffea; font-weight: bold; padding: 10px; border: 1px solid #a2a9b1; border-bottom: none; font-size: 1.1em; }
#ill-help { font-size: 0.85em; color: #54595d; padding: 10px; line-height: 1.4; }
.ill-loading-inline { font-style: italic; color: #72777d; font-size: 0.9em; display: block; margin: 5px 0; }
`,
setup() {
if (!["edit", "submit"].includes(mw.config.get('wgAction'))) return;
mw.loader.addStyleTag(this.css);
const link = mw.util.addPortletLink('p-cactions', '#', 'IllWill', 'ca-illwill', "Add interlanguage links via Wikidata");
if (link) {
link.addEventListener('click', (e) => {
e.preventDefault();
this.makePanel();
});
}
},
norm(s) {
if (!s) return "";
const t = s.trim().replace(/[\s_]+/g, " ");
return t.charAt(0).toUpperCase() + t.slice(1);
},
async getRedLinks($textbox) {
const txt = $textbox.textSelection('getContents') || "";
const wl = txt.match(/\[\[[^[\]\n]+\]\]/g);
if (!wl) return {
reds: [],
txt
};
const api = new mw.Api();
const textToParse = wl.join("").replace(/\|[^\]]+/g, "");
try {
const data = await api.post({
action: 'parse',
text: textToParse,
contentmodel: 'wikitext',
formatversion: 2
});
const tempDiv = document.createElement('div');
tempDiv.innerHTML = data.parse.text;
const reds = Array.from(tempDiv.querySelectorAll("a.new")).map(x => {
const urlParams = new URLSearchParams(x.href.split('?')[1]);
return urlParams.get('title')?.replace(/_/g, " ");
}).filter(Boolean);
return {
reds: [...new Set(reds)],
txt
};
} catch (e) {
return {
reds: [],
txt
};
}
},
async makePanel() {
const $editform = $('#editform');
const $textbox = $('#wpTextbox1');
$('#illwill').remove();
$editform.hide();
const $wrapper = $('<div id="illwill">Loading redlinks...</div>').insertBefore($editform);
const {
reds,
txt
} = await this.getRedLinks($textbox);
const wtLinks = [];
reds.forEach(title => {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s/g, "[\\s_]+");
const re = new RegExp("\\[\\[\\s*" + escaped + "\\s*(?:\\|[^\\[\\]]+)?\\]\\]", "ig");
const match = txt.match(re);
if (match && !wtLinks.some(z => z.title === title)) {
wtLinks.push({
link: match[0],
title
});
}
});
if (!wtLinks.length) {
const $back = $('<button>Quit</button>').on('click', () => {
$wrapper.remove();
$editform.show();
});
$wrapper.empty().append($('<strong>No valid redlinks found in text.</strong> '), $back);
return;
}
const $table = $(`
<table id="ill-table" class="wikitable">
<caption>IllWill.js (v${this.Version})</caption>
<thead>
<tr>
<th class="ill-x"><input type="checkbox" id="ill-select-all" disabled></th>
<th>Input link</th>
<th>Possible Wikidata matches</th>
<th>Sitelinks (top ${this.Config.MaxLanguages} by size)</th>
<th>Resulting wikitext</th>
</tr>
</thead>
<tbody></tbody>
<tfoot>
<tr>
<td colspan="3" id="ill-help">Carefully examine possible Wikidata matches.</td>
<td colspan="2" id="ill-buttons">
<button id="ill-cancel">Cancel</button>
<button id="ill-ok" disabled>Show changes</button>
</td>
</tr>
</tfoot>
</table>
`);
const $tbody = $table.find('tbody');
wtLinks.forEach((item, i) => {
$tbody.append(`
<tr id="ill-row-${i}" data-index="${i}">
<td class="ill-x"><input type="checkbox" class="ill-row-check" disabled></td>
<td class="ill-input">${this.escapeHTML(item.link)}</td>
<td class="ill-wd-item"><span class="ill-loading-inline">Searching...</span></td>
<td class="ill-sitelinks"></td>
<td class="ill-output"></td>
</tr>
`);
});
$wrapper.empty().append($table);
const chunkSize = 5;
for (let i = 0; i < wtLinks.length; i += chunkSize) {
const chunk = wtLinks.slice(i, i + chunkSize);
await Promise.all(chunk.map((item, idx) => this.fetchWikidataOptimized(item.title, i + idx)));
}
$table.on('click', '#ill-cancel', () => {
$editform.show();
$wrapper.remove();
});
$table.on('click', '#ill-ok', () => this.onOkButton($editform, $wrapper, $textbox));
$table.on('change', '#ill-select-all', (e) => {
const isChecked = $(e.target).is(':checked');
$table.find('.ill-row-check:not(:disabled)').each(function() {
$(this).prop('checked', isChecked).trigger('change');
});
});
$table.on('change', '.ill-row-check', (e) => {
this.onCheckbox(e);
this.updateOkButtonState($table);
});
$table.on('change', 'input[type="radio"]', (e) => {
this.showSiteLinks(e);
});
},
async fetchWikidataOptimized(title, index) {
const query = title.replace(this.Disambiguator, "");
const url = `${this.DataApi}?action=wbsearchentities&search=${encodeURIComponent(query)}&language=${this.LocalLang}&limit=5&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
const results = data.search;
const $cell = $(`#ill-row-${index} .ill-wd-item`);
if (!results || !results.length) {
$cell.html('<span class="ill-na">(no results)</span>');
return;
}
const entityIds = results.map(r => r.id);
await this.fetchEntities(entityIds, index, $cell);
} catch (e) {
$(`#ill-row-${index} .ill-wd-item`).text("Search failed.");
}
},
async fetchEntities(ids, rowIndex, $cell) {
const url = `${this.DataApi}?action=wbgetentities&ids=${ids.join('|')}&props=labels|descriptions|claims|sitelinks&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
$cell.empty();
ids.forEach(qNum => {
const entity = data.entities[qNum];
if (!entity) return;
const isScientific = entity.claims?.P31?.some(s => s.mainsnak?.datavalue?.value?.id === "Q13442814");
if (isScientific && this.Config.IgnoreScientific === HIDE) return;
const desc = entity.descriptions?.[this.LocalLang]?.value || entity.descriptions?.en?.value || "";
const label = entity.labels?.[this.LocalLang]?.value || entity.labels?.en?.value || qNum;
const isStrikeout = isScientific && this.Config.IgnoreScientific === STRIKEOUT;
const $div = $(`
<div class="${isStrikeout ? 'illwill-ignore' : ''}">
<input type="radio" name="selection-${rowIndex}" value="${qNum}" id="radio-${qNum}" ${isStrikeout ? 'disabled' : ''}>
<label for="radio-${qNum}">
<a href="https://wikidata.org/wiki/${qNum}" target="_blank">${this.escapeHTML(label)}</a>:
<span class="ill-desc">${desc ? this.escapeHTML(desc) : '<i>no description</i>'}</span>
</label>
</div>
`);
$cell.append($div);
this.SiteLinks[qNum] = entity.sitelinks || {};
});
} catch (e) {
$cell.text("Detail fetch failed.");
}
},
updateOkButtonState($table) {
const $rows = $table.find('.ill-row-check:not(:disabled)');
const $checkedRows = $rows.filter(':checked');
const anyEnabled = $rows.length > 0;
$table.find('#ill-ok').prop('disabled', $checkedRows.length === 0);
const $master = $table.find('#ill-select-all');
$master.prop('disabled', !anyEnabled);
if (anyEnabled && $rows.length === $checkedRows.length) {
$master.prop('checked', true);
} else {
$master.prop('checked', false);
}
},
async showSiteLinks(e) {
const isManualToggling = e.fromCheckbox || false;
const qNum = e.target.value;
const $row = $(e.target).closest('tr');
const sitelinks = this.SiteLinks[qNum];
const $checkbox = $row.find('.ill-row-check');
const $sitelinkCell = $row.find('.ill-sitelinks');
const $outputCell = $row.find('.ill-output');
const $table = $('#ill-table');
const effectiveMax = Math.min(Math.max(this.Config.MaxLanguages, 3), 6);
const validKeys = Object.keys(sitelinks).filter(k => k.endsWith('wiki') && !k.includes('commons'));
$sitelinkCell.html('<span class="ill-loading-inline">Measuring articles...</span>');
$outputCell.empty();
if (!isManualToggling) {
$checkbox.prop('disabled', true).prop('checked', false);
}
const candidates = validKeys.slice(0, 25);
const sizeMap = [];
await Promise.all(candidates.map(async (key) => {
const lang = key.replace('wiki', '').replace(/_/g, '-');
const title = sitelinks[key].title;
const endpoint = `https://${lang}.wikipedia.org/w/api.php?action=query&titles=${encodeURIComponent(title)}&prop=info&format=json&origin=*`;
try {
const res = await fetch(endpoint).then(r => r.json());
const page = Object.values(res.query.pages)[0];
sizeMap.push({
lang,
title,
length: page.length || 0
});
} catch (err) {
sizeMap.push({
lang,
title,
length: 0
});
}
}));
sizeMap.sort((a, b) => b.length - a.length);
const topLinks = sizeMap.slice(0, effectiveMax);
$sitelinkCell.html(topLinks.map(item => {
const kbValue = Math.round(item.length / 1024);
return `<div><a href="https://${item.lang}.wikipedia.org/wiki/${encodeURIComponent(item.title)}" target="_blank">${item.lang}:${this.escapeHTML(item.title)}</a> <span class="ill-size-tag">(${kbValue} kB)</span></div>`;
}).join(""));
const inputWikitext = $row.find('.ill-input').text();
const match = inputWikitext.match(/\[\[(.*?)\]\]/);
if (match) {
const parts = match[1].split('|');
const targetPage = parts[0].trim();
const surfaceName = (parts[1] || parts[0]).trim();
const illLangs = topLinks.map(item => {
const isSameTitle = this.norm(item.title) === this.norm(targetPage);
return `|${item.lang}|${isSameTitle ? '' : item.title}`;
}).join("");
const ltParam = (this.norm(surfaceName) === this.norm(targetPage)) ? "" : `|lt=${surfaceName}`;
$outputCell.text(`{{ill|${targetPage}${ltParam}${illLangs}}}`);
}
$checkbox.prop('disabled', !topLinks.length);
if (!isManualToggling) {
$checkbox.prop('checked', !!topLinks.length);
}
this.updateOkButtonState($table);
},
onCheckbox(e) {
const $row = $(e.target).closest('tr');
const $radio = $row.find('input[type="radio"]:checked');
const isChecked = $(e.target).is(':checked');
if (isChecked) {
if ($radio.length) {
this.showSiteLinks({
target: $radio[0],
fromCheckbox: true
});
}
} else {
$row.find('.ill-sitelinks, .ill-output').empty();
}
},
onOkButton($editform, $wrapper, $textbox) {
let text = $textbox.textSelection('getContents');
let changed = false;
$wrapper.find('.ill-row-check:checked').each(function() {
const $row = $(this).closest('tr');
const input = $row.find('.ill-input').text();
const output = $row.find('.ill-output').text();
if (output) {
const escapedInput = input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
text = text.replace(new RegExp(escapedInput, "gi"), output);
changed = true;
}
});
if (changed) {
$('#wpSummary').val((i, v) => (v ? v + "; " : "") + this.Summary);
$textbox.textSelection('setContents', text);
$wrapper.remove();
$editform.show();
if (this.Config.AutoDiff) $('#wpDiff').click();
}
},
escapeHTML(str) {
return str.replace(/[&<>"']/g, m => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
})[m]);
}
};
window.IllWill = IllWill;
mw.loader.using(['mediawiki.util', 'mediawiki.api', 'jquery.textSelection'], () => {
$(() => IllWill.setup());
});
})();
(function() {
'use strict';
const config = {
enableNS14: true,
rootWhitelists: ['Materials'],
userWhitelist: ""
};
const categoryDataCache = {};
let scanProgress = 0;
let totalToScan = 0;
async function verifyTreeMembership() {
if (!config.enableNS14) return false;
const api = new mw.Api();
const currentTitle = mw.config.get("wgPageName").replace(/_/g, ' ');
const fullRoots = config.rootWhitelists.concat(config.userWhitelist.split(',').map(s => s.trim()).filter(s => s)).map(cat => (cat.startsWith('Category:') ? cat : `Category:${cat}`));
if (fullRoots.includes(currentTitle)) return true;
let categoriesToTable = mw.config.get('wgCategories') || [];
let checked = new Set();
while (categoriesToTable.length > 0) {
if (categoriesToTable.some(cat => fullRoots.includes('Category:' + cat))) {
return true;
}
categoriesToTable.forEach(cat => checked.add(cat));
try {
const res = await api.get({
formatversion: 2,
prop: 'categories',
titles: categoriesToTable.map(c => 'Category:' + c),
cllimit: 'max'
});
let nextBatch = [];
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.categories) {
page.categories.forEach(parent => {
const parentName = parent.title.substring('Category:'.length);
if (!checked.has(parentName)) {
nextBatch.push(parentName);
}
});
}
});
}
categoriesToTable = [...new Set(nextBatch)];
} catch (e) {
return false;
}
}
return false;
}
function getChemboxBlock(wikitext) {
const chemboxRegex = /\{\{\s*Chembox[\s_]*(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const match = wikitext.match(chemboxRegex);
return match ? match[0] : null;
}
function getFormulaFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const formulaMatch = chembox.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
let rawFormula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
rawFormula = rawFormula.split(/\s+/)[0];
return rawFormula.split(',')[0].trim();
}
const propRegex = /\{\{\s*Chembox[\s_]+Properties(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const propSection = chembox.match(propRegex);
if (propSection) {
const content = propSection[0];
const allElements = ['Ac', 'Ag', 'Al', 'Am', 'Ar', 'As', 'At', 'Au', 'B', 'Ba', 'Be', 'Bh', 'Bi', 'Bk', 'Br', 'C', 'Ca', 'Cd', 'Ce', 'Cf', 'Cl', 'Cm', 'Cn', 'Co', 'Cr', 'Cs', 'Cu', 'D', 'Db', 'Ds', 'Dy', 'Er', 'Es', 'Eu', 'F', 'Fe', 'Fl', 'Fm', 'Fr', 'Ga', 'Gd', 'Ge', 'H', 'He', 'Hf', 'Hg', 'Ho', 'Hs', 'I', 'In', 'Ir', 'K', 'Kr', 'La', 'Li', 'Lr', 'Lu', 'Lv', 'Mc', 'Md', 'Mg', 'Mn', 'Mo', 'Mt', 'N', 'Na', 'Nb', 'Nd', 'Ne', 'Nh', 'Ni', 'No', 'Np', 'O', 'Og', 'Os', 'P', 'Pa', 'Pb', 'Pd', 'Pm', 'Po', 'Pr', 'Pt', 'Pu', 'Ra', 'Rb', 'Re', 'Rf', 'Rg', 'Rh', 'Rn', 'Ru', 'S', 'Sb', 'Sc', 'Se', 'Sg', 'Si', 'Sm', 'Sn', 'Sr', 'Ta', 'Tb', 'Tc', 'Te', 'Th', 'Ti', 'Tl', 'Tm', 'Ts', 'U', 'V', 'W', 'Xe', 'Y', 'Yb', 'Zn', 'Zr'];
let data = {};
allElements.forEach(el => {
const elRegex = new RegExp(`\\|\\s*${el}\\s*=\\s*(\\d*\\.?\\d+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch && elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
});
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
elementsPresent.filter(el => !(hasC && (el === "C" || el === "H"))).sort().forEach(el => {
formulaArray.push(el + data[el]);
});
return formulaArray.join('');
}
}
return null;
}
function getCASFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const idRegex = /\{\{\s*Chembox[\s_]+Identifiers(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const idSection = chembox.match(idRegex);
if (!idSection) return null;
const content = idSection[0];
const nbsp = String.fromCharCode(160);
const casMatch = content.match(/\|\s*CASNo\d*\b(?!\s*_)\s*=\s*([^|}\n]+)/i);
if (casMatch) {
let val = casMatch[1].replace(/\{\{[^}]+\}\}/g, "").replace(/\u00AD/g, "").split(" ").join("");
while (val.indexOf(nbsp) !== -1) val = val.replace(nbsp, "");
while (val.indexOf(" ") !== -1) val = val.replace(" ", "");
const cleanedCas = val.trim();
return /^\d{2,7}-\d{2}-\d$/.test(cleanedCas) ? cleanedCas : null;
}
return null;
}
async function getWikidataValue() {
const qid = mw.config.get('wgWikidataItemId');
if (!qid) return null;
const api = new mw.ForeignApi('https://www.wikidata.org/w/api.php');
const res = await api.get({
action: 'wbgetentities',
ids: qid,
props: 'claims',
format: 'json',
formatversion: 2
});
const claims = res.entities[qid].claims;
const data = {};
if (claims.P274) data.formula = claims.P274[0].mainsnak.datavalue.value;
if (claims.P231) data.cas = claims.P231[0].mainsnak.datavalue.value;
return data;
}
async function startBackgroundCategoryScan() {
const api = new mw.Api();
const titles = $('#mw-pages li a').map((i, el) => $(el).attr('title') || $(el).text()).get();
totalToScan = titles.length;
if (totalToScan === 0) return;
const chunks = arrayChunk(titles, 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: chunk,
rvprop: 'content',
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const f = getFormulaFromWikitext(wikitext);
const c = getCASFromWikitext(wikitext);
if (f || c) categoryDataCache[page.title] = {
formula: f,
cas: c
};
});
}
scanProgress += chunk.length;
$('#caschemassist-cat-btn').text(`Scanning... ${scanProgress}/${totalToScan}`);
}
$('#caschemassist-cat-btn').text('Show missing CAS/Chem').prop('disabled', false);
}
async function renderCategoryAudit() {
const api = new mw.Api();
const missingCheckTitles = [];
Object.values(categoryDataCache).forEach(data => {
if (data.formula) missingCheckTitles.push(data.formula);
if (data.cas) missingCheckTitles.push(data.cas);
});
if (missingCheckTitles.length === 0) {
mw.notify("No valid Chembox data found in this category.");
return;
}
const existenceMap = {};
const chunks = arrayChunk([...new Set(missingCheckTitles)], 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
titles: chunk,
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(p => {
existenceMap[p.title] = !p.missing;
});
}
}
$('#mw-pages li').each(function() {
const title = $(this).find('a').attr('title') || $(this).find('a').text().trim();
const data = categoryDataCache[title];
if (!data) return;
if (data.formula && !existenceMap[data.formula]) {
$(this).append($('<span>').css({
'color': 'red',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [${data.formula}]`));
}
if (data.cas && !existenceMap[data.cas]) {
$(this).append($('<span>').css({
'color': 'darkred',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [CAS: ${data.cas}]`));
}
});
$('#caschemassist-cat-btn').text('Audit complete').prop('disabled', true);
}
async function handleSingleArticle() {
const api = new mw.Api();
const currentTitle = mw.config.get("wgTitle");
const [wikiRes, wikidata] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
getWikidataValue()
]);
const page = wikiRes.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const formula = getFormulaFromWikitext(wikitext);
const cas = getCASFromWikitext(wikitext);
if (wikidata) {
let mismatches = [];
if (formula && wikidata.formula && formula !== wikidata.formula) {
mismatches.push(`Formula mismatch: Chembox "${formula}" vs Wikidata "${wikidata.formula}"`);
}
if (cas && wikidata.cas && cas !== wikidata.cas) {
mismatches.push(`CAS mismatch: Chembox "${cas}" vs Wikidata "${wikidata.cas}"`);
}
if (mismatches.length > 0) {
const $warning = $('<div>').css({
'background': '#fff2f2',
'border': '2px solid #d33',
'padding': '10px',
'margin-bottom': '1em',
'color': '#b32424'
}).append($('<strong>').text('⚠️ Data Mismatch Detected: '), mismatches.join(' | '));
$('#mw-content-text').prepend($warning);
}
}
async function checkAndRender(target, label, customWikitext) {
const check = await api.get({
action: 'query',
titles: target,
formatversion: 2
});
if (check.query.pages[0].missing) {
const $container = $('<div>').css({
'background': '#f8f9fa',
'border': '1px solid #a2a9b1',
'padding': '10px',
'margin-bottom': '1em'
});
const $btn = $('<button>').text(`Create redirect: ${target}`).click(async function() {
await api.postWithEditToken({
action: 'edit',
title: target,
text: customWikitext,
summary: `Creating redirect to [[${currentTitle}]] (${label})`,
createonly: true
});
mw.notify(`Created redirect: ${target}`);
$container.fadeOut(400, function() {
$(this).remove();
});
});
$container.append($('<strong>').text(`${label}: `), `Missing redirect. `, $btn);
$('#mw-content-text').prepend($container);
}
}
if (formula && formula !== currentTitle) {
const formulaWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from chemical formula}}\n}}`;
await checkAndRender(formula, "Chemical formula", formulaWikitext);
}
if (cas && cas !== currentTitle) {
const casWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from CAS Registry Number}}\n{{R unprintworthy}}\n}}`;
await checkAndRender(cas, "CAS number", casWikitext);
}
}
function arrayChunk(arr, size) {
const result = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
$(document).ready(async function() {
const ns = mw.config.get("wgNamespaceNumber");
if (ns === 14) {
if (await verifyTreeMembership()) {
const $catContainer = $('#mw-pages');
if ($catContainer.length) {
const $btn = $('<button>').text('Initializing scan...').attr('id', 'caschemassist-cat-btn').addClass('mw-ui-button').css({
'margin': '0 10px 10px 0',
'float': 'right',
'position': 'relative',
'z-index': '100'
}).prop('disabled', true).click(renderCategoryAudit);
$catContainer.prepend($btn);
startBackgroundCategoryScan();
}
}
} else if (ns === 0 && mw.config.get("wgAction") === "view") {
handleSingleArticle();
}
});
})();
//</nowiki>
3e2gqyz3qhv65sg62k3qaosk5fv1w4h
739299
739298
2026-04-25T07:57:11Z
Sam Sailor
26820
Test
739299
javascript
text/javascript
//<nowiki>
(function() {
mw.util.addCSS(`
.ambox-Orphan { display: block !important; }
.comrade-container {
box-shadow: 0 1px 3px rgba(0,0,0,0.1); border-radius: 2px;
padding: 10px 15px; margin: 10px 0; display: flex;
flex-direction: column; align-items: flex-start; gap: 8px;
border: 1px solid #a2a9b1; background: #fcfcfc; line-height: 1.5;
}
.comrade-header { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.comrade-content { display: flex; flex-direction: column; gap: 5px; width: 100%; }
.comrade-success { background-color: #d5f5e3 !important; border-left: 5px solid #27ae60 !important; color: #1b5e20 !important; }
.comrade-nudge { background: #fff9ea; border-left: 5px solid #f0ad4e; }
.comrade-info { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-status { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-container button:not(.comrade-dismiss) {
background: #f8f9fa; border: 1px solid #a2a9b1; border-radius: 2px;
padding: 2px 10px; cursor: pointer; font-weight: bold; font-size: 0.95em; margin-left: 10px;
}
.comrade-container button:not(.comrade-dismiss):hover { border-color: #36c; color: #36c; background: #fff; }
.comrade-dismiss {
background: transparent; border: none; color: #d33; font-weight: bold;
cursor: pointer; font-size: 0.9em;
}
.comrade-dismiss:hover { text-decoration: underline; }
.comrade-code-block { background: #f1f1f1; padding: 4px 8px; border-radius: 3px; font-family: monospace; display: inline-block; margin-top: 4px; }
`);
function appendDismiss($el) {
$('<button>').addClass('comrade-dismiss').text('Dismiss').on('click', function() {
$(this).closest('.comrade-container').fadeOut();
}).appendTo($el.find('.comrade-header'));
$("#siteSub").after($el);
}
async function checkNamingPrecision(currentTitle, isHuman) {
const disambigMatch = currentTitle.match(/^(.+?)(?:, | \()/);
if (isHuman || !disambigMatch) return;
const baseTitle = disambigMatch[1].trim();
const api = new mw.Api();
try {
const baseRes = await api.get({
action: 'query',
titles: baseTitle,
redirects: 1,
formatversion: 2
});
const page = baseRes.query.pages[0];
const baseExists = !page.missing;
let baseRedirectsToSelf = false;
if (baseRes.query.redirects) {
baseRedirectsToSelf = baseRes.query.redirects.some(r => r.to === currentTitle);
}
const searchRes = await api.get({
action: 'query',
list: 'allpages',
apprefix: baseTitle,
aplimit: 50,
formatversion: 2
});
const competitors = searchRes.query.allpages.filter(p => {
const t = p.title;
return t !== currentTitle && t !== baseTitle && (t.startsWith(baseTitle + ', ') || t.startsWith(baseTitle + ' ('));
});
if (!baseExists || baseRedirectsToSelf) {
const $container = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (competitors.length === 0) {
$header.append($('<strong>').text('Precision:'));
const reason = baseRedirectsToSelf ? "already redirects here" : "is free";
$content.append($('<span>').append(`The base name `, $('<code>').text(baseTitle), ` ${reason}. Consider moving this article.`));
const moveUrl = mw.util.getUrl('Special:MovePage/' + currentTitle, {
wpNewTitle: baseTitle,
wpReason: 'Unnecessary disambiguation; base name is free/redirects here per [[WP:PRECISION]]'
});
$content.append($('<button>').text(`Move to ${baseTitle}`).on('click', () => window.open(moveUrl, '_blank')));
} else {
$header.append($('<strong>').text('Observation:'));
const state = baseRedirectsToSelf ? 'redirect to this page' : 'redlink';
$content.append($('<span>').append(`Base name `, $('<code>').text(baseTitle), ` has ${competitors.length} competitors, but it is currently a ${state}.`));
$content.append($('<span>').text('Consider if a disambiguation page is needed.'));
}
$container.append($header, $content);
appendDismiss($container);
}
} catch (e) {
console.warn("Comrade: Precision check failed", e);
}
}
async function checkAndRenderRedirect(redirTitle, targetTitle, rCategory, labelText, customWikitext) {
const api = new mw.Api();
const res = await api.get({
action: 'query',
titles: redirTitle,
formatversion: 2
});
if (res.query.pages[0].missing) {
const safeId = "comrade-redir-" + redirTitle.replace(/[^a-z0-9]/gi, '-');
const $container = $('<div>').addClass('comrade-container comrade-info').attr('id', safeId);
const $header = $('<div>').addClass('comrade-header');
const summary = `Created redirect from ${labelText.toLowerCase()} to [[${targetTitle}]]`;
const $btn = $('<button>').text('Create redirect').on('click', () => createModernRedirect(redirTitle, targetTitle, rCategory, summary, safeId, customWikitext));
$header.append($('<div>').append($('<strong>').text(`${labelText}: `), `Create redirect from `, $('<code>').text(redirTitle), $btn));
$container.append($header);
appendDismiss($container);
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
if (qid) {
try {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
});
const res = await fetch(url).then(r => r.json());
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
}
} catch (e) {
console.warn("Comrade: Wikidata API call failed.");
}
}
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
if (domain.includes(".") && url.pathname === "/") {
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
try {
const response = await fetch(citationURL);
if (response.ok) {
await checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
}
} catch (e) {
console.warn("Comrade: Citation API check failed.");
}
}
} catch (e) {
console.warn("Comrade: Error parsing URL object.");
}
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan, resolvedTitle) {
const api = new mw.Api();
const candidates = resolvedTitle ? [resolvedTitle] : [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
redirects: 0
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const foundTitle = page.title;
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) break;
const $container = $('<div>').addClass('comrade-container');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (isOrphan) {
$container.addClass('comrade-nudge');
$header.append($('<strong>').text('Comrade nudge:'));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
}
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(foundTitle),
target: '_blank'
}).text(foundTitle), "; should it be?"));
const snippet = '* {{anbl|' + currentTitle + '}}';
const $snippetWrapper = $('<div>').css({
'display': 'flex',
'align-items': 'center',
'gap': '10px',
'margin-top': '4px'
});
const $instructionSpan = $('<span>').append('If the Short description is good, consider adding ', $('<code>').text(snippet).css({
'background-color': '#f8f9fa',
'border': '1px solid #eaecf0',
'border-radius': '2px',
'padding': '1px 4px'
}));
const $copyBtn = $('<button>').text('Copy & insert').css('margin-left', '0').on('click', function() {
navigator.clipboard.writeText(snippet).then(() => {
const $this = $(this);
const originalText = $this.text();
$this.text('Copied!').prop('disabled', true);
const editUrl = mw.util.getUrl(foundTitle, {
action: 'edit',
summary: snippet
});
window.open(editUrl, '_blank');
setTimeout(() => {
$this.text(originalText).prop('disabled', false);
}, 1500);
});
});
$snippetWrapper.append($instructionSpan, $copyBtn);
$content.append($snippetWrapper);
if (!hasShortDesc) {
$content.append($('<span>').css({
'color': '#d33',
'font-weight': 'bold',
'margin-top': '5px'
}).text('⚠️ Missing short description: Please add a concise one before listing.'));
}
$container.append($header, $content);
appendDismiss($container);
break;
}
}
}
}
async function checkQualityConsistency() {
const oresClass = getOresPrediction();
if (!oresClass || oresClass === 'Stub') return;
const talkTitle = 'Talk:' + mw.config.get('wgPageName');
const api = new mw.Api();
try {
const [talkRes, pageRes] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: talkTitle,
rvprop: 'content',
formatversion: 2
}),
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
})
]);
const talkPage = talkRes.query.pages[0];
const wikitext = pageRes.query.pages[0].revisions[0].content;
const hasStubTag = /\{\{[^}]*stub\}\}/i.test(wikitext);
const $raterLink = $('#ca-rater, #t-rater');
const isRaterAvailable = (typeof window.rater !== 'undefined' || $raterLink.length > 0);
let talkClass = null;
if (!talkPage.missing) {
const classMatch = talkPage.revisions[0].content.match(/\|class\s*=\s*([^|}\s]+)/i);
talkClass = classMatch ? classMatch[1].charAt(0).toUpperCase() + classMatch[1].slice(1).toLowerCase() : null;
}
const isStubRated = talkClass === 'Stub';
const lingeringStubTag = (talkClass && talkClass !== 'Stub' && oresClass !== 'Stub' && hasStubTag);
if (talkPage.missing || isStubRated || lingeringStubTag) {
const $container = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').append($('<strong>').text('Quality:'), " ");
let message = `ORES suggests <b>${oresClass}</b>. `;
if (talkPage.missing) {
message = `Talk page missing; ORES suggests <b>${oresClass}</b>. `;
let $btn;
if (isRaterAvailable) {
$btn = $('<button>').text('Open Rater').on('click', () => $raterLink.find('a')[0].click());
} else {
$btn = $('<button>').text('Install Rater').on('click', () => window.open('https://en.wikipedia.org/wiki/User:Evad37/rater', '_blank'));
message += "Consider installing <i>Rater</i> for easy assessment.";
}
$content.append(message, $btn);
} else if (lingeringStubTag && !isStubRated) {
message = `Article is ${talkClass}-class, but stub tags remain. `;
const $btn = $('<button>').text('Remove stub tags').on('click', () => performStubRemoval());
$content.append(message, $btn);
} else if (isStubRated) {
message = `Talk page says Stub, but ORES predicts <b>${oresClass}</b>. `;
const $btnOres = $('<button>').text(`Promote to ${oresClass}`).on('click', () => performPromotion(oresClass));
$content.append(message, $btnOres);
if (oresClass === 'C') {
const $btnStart = $('<button>').text(`Promote to Start`).on('click', () => performPromotion('Start'));
$content.append(" or ", $btnStart);
} else if (oresClass === 'B') {
const $btnC = $('<button>').text(`Promote to C`).on('click', () => performPromotion('C'));
$content.append(" or ", $btnC);
}
}
$header.append($content);
$container.append($header);
appendDismiss($container);
}
} catch (e) {
console.error("Comrade quality check failed", e);
}
}
async function performStubRemoval() {
const api = new mw.Api();
try {
const pageData = await api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
});
let wikitext = pageData.query.pages[0].revisions[0].content;
const newWikitext = wikitext.replace(/\{\{[^}]*stub\}\}\n?/gi, '').trim();
if (wikitext === newWikitext) {
mw.notify("No stub tags found to remove.");
return;
}
await api.postWithToken('csrf', {
action: 'edit',
title: mw.config.get("wgPageName"),
text: newWikitext,
summary: `Removing lingering stub tags per ORES/Talk assessment`,
nocreate: true
});
location.reload();
} catch (err) {
console.error("Comrade failed to remove stub tags:", err);
mw.notify("Error removing stub tags.", {
type: 'error'
});
}
}
async function createModernRedirect(redirTitle, targetTitle, rCategory, customSummary, elementId, customWikitext) {
const api = new mw.Api();
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
try {
await api.postWithEditToken({
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
});
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
} catch (e) {
mw.notify("Failed to create redirect.", {
type: 'error'
});
}
}
function getOresPrediction() {
// Dependency: [[m:User:EpochFail/ArticleQuality-system.js]] renders the prediction inside .article_quality
const text = $('.article_quality').text();
if (!text) return null;
if (text.includes('Start')) return 'Start';
if (text.includes('C')) return 'C';
if (text.includes('B')) return 'B';
return null;
}
function getSmartSortKey(fullName, entity) {
const countryIds = entity.claims?.P27?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const locationIds = entity.claims?.P17?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const placeIds = [...(entity.claims?.P19 || []), ...(entity.claims?.P119 || [])].map(c => c.mainsnak?.datavalue?.value?.id).filter(Boolean);
const allRelevantIds = [...countryIds, ...locationIds, ...placeIds];
const birthYear = parseInt(entity.claims?.P569?.[0]?.mainsnak?.datavalue?.value?.time?.match(/[+-](\d{4})/)?.[1]) || null;
if (fullName.startsWith("Saint ")) return fullName.replace("Saint ", "");
const arabicParticles = /\b(Abu|Abd|Abdel|Abdul|ben|bin|bint)\b/i;
if (fullName.match(arabicParticles)) {
if (birthYear && birthYear > 1900) {
const parts = fullName.split(' ');
const binIndex = parts.findIndex(p => p.toLowerCase() === 'bin' || p.toLowerCase() === 'ben');
if (binIndex > 0) return parts.slice(binIndex).join(' ') + ', ' + parts.slice(0, binIndex).join(' ');
} else {
return fullName;
}
}
const eastAsianQids = ["Q148", "Q184", "Q884", "Q424", "Q881", "Q17"];
if (allRelevantIds.some(id => eastAsianQids.includes(id))) {
if (allRelevantIds.includes("Q17") && birthYear && birthYear > 1885) return westernSort(fullName);
const parts = fullName.split(' ');
if (parts.length > 1) return `${parts[0]}, ${parts.slice(1).join(' ')}`;
}
return westernSort(fullName);
}
function westernSort(name) {
let cleanName = name;
if (name.startsWith("O'")) cleanName = name.replace("O'", "O");
const parts = cleanName.split(' ');
if (parts.length < 2) return cleanName;
const surname = parts.pop();
if (["Jr.", "Jr", "III", "II", "Sr.", "Sr"].includes(surname)) {
const actualSurname = parts.pop();
return `${actualSurname}, ${parts.join(' ')} ${surname}`;
}
return `${surname}, ${parts.join(' ')}`;
}
async function performDeorphan() {
const api = new mw.Api();
try {
await api.edit(mw.config.get('wgPageName'), function(rev) {
let text = rev.content;
const summary = 'Article has backlinks; removed [[Template:Orphan|{{Orphan}}]]';
text = text.replace(/\{\{Orphan\s*(?:\|[^}]*)?\}\}\s*/gi, '');
text = text.replace(/(\{\{Multiple[ _]issues\s*)\|\s*\|/gi, '$1|');
text = replaceMI(text);
text = text.replace(/\n{3,}/g, '\n\n').trim();
return {
text,
summary,
minor: true
};
});
mw.notify('Orphan tag removed!', {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
setTimeout(() => location.reload(), 700);
} catch (e) {
mw.notify('Failed to remove orphan tag.', {
type: 'error'
});
}
}
async function performPromotion(newClass) {
const api = new mw.Api();
const pageName = mw.config.get('wgPageName');
const talkName = 'Talk:' + pageName;
try {
const talkData = await api.get({
action: 'query',
prop: 'revisions',
titles: talkName,
rvprop: 'content',
formatversion: 2
});
let talkWikitext = talkData.query.pages[0].revisions[0].content;
talkWikitext = talkWikitext.replace(/(\|class\s*=\s*)Stub/i, `$1${newClass}`);
await api.postWithEditToken({
action: 'edit',
title: talkName,
text: talkWikitext,
summary: `Promoting article to ${newClass} class per ORES prediction [[WP:ORES]]`
});
const artData = await api.get({
action: 'query',
prop: 'revisions',
titles: pageName,
rvprop: 'content',
formatversion: 2
});
let artWikitext = artData.query.pages[0].revisions[0].content;
const stubRegex = /\{\{[^}]*?-stub\}\}\s*\n?/gi;
const genericStubRegex = /\{\{stub\}\}\s*/gi;
artWikitext = artWikitext.replace(stubRegex, '').replace(genericStubRegex, '');
await api.postWithEditToken({
action: 'edit',
title: pageName,
text: artWikitext,
summary: `Removing stub tags; article promoted to ${newClass}`
});
location.reload();
} catch (e) {
alert("Promotion failed. Check console for details.");
console.error(e);
}
}
function replaceMI(text) {
const miRegex = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miRegex.exec(text)) !== null) {
const start = match.index;
result += text.slice(lastIndex, start);
let depth = 0;
let i = start;
while (i < text.length) {
if (text[i] === '{' && text[i + 1] === '{') {
depth++;
i += 2;
} else if (text[i] === '}' && text[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
const end = i;
const full = text.slice(start, end);
const pipeIdx = full.indexOf('|');
const inner = full.slice(pipeIdx + 1, full.length - 2).trim();
const topLevel = extractTopLevelTemplates(inner);
if (topLevel.length === 0) {
result += '';
} else if (topLevel.length === 1) {
result += topLevel[0].trim();
} else {
result += full;
}
lastIndex = end;
}
result += text.slice(lastIndex);
return result;
}
function extractTopLevelTemplates(inner) {
const templates = [];
let i = 0;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
let depth = 0;
const start = i;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
depth++;
i += 2;
} else if (inner[i] === '}' && inner[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
templates.push(inner.slice(start, i));
} else {
i++;
}
}
return templates;
}
async function performSortCorrection(newName, type) {
const api = new mw.Api();
const title = mw.config.get("wgPageName");
try {
const data = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2
});
let content = data.query.pages[0].revisions[0].content;
const dsRegex = /\{\{(?:DEFAULTSORT|Defaultsort):[^}]+\}\}/i;
const newTag = `{{DEFAULTSORT:${newName}}}`;
let actionDesc = type === "add" ? "added" : "corrected";
if (dsRegex.test(content)) {
content = content.replace(dsRegex, newTag);
} else {
const catRegex = /\[\[Category:/i;
if (catRegex.test(content)) {
content = content.replace(catRegex, `${newTag}\n[[Category:`);
} else {
content += `\n\n${newTag}`;
}
}
await api.postWithEditToken({
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName}`,
nocreate: true
});
mw.notify(`DEFAULTSORT ${actionDesc} to ${newName}!`, {
title: 'Comrade Success',
type: 'success'
});
setTimeout(() => location.reload(), 700);
} catch (err) {
mw.notify('Failed to save DEFAULTSORT.', {
type: 'error'
});
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
function renderSortNudge(target, reason) {
const $dsNudge = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Correct to: ${target}`).on('click', () => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
async function fetchWikidataNameLabels(entity) {
const familyIds = entity.claims?.P734?.map(c => c.mainsnak.datavalue.value.id) || [];
const givenIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
const allNameIds = [...new Set([...familyIds, ...givenIds])];
if (allNameIds.length === 0) return {
givens: [],
surnames: []
};
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: allNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
});
const nameData = await fetch(url).then(r => r.json());
return {
givens: givenIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean),
surnames: familyIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean)
};
}
function fixNameCasing(part, referenceName) {
return part.split(' ').map(word => {
const match = referenceName.match(new RegExp(`\\b${word}\\b`, 'i'));
if (match) return match[0];
return referenceName.split(' ').find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase())) || word;
}).join(' ');
}
function getLongNameFromWikitext(wikitext) {
const firstLine = wikitext.split('\n').find(l => l.includes("'''"));
if (!firstLine) return null;
const boldMatch = firstLine.match(/'''(.+?)'''/);
if (!boldMatch) return null;
return boldMatch[1].replace(/\s*([“"«《„‹「『'].+?[”"»》”›」』']|\([^)\n]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
}
async function init() {
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") return;
const api = new mw.Api();
const qid = mw.config.get("wgWikibaseItemId");
try {
const [pageData, backlinkData] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
api.get({
action: 'query',
list: 'backlinks',
blfilterredir: 'nonredirects',
bllimit: 50,
blnamespace: 0,
bltitle: mw.config.get("wgPageName"),
formatversion: 2
})
]);
const page = pageData.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const currentTitle = mw.config.get("wgTitle");
const cleanTitle = stripDiacritics(currentTitle);
const isOrphanTagged = /\{\{\s*(Orphan|Do-attempt|Lonely|Orp|Orphaned article)/i.test(wikitext) || /\| *orphan *=/i.test(wikitext);
const backLinkCount = backlinkData.query.backlinks.length;
if (isOrphanTagged && backLinkCount >= 1) {
const $container = $('<div>').addClass('comrade-container comrade-status');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text('Remove orphan tag').on('click', () => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) await checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
await checkDomainRedirect(qid, currentTitle);
let isHuman = false;
let entity = null;
if (qid) {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
});
const wikiData = await fetch(url).then(r => r.json());
entity = wikiData.entities[qid];
isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
}
await checkNamingPrecision(currentTitle, isHuman);
if (qid && entity) {
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const nameParts = tClean.split(' ');
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
const isOrphan = isOrphanTagged && backLinkCount < 1;
const primarySurname = hasFamilyNameHatnote ? nameParts[0] : (getSmartSortKey(tClean, entity).split(',')[0] || nameParts[nameParts.length - 1]);
let targetSortName = "";
if (isHuman) {
const {
givens,
surnames
} = await fetchWikidataNameLabels(entity);
const refName = entity.labels?.en?.value || currentTitle;
if (dsMatch) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixNameCasing(lastPart, refName)}, ${fixNameCasing(firstPart, refName)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field.");
} else if (givens.length > 0 && lastPart.split(' ').length > 1) {
const misplacedGiven = lastPart.split(' ').find(part => givens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastPart.split(' ').filter(p => p !== misplacedGiven).join(' ');
targetSortName = `${fixNameCasing(newSurname, refName)}, ${fixNameCasing(misplacedGiven + ' ' + firstPart, refName)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
}
}
} else {
targetSortName = hasFamilyNameHatnote ? `${nameParts[0]}, ${nameParts.slice(1).join(' ')}` : getSmartSortKey(tClean, entity);
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).on('click', () => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
const longName = getLongNameFromWikitext(wikitext);
if (longName && longName.length > tClean.length) {
let sortKey = hasFamilyNameHatnote ? `${longName.split(' ')[0]}, ${longName.split(' ').slice(1).join(' ')}` : getSmartSortKey(longName, entity);
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
await checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
if (targetSortName) {
let targetPage = currentTitle.includes(' (') ? currentTitle.split(' (')[0] : currentTitle;
let rCat = targetPage !== currentTitle ? "R from ambiguous sort name" : "R from sort name";
if (targetSortName.includes(',')) {
const p = targetSortName.split(',');
rCat += `|${p[0].trim().charAt(0).toUpperCase()}|${p[1].trim().charAt(0).toUpperCase()}`;
}
await checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name");
}
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
surnames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
givens.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) finalGivenNames.add(gn);
});
if (finalFamilyNames.size === 0 && nameParts.length >= 2) finalFamilyNames.add(nameParts[nameParts.length - 1]);
if (finalGivenNames.size === 0 && nameParts.length >= 2) finalGivenNames.add(nameParts[0]);
const getPriorityTarget = async (name, type) => {
const titles = type === 'surname' ? [`List of people with surname ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`] : [`List of people with given name ${name}`, `${name} (given name)`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
return titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
};
for (const name of finalGivenNames) {
if (name.toLowerCase() === primarySurname.toLowerCase()) continue;
const target = await getPriorityTarget(name, 'given');
if (target) await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
for (const name of finalFamilyNames) {
const titles = [name, `${name} (surname)`, `List of people with surname ${name}`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
const target = titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
if (target) await checkNameList(name, 'surname', currentTitle, hasShortDesc, isOrphan, target);
}
}
const oresWait = setInterval(() => {
if ($('.article_quality').length) {
clearInterval(oresWait);
checkQualityConsistency();
}
}, 500);
setTimeout(() => clearInterval(oresWait), 5000);
}
} catch (err) {
console.error("Comrade error:", err);
}
}
init();
})();
(function() {
if (window.IllWill) return;
const STRIKEOUT = true;
const HIDE = 'hide';
const IllWill = {
Version: "1.1.1",
Attribution: 'created by <a target="_blank" href="/wiki/User:Cobaltcigs">cobaltcigs</a>',
Summary: "Added interlanguage links using IllWill.js",
DataApi: "https://www.wikidata.org/w/api.php",
LocalLang: mw.config.get("wgPageContentLanguage"),
Disambiguator: / \(.*\)$/,
SiteLinks: {},
Config: {
MaxLanguages: 5,
IgnoreScientific: STRIKEOUT,
AutoDiff: true
},
css: `
#illwill { clear: both; margin-bottom: 1.5em; border: 1px solid #a2a9b1; padding: 15px; background: #f8f9fa; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.ill-sitelinks a { display: inline-block; margin-bottom: 3px; font-size: 0.9em; color: #36c; }
.ill-size-tag { font-size: 0.8em; color: #72777d; margin-left: 6px; white-space: nowrap; }
.illwill-ignore { text-decoration: line-through; color: #72777d; font-size: 0.85em; }
.ill-x { width: 35px; text-align: center; }
.ill-input, .ill-output { font-family: 'Courier New', monospace; white-space: pre-wrap; font-size: 0.9em; background: #fff; border: 1px solid #eaecf0; padding: 4px; border-radius: 2px; }
#ill-buttons { text-align: right; padding: 10px; border-top: 1px solid #a2a9b1; margin-top: 10px; }
#ill-buttons button { margin-left: 10px; cursor: pointer; padding: 6px 16px; border-radius: 2px; border: 1px solid #a2a9b1; transition: all 0.2s; }
#ill-ok { background: #36c; color: #fff; border-color: #36c !important; font-weight: bold; }
#ill-ok:hover { background: #447ff5; }
#ill-ok:disabled { background: #eaecf0; color: #72777d; border-color: #c8ccd1 !important; cursor: not-allowed; opacity: 0.7; }
#ill-table { width: 100%; border-collapse: collapse; margin-top: 10px; background: white; }
#ill-table th { background: #f2f2f2; text-align: left; padding: 8px; border: 1px solid #a2a9b1; }
#ill-table td { padding: 8px; border: 1px solid #a2a9b1; vertical-align: top; }
#ill-table caption { background: #eaffea; font-weight: bold; padding: 10px; border: 1px solid #a2a9b1; border-bottom: none; font-size: 1.1em; }
#ill-help { font-size: 0.85em; color: #54595d; padding: 10px; line-height: 1.4; }
.ill-loading-inline { font-style: italic; color: #72777d; font-size: 0.9em; display: block; margin: 5px 0; }
`,
setup() {
if (!["edit", "submit"].includes(mw.config.get('wgAction'))) return;
mw.loader.addStyleTag(this.css);
const link = mw.util.addPortletLink('p-cactions', '#', 'IllWill', 'ca-illwill', "Add interlanguage links via Wikidata");
if (link) {
link.addEventListener('click', (e) => {
e.preventDefault();
this.makePanel();
});
}
},
norm(s) {
if (!s) return "";
const t = s.trim().replace(/[\s_]+/g, " ");
return t.charAt(0).toUpperCase() + t.slice(1);
},
async getRedLinks($textbox) {
const txt = $textbox.textSelection('getContents') || "";
const wl = txt.match(/\[\[[^[\]\n]+\]\]/g);
if (!wl) return {
reds: [],
txt
};
const api = new mw.Api();
const textToParse = wl.join("").replace(/\|[^\]]+/g, "");
try {
const data = await api.post({
action: 'parse',
text: textToParse,
contentmodel: 'wikitext',
formatversion: 2
});
const tempDiv = document.createElement('div');
tempDiv.innerHTML = data.parse.text;
const reds = Array.from(tempDiv.querySelectorAll("a.new")).map(x => {
const urlParams = new URLSearchParams(x.href.split('?')[1]);
return urlParams.get('title')?.replace(/_/g, " ");
}).filter(Boolean);
return {
reds: [...new Set(reds)],
txt
};
} catch (e) {
return {
reds: [],
txt
};
}
},
async makePanel() {
const $editform = $('#editform');
const $textbox = $('#wpTextbox1');
$('#illwill').remove();
$editform.hide();
const $wrapper = $('<div id="illwill">Loading redlinks...</div>').insertBefore($editform);
const {
reds,
txt
} = await this.getRedLinks($textbox);
const wtLinks = [];
reds.forEach(title => {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s/g, "[\\s_]+");
const re = new RegExp("\\[\\[\\s*" + escaped + "\\s*(?:\\|[^\\[\\]]+)?\\]\\]", "ig");
const match = txt.match(re);
if (match && !wtLinks.some(z => z.title === title)) {
wtLinks.push({
link: match[0],
title
});
}
});
if (!wtLinks.length) {
const $back = $('<button>Quit</button>').on('click', () => {
$wrapper.remove();
$editform.show();
});
$wrapper.empty().append($('<strong>No valid redlinks found in text.</strong> '), $back);
return;
}
const $table = $(`
<table id="ill-table" class="wikitable">
<caption>IllWill.js (v${this.Version})</caption>
<thead>
<tr>
<th class="ill-x"><input type="checkbox" id="ill-select-all" disabled></th>
<th>Input link</th>
<th>Possible Wikidata matches</th>
<th>Sitelinks (top ${this.Config.MaxLanguages} by size)</th>
<th>Resulting wikitext</th>
</tr>
</thead>
<tbody></tbody>
<tfoot>
<tr>
<td colspan="3" id="ill-help">Carefully examine possible Wikidata matches.</td>
<td colspan="2" id="ill-buttons">
<button id="ill-cancel">Cancel</button>
<button id="ill-ok" disabled>Show changes</button>
</td>
</tr>
</tfoot>
</table>
`);
const $tbody = $table.find('tbody');
wtLinks.forEach((item, i) => {
$tbody.append(`
<tr id="ill-row-${i}" data-index="${i}">
<td class="ill-x"><input type="checkbox" class="ill-row-check" disabled></td>
<td class="ill-input">${this.escapeHTML(item.link)}</td>
<td class="ill-wd-item"><span class="ill-loading-inline">Searching...</span></td>
<td class="ill-sitelinks"></td>
<td class="ill-output"></td>
</tr>
`);
});
$wrapper.empty().append($table);
const chunkSize = 5;
for (let i = 0; i < wtLinks.length; i += chunkSize) {
const chunk = wtLinks.slice(i, i + chunkSize);
await Promise.all(chunk.map((item, idx) => this.fetchWikidataOptimized(item.title, i + idx)));
}
$table.on('click', '#ill-cancel', () => {
$editform.show();
$wrapper.remove();
});
$table.on('click', '#ill-ok', () => this.onOkButton($editform, $wrapper, $textbox));
$table.on('change', '#ill-select-all', (e) => {
const isChecked = $(e.target).is(':checked');
$table.find('.ill-row-check:not(:disabled)').each(function() {
$(this).prop('checked', isChecked).trigger('change');
});
});
$table.on('change', '.ill-row-check', (e) => {
this.onCheckbox(e);
this.updateOkButtonState($table);
});
$table.on('change', 'input[type="radio"]', (e) => {
this.showSiteLinks(e);
});
},
async fetchWikidataOptimized(title, index) {
const query = title.replace(this.Disambiguator, "");
const url = `${this.DataApi}?action=wbsearchentities&search=${encodeURIComponent(query)}&language=${this.LocalLang}&limit=5&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
const results = data.search;
const $cell = $(`#ill-row-${index} .ill-wd-item`);
if (!results || !results.length) {
$cell.html('<span class="ill-na">(no results)</span>');
return;
}
const entityIds = results.map(r => r.id);
await this.fetchEntities(entityIds, index, $cell);
} catch (e) {
$(`#ill-row-${index} .ill-wd-item`).text("Search failed.");
}
},
async fetchEntities(ids, rowIndex, $cell) {
const url = `${this.DataApi}?action=wbgetentities&ids=${ids.join('|')}&props=labels|descriptions|claims|sitelinks&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
$cell.empty();
ids.forEach(qNum => {
const entity = data.entities[qNum];
if (!entity) return;
const isScientific = entity.claims?.P31?.some(s => s.mainsnak?.datavalue?.value?.id === "Q13442814");
if (isScientific && this.Config.IgnoreScientific === HIDE) return;
const desc = entity.descriptions?.[this.LocalLang]?.value || entity.descriptions?.en?.value || "";
const label = entity.labels?.[this.LocalLang]?.value || entity.labels?.en?.value || qNum;
const isStrikeout = isScientific && this.Config.IgnoreScientific === STRIKEOUT;
const $div = $(`
<div class="${isStrikeout ? 'illwill-ignore' : ''}">
<input type="radio" name="selection-${rowIndex}" value="${qNum}" id="radio-${qNum}" ${isStrikeout ? 'disabled' : ''}>
<label for="radio-${qNum}">
<a href="https://wikidata.org/wiki/${qNum}" target="_blank">${this.escapeHTML(label)}</a>:
<span class="ill-desc">${desc ? this.escapeHTML(desc) : '<i>no description</i>'}</span>
</label>
</div>
`);
$cell.append($div);
this.SiteLinks[qNum] = entity.sitelinks || {};
});
} catch (e) {
$cell.text("Detail fetch failed.");
}
},
updateOkButtonState($table) {
const $rows = $table.find('.ill-row-check:not(:disabled)');
const $checkedRows = $rows.filter(':checked');
const anyEnabled = $rows.length > 0;
$table.find('#ill-ok').prop('disabled', $checkedRows.length === 0);
const $master = $table.find('#ill-select-all');
$master.prop('disabled', !anyEnabled);
if (anyEnabled && $rows.length === $checkedRows.length) {
$master.prop('checked', true);
} else {
$master.prop('checked', false);
}
},
async showSiteLinks(e) {
const isManualToggling = e.fromCheckbox || false;
const qNum = e.target.value;
const $row = $(e.target).closest('tr');
const sitelinks = this.SiteLinks[qNum];
const $checkbox = $row.find('.ill-row-check');
const $sitelinkCell = $row.find('.ill-sitelinks');
const $outputCell = $row.find('.ill-output');
const $table = $('#ill-table');
const effectiveMax = Math.min(Math.max(this.Config.MaxLanguages, 3), 6);
const validKeys = Object.keys(sitelinks).filter(k => k.endsWith('wiki') && !k.includes('commons'));
$sitelinkCell.html('<span class="ill-loading-inline">Measuring articles...</span>');
$outputCell.empty();
if (!isManualToggling) {
$checkbox.prop('disabled', true).prop('checked', false);
}
const candidates = validKeys.slice(0, 25);
const sizeMap = [];
await Promise.all(candidates.map(async (key) => {
const lang = key.replace('wiki', '').replace(/_/g, '-');
const title = sitelinks[key].title;
const endpoint = `https://${lang}.wikipedia.org/w/api.php?action=query&titles=${encodeURIComponent(title)}&prop=info&format=json&origin=*`;
try {
const res = await fetch(endpoint).then(r => r.json());
const page = Object.values(res.query.pages)[0];
sizeMap.push({
lang,
title,
length: page.length || 0
});
} catch (err) {
sizeMap.push({
lang,
title,
length: 0
});
}
}));
sizeMap.sort((a, b) => b.length - a.length);
const topLinks = sizeMap.slice(0, effectiveMax);
$sitelinkCell.html(topLinks.map(item => {
const kbValue = Math.round(item.length / 1024);
return `<div><a href="https://${item.lang}.wikipedia.org/wiki/${encodeURIComponent(item.title)}" target="_blank">${item.lang}:${this.escapeHTML(item.title)}</a> <span class="ill-size-tag">(${kbValue} kB)</span></div>`;
}).join(""));
const inputWikitext = $row.find('.ill-input').text();
const match = inputWikitext.match(/\[\[(.*?)\]\]/);
if (match) {
const parts = match[1].split('|');
const targetPage = parts[0].trim();
const surfaceName = (parts[1] || parts[0]).trim();
const illLangs = topLinks.map(item => {
const isSameTitle = this.norm(item.title) === this.norm(targetPage);
return `|${item.lang}|${isSameTitle ? '' : item.title}`;
}).join("");
const ltParam = (this.norm(surfaceName) === this.norm(targetPage)) ? "" : `|lt=${surfaceName}`;
$outputCell.text(`{{ill|${targetPage}${ltParam}${illLangs}}}`);
}
$checkbox.prop('disabled', !topLinks.length);
if (!isManualToggling) {
$checkbox.prop('checked', !!topLinks.length);
}
this.updateOkButtonState($table);
},
onCheckbox(e) {
const $row = $(e.target).closest('tr');
const $radio = $row.find('input[type="radio"]:checked');
const isChecked = $(e.target).is(':checked');
if (isChecked) {
if ($radio.length) {
this.showSiteLinks({
target: $radio[0],
fromCheckbox: true
});
}
} else {
$row.find('.ill-sitelinks, .ill-output').empty();
}
},
onOkButton($editform, $wrapper, $textbox) {
let text = $textbox.textSelection('getContents');
let changed = false;
$wrapper.find('.ill-row-check:checked').each(function() {
const $row = $(this).closest('tr');
const input = $row.find('.ill-input').text();
const output = $row.find('.ill-output').text();
if (output) {
const escapedInput = input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
text = text.replace(new RegExp(escapedInput, "gi"), output);
changed = true;
}
});
if (changed) {
$('#wpSummary').val((i, v) => (v ? v + "; " : "") + this.Summary);
$textbox.textSelection('setContents', text);
$wrapper.remove();
$editform.show();
if (this.Config.AutoDiff) $('#wpDiff').click();
}
},
escapeHTML(str) {
return str.replace(/[&<>"']/g, m => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
})[m]);
}
};
window.IllWill = IllWill;
mw.loader.using(['mediawiki.util', 'mediawiki.api', 'jquery.textSelection'], () => {
$(() => IllWill.setup());
});
})();
(function() {
'use strict';
const config = {
enableNS14: true,
rootWhitelists: ['Materials'],
userWhitelist: ""
};
const categoryDataCache = {};
let scanProgress = 0;
let totalToScan = 0;
async function verifyTreeMembership() {
if (!config.enableNS14) return false;
const api = new mw.Api();
const currentTitle = mw.config.get("wgPageName").replace(/_/g, ' ');
const fullRoots = config.rootWhitelists.concat(config.userWhitelist.split(',').map(s => s.trim()).filter(s => s)).map(cat => (cat.startsWith('Category:') ? cat : `Category:${cat}`));
if (fullRoots.includes(currentTitle)) return true;
let categoriesToTable = mw.config.get('wgCategories') || [];
let checked = new Set();
while (categoriesToTable.length > 0) {
if (categoriesToTable.some(cat => fullRoots.includes('Category:' + cat))) {
return true;
}
categoriesToTable.forEach(cat => checked.add(cat));
try {
const res = await api.get({
formatversion: 2,
prop: 'categories',
titles: categoriesToTable.map(c => 'Category:' + c),
cllimit: 'max'
});
let nextBatch = [];
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.categories) {
page.categories.forEach(parent => {
const parentName = parent.title.substring('Category:'.length);
if (!checked.has(parentName)) {
nextBatch.push(parentName);
}
});
}
});
}
categoriesToTable = [...new Set(nextBatch)];
} catch (e) {
return false;
}
}
return false;
}
function getChemboxBlock(wikitext) {
const chemboxRegex = /\{\{\s*Chembox[\s_]*(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const match = wikitext.match(chemboxRegex);
return match ? match[0] : null;
}
function getFormulaFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const formulaMatch = chembox.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
let rawFormula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
rawFormula = rawFormula.split(/\s+/)[0];
return rawFormula.split(',')[0].trim();
}
const propRegex = /\{\{\s*Chembox[\s_]+Properties(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const propSection = chembox.match(propRegex);
if (propSection) {
const content = propSection[0];
const allElements = ['Ac', 'Ag', 'Al', 'Am', 'Ar', 'As', 'At', 'Au', 'B', 'Ba', 'Be', 'Bh', 'Bi', 'Bk', 'Br', 'C', 'Ca', 'Cd', 'Ce', 'Cf', 'Cl', 'Cm', 'Cn', 'Co', 'Cr', 'Cs', 'Cu', 'D', 'Db', 'Ds', 'Dy', 'Er', 'Es', 'Eu', 'F', 'Fe', 'Fl', 'Fm', 'Fr', 'Ga', 'Gd', 'Ge', 'H', 'He', 'Hf', 'Hg', 'Ho', 'Hs', 'I', 'In', 'Ir', 'K', 'Kr', 'La', 'Li', 'Lr', 'Lu', 'Lv', 'Mc', 'Md', 'Mg', 'Mn', 'Mo', 'Mt', 'N', 'Na', 'Nb', 'Nd', 'Ne', 'Nh', 'Ni', 'No', 'Np', 'O', 'Og', 'Os', 'P', 'Pa', 'Pb', 'Pd', 'Pm', 'Po', 'Pr', 'Pt', 'Pu', 'Ra', 'Rb', 'Re', 'Rf', 'Rg', 'Rh', 'Rn', 'Ru', 'S', 'Sb', 'Sc', 'Se', 'Sg', 'Si', 'Sm', 'Sn', 'Sr', 'Ta', 'Tb', 'Tc', 'Te', 'Th', 'Ti', 'Tl', 'Tm', 'Ts', 'U', 'V', 'W', 'Xe', 'Y', 'Yb', 'Zn', 'Zr'];
let data = {};
allElements.forEach(el => {
const elRegex = new RegExp(`\\|\\s*${el}\\s*=\\s*(\\d*\\.?\\d+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch && elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
});
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
elementsPresent.filter(el => !(hasC && (el === "C" || el === "H"))).sort().forEach(el => {
formulaArray.push(el + data[el]);
});
return formulaArray.join('');
}
}
return null;
}
function getCASFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const idRegex = /\{\{\s*Chembox[\s_]+Identifiers(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const idSection = chembox.match(idRegex);
if (!idSection) return null;
const content = idSection[0];
const nbsp = String.fromCharCode(160);
const casMatch = content.match(/\|\s*CASNo\d*\b(?!\s*_)\s*=\s*([^|}\n]+)/i);
if (casMatch) {
let val = casMatch[1].replace(/\{\{[^}]+\}\}/g, "").replace(/\u00AD/g, "").split(" ").join("");
while (val.indexOf(nbsp) !== -1) val = val.replace(nbsp, "");
while (val.indexOf(" ") !== -1) val = val.replace(" ", "");
const cleanedCas = val.trim();
return /^\d{2,7}-\d{2}-\d$/.test(cleanedCas) ? cleanedCas : null;
}
return null;
}
async function getWikidataValue() {
const qid = mw.config.get('wgWikidataItemId');
if (!qid) return null;
const api = new mw.ForeignApi('https://www.wikidata.org/w/api.php');
const res = await api.get({
action: 'wbgetentities',
ids: qid,
props: 'claims',
format: 'json',
formatversion: 2
});
const claims = res.entities[qid].claims;
const data = {};
if (claims.P274) data.formula = claims.P274[0].mainsnak.datavalue.value;
if (claims.P231) data.cas = claims.P231[0].mainsnak.datavalue.value;
return data;
}
async function startBackgroundCategoryScan() {
const api = new mw.Api();
const titles = $('#mw-pages li a').map((i, el) => $(el).attr('title') || $(el).text()).get();
totalToScan = titles.length;
if (totalToScan === 0) return;
const chunks = arrayChunk(titles, 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: chunk,
rvprop: 'content',
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const f = getFormulaFromWikitext(wikitext);
const c = getCASFromWikitext(wikitext);
if (f || c) categoryDataCache[page.title] = {
formula: f,
cas: c
};
});
}
scanProgress += chunk.length;
$('#caschemassist-cat-btn').text(`Scanning... ${scanProgress}/${totalToScan}`);
}
$('#caschemassist-cat-btn').text('Show missing CAS/Chem').prop('disabled', false);
}
async function renderCategoryAudit() {
const api = new mw.Api();
const missingCheckTitles = [];
Object.values(categoryDataCache).forEach(data => {
if (data.formula) missingCheckTitles.push(data.formula);
if (data.cas) missingCheckTitles.push(data.cas);
});
if (missingCheckTitles.length === 0) {
mw.notify("No valid Chembox data found in this category.");
return;
}
const existenceMap = {};
const chunks = arrayChunk([...new Set(missingCheckTitles)], 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
titles: chunk,
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(p => {
existenceMap[p.title] = !p.missing;
});
}
}
$('#mw-pages li').each(function() {
const title = $(this).find('a').attr('title') || $(this).find('a').text().trim();
const data = categoryDataCache[title];
if (!data) return;
if (data.formula && !existenceMap[data.formula]) {
$(this).append($('<span>').css({
'color': 'red',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [${data.formula}]`));
}
if (data.cas && !existenceMap[data.cas]) {
$(this).append($('<span>').css({
'color': 'darkred',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [CAS: ${data.cas}]`));
}
});
$('#caschemassist-cat-btn').text('Audit complete').prop('disabled', true);
}
async function handleSingleArticle() {
const api = new mw.Api();
const currentTitle = mw.config.get("wgTitle");
const [wikiRes, wikidata] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
getWikidataValue()
]);
const page = wikiRes.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const formula = getFormulaFromWikitext(wikitext);
const cas = getCASFromWikitext(wikitext);
if (wikidata) {
let mismatches = [];
if (formula && wikidata.formula && formula !== wikidata.formula) {
mismatches.push(`Formula mismatch: Chembox "${formula}" vs Wikidata "${wikidata.formula}"`);
}
if (cas && wikidata.cas && cas !== wikidata.cas) {
mismatches.push(`CAS mismatch: Chembox "${cas}" vs Wikidata "${wikidata.cas}"`);
}
if (mismatches.length > 0) {
const $warning = $('<div>').css({
'background': '#fff2f2',
'border': '2px solid #d33',
'padding': '10px',
'margin-bottom': '1em',
'color': '#b32424'
}).append($('<strong>').text('⚠️ Data Mismatch Detected: '), mismatches.join(' | '));
$('#mw-content-text').prepend($warning);
}
}
async function checkAndRender(target, label, customWikitext) {
const check = await api.get({
action: 'query',
titles: target,
formatversion: 2
});
if (check.query.pages[0].missing) {
const $container = $('<div>').css({
'background': '#f8f9fa',
'border': '1px solid #a2a9b1',
'padding': '10px',
'margin-bottom': '1em'
});
const $btn = $('<button>').text(`Create redirect: ${target}`).click(async function() {
await api.postWithEditToken({
action: 'edit',
title: target,
text: customWikitext,
summary: `Creating redirect to [[${currentTitle}]] (${label})`,
createonly: true
});
mw.notify(`Created redirect: ${target}`);
$container.fadeOut(400, function() {
$(this).remove();
});
});
$container.append($('<strong>').text(`${label}: `), `Missing redirect. `, $btn);
$('#mw-content-text').prepend($container);
}
}
if (formula && formula !== currentTitle) {
const formulaWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from chemical formula}}\n}}`;
await checkAndRender(formula, "Chemical formula", formulaWikitext);
}
if (cas && cas !== currentTitle) {
const casWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from CAS Registry Number}}\n{{R unprintworthy}}\n}}`;
await checkAndRender(cas, "CAS number", casWikitext);
}
}
function arrayChunk(arr, size) {
const result = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
$(document).ready(async function() {
const ns = mw.config.get("wgNamespaceNumber");
if (ns === 14) {
if (await verifyTreeMembership()) {
const $catContainer = $('#mw-pages');
if ($catContainer.length) {
const $btn = $('<button>').text('Initializing scan...').attr('id', 'caschemassist-cat-btn').addClass('mw-ui-button').css({
'margin': '0 10px 10px 0',
'float': 'right',
'position': 'relative',
'z-index': '100'
}).prop('disabled', true).click(renderCategoryAudit);
$catContainer.prepend($btn);
startBackgroundCategoryScan();
}
}
} else if (ns === 0 && mw.config.get("wgAction") === "view") {
handleSingleArticle();
}
});
})();
//</nowiki>
0qycug3z3jg7tv19o0nng54i695wph4
739300
739299
2026-04-25T08:04:36Z
Sam Sailor
26820
Test
739300
javascript
text/javascript
//<nowiki>
(function() {
mw.util.addCSS(`
.ambox-Orphan { display: block !important; }
.comrade-container {
box-shadow: 0 1px 3px rgba(0,0,0,0.1); border-radius: 2px;
padding: 10px 15px; margin: 10px 0; display: flex;
flex-direction: column; align-items: flex-start; gap: 8px;
border: 1px solid #a2a9b1; background: #fcfcfc; line-height: 1.5;
}
.comrade-header { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.comrade-content { display: flex; flex-direction: column; gap: 5px; width: 100%; }
.comrade-success { background-color: #d5f5e3 !important; border-left: 5px solid #27ae60 !important; color: #1b5e20 !important; }
.comrade-nudge { background: #fff9ea; border-left: 5px solid #f0ad4e; }
.comrade-info { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-status { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-container button:not(.comrade-dismiss) {
background: #f8f9fa; border: 1px solid #a2a9b1; border-radius: 2px;
padding: 2px 10px; cursor: pointer; font-weight: bold; font-size: 0.95em; margin-left: 10px;
}
.comrade-container button:not(.comrade-dismiss):hover { border-color: #36c; color: #36c; background: #fff; }
.comrade-dismiss {
background: transparent; border: none; color: #d33; font-weight: bold;
cursor: pointer; font-size: 0.9em;
}
.comrade-dismiss:hover { text-decoration: underline; }
.comrade-code-block { background: #f1f1f1; padding: 4px 8px; border-radius: 3px; font-family: monospace; display: inline-block; margin-top: 4px; }
`);
function appendDismiss($el) {
$('<button>').addClass('comrade-dismiss').text('Dismiss').on('click', function() {
$(this).closest('.comrade-container').fadeOut();
}).appendTo($el.find('.comrade-header'));
$("#siteSub").after($el);
}
async function checkNamingPrecision(currentTitle, isHuman) {
const disambigMatch = currentTitle.match(/^(.+?)(?:, | \()/);
if (isHuman || !disambigMatch) return;
const baseTitle = disambigMatch[1].trim();
const api = new mw.Api();
try {
const baseRes = await api.get({
action: 'query',
titles: baseTitle,
redirects: 1,
formatversion: 2
});
const page = baseRes.query.pages[0];
const baseExists = !page.missing;
let baseRedirectsToSelf = false;
if (baseRes.query.redirects) {
baseRedirectsToSelf = baseRes.query.redirects.some(r => r.to === currentTitle);
}
const searchRes = await api.get({
action: 'query',
list: 'allpages',
apprefix: baseTitle,
aplimit: 50,
formatversion: 2
});
const competitors = searchRes.query.allpages.filter(p => {
const t = p.title;
return t !== currentTitle && t !== baseTitle && (t.startsWith(baseTitle + ', ') || t.startsWith(baseTitle + ' ('));
});
if (!baseExists || baseRedirectsToSelf) {
const $container = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (competitors.length === 0) {
$header.append($('<strong>').text('Precision:'));
const reason = baseRedirectsToSelf ? "already redirects here" : "is free";
$content.append($('<span>').append(`The base name `, $('<code>').text(baseTitle), ` ${reason}. Consider moving this article.`));
const moveUrl = mw.util.getUrl('Special:MovePage/' + currentTitle, {
wpNewTitle: baseTitle,
wpReason: 'Unnecessary disambiguation; base name is free/redirects here per [[WP:PRECISION]]'
});
$content.append($('<button>').text(`Move to ${baseTitle}`).on('click', () => window.open(moveUrl, '_blank')));
} else {
$header.append($('<strong>').text('Observation:'));
const state = baseRedirectsToSelf ? 'redirect to this page' : 'redlink';
$content.append($('<span>').append(`Base name `, $('<code>').text(baseTitle), ` has ${competitors.length} competitors, but it is currently a ${state}.`));
$content.append($('<span>').text('Consider if a disambiguation page is needed.'));
}
$container.append($header, $content);
appendDismiss($container);
}
} catch (e) {
console.warn("Comrade: Precision check failed", e);
}
}
async function checkAndRenderRedirect(redirTitle, targetTitle, rCategory, labelText, customWikitext) {
const api = new mw.Api();
const res = await api.get({
action: 'query',
titles: redirTitle,
formatversion: 2
});
if (res.query.pages[0].missing) {
const safeId = "comrade-redir-" + redirTitle.replace(/[^a-z0-9]/gi, '-');
const $container = $('<div>').addClass('comrade-container comrade-info').attr('id', safeId);
const $header = $('<div>').addClass('comrade-header');
const summary = `Created redirect from ${labelText.toLowerCase()} to [[${targetTitle}]]`;
const $btn = $('<button>').text('Create redirect').on('click', () => createModernRedirect(redirTitle, targetTitle, rCategory, summary, safeId, customWikitext));
$header.append($('<div>').append($('<strong>').text(`${labelText}: `), `Create redirect from `, $('<code>').text(redirTitle), $btn));
$container.append($header);
appendDismiss($container);
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
if (qid) {
try {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
});
const res = await fetch(url).then(r => r.json());
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
}
} catch (e) {
console.warn("Comrade: Wikidata API call failed.");
}
}
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
if (domain.includes(".") && url.pathname === "/") {
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
try {
const response = await fetch(citationURL);
if (response.ok) {
await checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
}
} catch (e) {
console.warn("Comrade: Citation API check failed.");
}
}
} catch (e) {
console.warn("Comrade: Error parsing URL object.");
}
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan, resolvedTitle) {
const api = new mw.Api();
const candidates = resolvedTitle ? [resolvedTitle] : [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
redirects: 0
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const foundTitle = page.title;
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) break;
const $container = $('<div>').addClass('comrade-container');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (isOrphan) {
$container.addClass('comrade-nudge');
$header.append($('<strong>').text('Comrade nudge:'));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
}
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(foundTitle),
target: '_blank'
}).text(foundTitle), "; should it be?"));
const snippet = '* {{anbl|' + currentTitle + '}}';
const $snippetWrapper = $('<div>').css({
'display': 'flex',
'align-items': 'center',
'gap': '10px',
'margin-top': '4px'
});
const $instructionSpan = $('<span>').append('If the Short description is good, consider adding ', $('<code>').text(snippet).css({
'background-color': '#f8f9fa',
'border': '1px solid #eaecf0',
'border-radius': '2px',
'padding': '1px 4px'
}));
const $copyBtn = $('<button>').text('Copy & insert').css('margin-left', '0').on('click', function() {
navigator.clipboard.writeText(snippet).then(() => {
const $this = $(this);
const originalText = $this.text();
$this.text('Copied!').prop('disabled', true);
const editUrl = mw.util.getUrl(foundTitle, {
action: 'edit',
summary: snippet
});
window.open(editUrl, '_blank');
setTimeout(() => {
$this.text(originalText).prop('disabled', false);
}, 1500);
});
});
$snippetWrapper.append($instructionSpan, $copyBtn);
$content.append($snippetWrapper);
if (!hasShortDesc) {
$content.append($('<span>').css({
'color': '#d33',
'font-weight': 'bold',
'margin-top': '5px'
}).text('⚠️ Missing short description: Please add a concise one before listing.'));
}
$container.append($header, $content);
appendDismiss($container);
break;
}
}
}
}
async function checkQualityConsistency() {
const oresClass = getOresPrediction();
if (!oresClass || oresClass === 'Stub') return;
const talkTitle = 'Talk:' + mw.config.get('wgPageName');
const api = new mw.Api();
try {
const [talkRes, pageRes] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: talkTitle,
rvprop: 'content',
formatversion: 2
}),
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
})
]);
const talkPage = talkRes.query.pages[0];
const wikitext = pageRes.query.pages[0].revisions[0].content;
const hasStubTag = /\{\{[^}]*stub\}\}/i.test(wikitext);
const $raterLink = $('#ca-rater, #t-rater');
const isRaterAvailable = (typeof window.rater !== 'undefined' || $raterLink.length > 0);
let talkClass = null;
if (!talkPage.missing) {
const classMatch = talkPage.revisions[0].content.match(/\|class\s*=\s*([^|}\s]+)/i);
talkClass = classMatch ? classMatch[1].charAt(0).toUpperCase() + classMatch[1].slice(1).toLowerCase() : null;
}
const isStubRated = talkClass === 'Stub';
const lingeringStubTag = (talkClass && talkClass !== 'Stub' && oresClass !== 'Stub' && hasStubTag);
if (talkPage.missing || isStubRated || lingeringStubTag) {
const $container = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').append($('<strong>').text('Quality:'), " ");
let message = `ORES suggests <b>${oresClass}</b>. `;
if (talkPage.missing) {
message = `Talk page missing; ORES suggests <b>${oresClass}</b>. `;
let $btn;
if (isRaterAvailable) {
$btn = $('<button>').text('Open Rater').on('click', () => $raterLink.find('a')[0].click());
} else {
$btn = $('<button>').text('Install Rater').on('click', () => window.open('https://en.wikipedia.org/wiki/User:Evad37/rater', '_blank'));
message += "Consider installing <i>Rater</i> for easy assessment.";
}
$content.append(message, $btn);
} else if (lingeringStubTag && !isStubRated) {
message = `Article is ${talkClass}-class, but stub tags remain. `;
const $btn = $('<button>').text('Remove stub tags').on('click', () => performStubRemoval());
$content.append(message, $btn);
} else if (isStubRated) {
message = `Talk page says Stub, but ORES predicts <b>${oresClass}</b>. `;
const $btnOres = $('<button>').text(`Promote to ${oresClass}`).on('click', () => performPromotion(oresClass));
$content.append(message, $btnOres);
if (oresClass === 'C') {
const $btnStart = $('<button>').text(`Promote to Start`).on('click', () => performPromotion('Start'));
$content.append(" or ", $btnStart);
} else if (oresClass === 'B') {
const $btnC = $('<button>').text(`Promote to C`).on('click', () => performPromotion('C'));
$content.append($('<span>').text(' or ').css('margin', '0 5px'), $btnC);
}
}
$header.append($content);
$container.append($header);
appendDismiss($container);
}
} catch (e) {
console.error("Comrade quality check failed", e);
}
}
async function performStubRemoval() {
const api = new mw.Api();
try {
const pageData = await api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
});
let wikitext = pageData.query.pages[0].revisions[0].content;
const newWikitext = wikitext.replace(/\{\{[^}]*stub\}\}\n?/gi, '').trim();
if (wikitext === newWikitext) {
mw.notify("No stub tags found to remove.");
return;
}
await api.postWithToken('csrf', {
action: 'edit',
title: mw.config.get("wgPageName"),
text: newWikitext,
summary: `Removing lingering stub tags per ORES/Talk assessment`,
nocreate: true
});
location.reload();
} catch (err) {
console.error("Comrade failed to remove stub tags:", err);
mw.notify("Error removing stub tags.", {
type: 'error'
});
}
}
async function createModernRedirect(redirTitle, targetTitle, rCategory, customSummary, elementId, customWikitext) {
const api = new mw.Api();
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
try {
await api.postWithEditToken({
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
});
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
} catch (e) {
mw.notify("Failed to create redirect.", {
type: 'error'
});
}
}
function getOresPrediction() {
// Dependency: [[m:User:EpochFail/ArticleQuality-system.js]] renders the prediction inside .article_quality
const text = $('.article_quality').text();
if (!text) return null;
if (text.includes('Start')) return 'Start';
if (text.includes('C')) return 'C';
if (text.includes('B')) return 'B';
return null;
}
function getSmartSortKey(fullName, entity) {
const countryIds = entity.claims?.P27?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const locationIds = entity.claims?.P17?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const placeIds = [...(entity.claims?.P19 || []), ...(entity.claims?.P119 || [])].map(c => c.mainsnak?.datavalue?.value?.id).filter(Boolean);
const allRelevantIds = [...countryIds, ...locationIds, ...placeIds];
const birthYear = parseInt(entity.claims?.P569?.[0]?.mainsnak?.datavalue?.value?.time?.match(/[+-](\d{4})/)?.[1]) || null;
if (fullName.startsWith("Saint ")) return fullName.replace("Saint ", "");
const arabicParticles = /\b(Abu|Abd|Abdel|Abdul|ben|bin|bint)\b/i;
if (fullName.match(arabicParticles)) {
if (birthYear && birthYear > 1900) {
const parts = fullName.split(' ');
const binIndex = parts.findIndex(p => p.toLowerCase() === 'bin' || p.toLowerCase() === 'ben');
if (binIndex > 0) return parts.slice(binIndex).join(' ') + ', ' + parts.slice(0, binIndex).join(' ');
} else {
return fullName;
}
}
const eastAsianQids = ["Q148", "Q184", "Q884", "Q424", "Q881", "Q17"];
if (allRelevantIds.some(id => eastAsianQids.includes(id))) {
if (allRelevantIds.includes("Q17") && birthYear && birthYear > 1885) return westernSort(fullName);
const parts = fullName.split(' ');
if (parts.length > 1) return `${parts[0]}, ${parts.slice(1).join(' ')}`;
}
return westernSort(fullName);
}
function westernSort(name) {
let cleanName = name;
if (name.startsWith("O'")) cleanName = name.replace("O'", "O");
const parts = cleanName.split(' ');
if (parts.length < 2) return cleanName;
const surname = parts.pop();
if (["Jr.", "Jr", "III", "II", "Sr.", "Sr"].includes(surname)) {
const actualSurname = parts.pop();
return `${actualSurname}, ${parts.join(' ')} ${surname}`;
}
return `${surname}, ${parts.join(' ')}`;
}
async function performDeorphan() {
const api = new mw.Api();
try {
await api.edit(mw.config.get('wgPageName'), function(rev) {
let text = rev.content;
const summary = 'Article has backlinks; removed [[Template:Orphan|{{Orphan}}]]';
text = text.replace(/\{\{Orphan\s*(?:\|[^}]*)?\}\}\s*/gi, '');
text = text.replace(/(\{\{Multiple[ _]issues\s*)\|\s*\|/gi, '$1|');
text = replaceMI(text);
text = text.replace(/\n{3,}/g, '\n\n').trim();
return {
text,
summary,
minor: true
};
});
mw.notify('Orphan tag removed!', {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
setTimeout(() => location.reload(), 700);
} catch (e) {
mw.notify('Failed to remove orphan tag.', {
type: 'error'
});
}
}
async function performPromotion(newClass) {
const api = new mw.Api();
const pageName = mw.config.get('wgPageName');
const talkName = 'Talk:' + pageName;
try {
const talkData = await api.get({
action: 'query',
prop: 'revisions',
titles: talkName,
rvprop: 'content',
formatversion: 2
});
let talkWikitext = talkData.query.pages[0].revisions[0].content;
talkWikitext = talkWikitext.replace(/(\|class\s*=\s*)Stub/i, `$1${newClass}`);
await api.postWithEditToken({
action: 'edit',
title: talkName,
text: talkWikitext,
summary: `Promoting article to ${newClass} class per ORES prediction [[WP:ORES]]`
});
const artData = await api.get({
action: 'query',
prop: 'revisions',
titles: pageName,
rvprop: 'content',
formatversion: 2
});
let artWikitext = artData.query.pages[0].revisions[0].content;
const stubRegex = /\{\{[^}]*?-stub\}\}\s*\n?/gi;
const genericStubRegex = /\{\{stub\}\}\s*/gi;
artWikitext = artWikitext.replace(stubRegex, '').replace(genericStubRegex, '');
await api.postWithEditToken({
action: 'edit',
title: pageName,
text: artWikitext,
summary: `Removing stub tags; article promoted to ${newClass}`
});
location.reload();
} catch (e) {
alert("Promotion failed. Check console for details.");
console.error(e);
}
}
function replaceMI(text) {
const miRegex = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miRegex.exec(text)) !== null) {
const start = match.index;
result += text.slice(lastIndex, start);
let depth = 0;
let i = start;
while (i < text.length) {
if (text[i] === '{' && text[i + 1] === '{') {
depth++;
i += 2;
} else if (text[i] === '}' && text[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
const end = i;
const full = text.slice(start, end);
const pipeIdx = full.indexOf('|');
const inner = full.slice(pipeIdx + 1, full.length - 2).trim();
const topLevel = extractTopLevelTemplates(inner);
if (topLevel.length === 0) {
result += '';
} else if (topLevel.length === 1) {
result += topLevel[0].trim();
} else {
result += full;
}
lastIndex = end;
}
result += text.slice(lastIndex);
return result;
}
function extractTopLevelTemplates(inner) {
const templates = [];
let i = 0;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
let depth = 0;
const start = i;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
depth++;
i += 2;
} else if (inner[i] === '}' && inner[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
templates.push(inner.slice(start, i));
} else {
i++;
}
}
return templates;
}
async function performSortCorrection(newName, type) {
const api = new mw.Api();
const title = mw.config.get("wgPageName");
try {
const data = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2
});
let content = data.query.pages[0].revisions[0].content;
const dsRegex = /\{\{(?:DEFAULTSORT|Defaultsort):[^}]+\}\}/i;
const newTag = `{{DEFAULTSORT:${newName}}}`;
let actionDesc = type === "add" ? "added" : "corrected";
if (dsRegex.test(content)) {
content = content.replace(dsRegex, newTag);
} else {
const catRegex = /\[\[Category:/i;
if (catRegex.test(content)) {
content = content.replace(catRegex, `${newTag}\n[[Category:`);
} else {
content += `\n\n${newTag}`;
}
}
await api.postWithEditToken({
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName}`,
nocreate: true
});
mw.notify(`DEFAULTSORT ${actionDesc} to ${newName}!`, {
title: 'Comrade Success',
type: 'success'
});
setTimeout(() => location.reload(), 700);
} catch (err) {
mw.notify('Failed to save DEFAULTSORT.', {
type: 'error'
});
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
function renderSortNudge(target, reason) {
const $dsNudge = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Correct to: ${target}`).on('click', () => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
async function fetchWikidataNameLabels(entity) {
const familyIds = entity.claims?.P734?.map(c => c.mainsnak.datavalue.value.id) || [];
const givenIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
const allNameIds = [...new Set([...familyIds, ...givenIds])];
if (allNameIds.length === 0) return {
givens: [],
surnames: []
};
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: allNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
});
const nameData = await fetch(url).then(r => r.json());
return {
givens: givenIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean),
surnames: familyIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean)
};
}
function fixNameCasing(part, referenceName) {
return part.split(' ').map(word => {
const match = referenceName.match(new RegExp(`\\b${word}\\b`, 'i'));
if (match) return match[0];
return referenceName.split(' ').find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase())) || word;
}).join(' ');
}
function getLongNameFromWikitext(wikitext) {
const firstLine = wikitext.split('\n').find(l => l.includes("'''"));
if (!firstLine) return null;
const boldMatch = firstLine.match(/'''(.+?)'''/);
if (!boldMatch) return null;
return boldMatch[1].replace(/\s*([“"«《„‹「『'].+?[”"»》”›」』']|\([^)\n]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
}
async function init() {
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") return;
const api = new mw.Api();
const qid = mw.config.get("wgWikibaseItemId");
try {
const [pageData, backlinkData] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
api.get({
action: 'query',
list: 'backlinks',
blfilterredir: 'nonredirects',
bllimit: 50,
blnamespace: 0,
bltitle: mw.config.get("wgPageName"),
formatversion: 2
})
]);
const page = pageData.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const currentTitle = mw.config.get("wgTitle");
const cleanTitle = stripDiacritics(currentTitle);
const isOrphanTagged = /\{\{\s*(Orphan|Do-attempt|Lonely|Orp|Orphaned article)/i.test(wikitext) || /\| *orphan *=/i.test(wikitext);
const backLinkCount = backlinkData.query.backlinks.length;
if (isOrphanTagged && backLinkCount >= 1) {
const $container = $('<div>').addClass('comrade-container comrade-status');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text('Remove orphan tag').on('click', () => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) await checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
await checkDomainRedirect(qid, currentTitle);
let isHuman = false;
let entity = null;
if (qid) {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
});
const wikiData = await fetch(url).then(r => r.json());
entity = wikiData.entities[qid];
isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
}
await checkNamingPrecision(currentTitle, isHuman);
if (qid && entity) {
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const nameParts = tClean.split(' ');
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
const isOrphan = isOrphanTagged && backLinkCount < 1;
const primarySurname = hasFamilyNameHatnote ? nameParts[0] : (getSmartSortKey(tClean, entity).split(',')[0] || nameParts[nameParts.length - 1]);
let targetSortName = "";
if (isHuman) {
const {
givens,
surnames
} = await fetchWikidataNameLabels(entity);
const refName = entity.labels?.en?.value || currentTitle;
if (dsMatch) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixNameCasing(lastPart, refName)}, ${fixNameCasing(firstPart, refName)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field.");
} else if (givens.length > 0 && lastPart.split(' ').length > 1) {
const misplacedGiven = lastPart.split(' ').find(part => givens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastPart.split(' ').filter(p => p !== misplacedGiven).join(' ');
targetSortName = `${fixNameCasing(newSurname, refName)}, ${fixNameCasing(misplacedGiven + ' ' + firstPart, refName)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
}
}
} else {
targetSortName = hasFamilyNameHatnote ? `${nameParts[0]}, ${nameParts.slice(1).join(' ')}` : getSmartSortKey(tClean, entity);
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).on('click', () => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
const longName = getLongNameFromWikitext(wikitext);
if (longName && longName.length > tClean.length) {
let sortKey = hasFamilyNameHatnote ? `${longName.split(' ')[0]}, ${longName.split(' ').slice(1).join(' ')}` : getSmartSortKey(longName, entity);
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
await checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
if (targetSortName) {
let targetPage = currentTitle.includes(' (') ? currentTitle.split(' (')[0] : currentTitle;
let rCat = targetPage !== currentTitle ? "R from ambiguous sort name" : "R from sort name";
if (targetSortName.includes(',')) {
const p = targetSortName.split(',');
rCat += `|${p[0].trim().charAt(0).toUpperCase()}|${p[1].trim().charAt(0).toUpperCase()}`;
}
await checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name");
}
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
surnames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
givens.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) finalGivenNames.add(gn);
});
if (finalFamilyNames.size === 0 && nameParts.length >= 2) finalFamilyNames.add(nameParts[nameParts.length - 1]);
if (finalGivenNames.size === 0 && nameParts.length >= 2) finalGivenNames.add(nameParts[0]);
const getPriorityTarget = async (name, type) => {
const titles = type === 'surname' ? [`List of people with surname ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`] : [`List of people with given name ${name}`, `${name} (given name)`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
return titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
};
for (const name of finalGivenNames) {
if (name.toLowerCase() === primarySurname.toLowerCase()) continue;
const target = await getPriorityTarget(name, 'given');
if (target) await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
for (const name of finalFamilyNames) {
const titles = [name, `${name} (surname)`, `List of people with surname ${name}`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
const target = titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
if (target) await checkNameList(name, 'surname', currentTitle, hasShortDesc, isOrphan, target);
}
}
const oresWait = setInterval(() => {
if ($('.article_quality').length) {
clearInterval(oresWait);
checkQualityConsistency();
}
}, 500);
setTimeout(() => clearInterval(oresWait), 5000);
}
} catch (err) {
console.error("Comrade error:", err);
}
}
init();
})();
(function() {
if (window.IllWill) return;
const STRIKEOUT = true;
const HIDE = 'hide';
const IllWill = {
Version: "1.1.1",
Attribution: 'created by <a target="_blank" href="/wiki/User:Cobaltcigs">cobaltcigs</a>',
Summary: "Added interlanguage links using IllWill.js",
DataApi: "https://www.wikidata.org/w/api.php",
LocalLang: mw.config.get("wgPageContentLanguage"),
Disambiguator: / \(.*\)$/,
SiteLinks: {},
Config: {
MaxLanguages: 5,
IgnoreScientific: STRIKEOUT,
AutoDiff: true
},
css: `
#illwill { clear: both; margin-bottom: 1.5em; border: 1px solid #a2a9b1; padding: 15px; background: #f8f9fa; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.ill-sitelinks a { display: inline-block; margin-bottom: 3px; font-size: 0.9em; color: #36c; }
.ill-size-tag { font-size: 0.8em; color: #72777d; margin-left: 6px; white-space: nowrap; }
.illwill-ignore { text-decoration: line-through; color: #72777d; font-size: 0.85em; }
.ill-x { width: 35px; text-align: center; }
.ill-input, .ill-output { font-family: 'Courier New', monospace; white-space: pre-wrap; font-size: 0.9em; background: #fff; border: 1px solid #eaecf0; padding: 4px; border-radius: 2px; }
#ill-buttons { text-align: right; padding: 10px; border-top: 1px solid #a2a9b1; margin-top: 10px; }
#ill-buttons button { margin-left: 10px; cursor: pointer; padding: 6px 16px; border-radius: 2px; border: 1px solid #a2a9b1; transition: all 0.2s; }
#ill-ok { background: #36c; color: #fff; border-color: #36c !important; font-weight: bold; }
#ill-ok:hover { background: #447ff5; }
#ill-ok:disabled { background: #eaecf0; color: #72777d; border-color: #c8ccd1 !important; cursor: not-allowed; opacity: 0.7; }
#ill-table { width: 100%; border-collapse: collapse; margin-top: 10px; background: white; }
#ill-table th { background: #f2f2f2; text-align: left; padding: 8px; border: 1px solid #a2a9b1; }
#ill-table td { padding: 8px; border: 1px solid #a2a9b1; vertical-align: top; }
#ill-table caption { background: #eaffea; font-weight: bold; padding: 10px; border: 1px solid #a2a9b1; border-bottom: none; font-size: 1.1em; }
#ill-help { font-size: 0.85em; color: #54595d; padding: 10px; line-height: 1.4; }
.ill-loading-inline { font-style: italic; color: #72777d; font-size: 0.9em; display: block; margin: 5px 0; }
`,
setup() {
if (!["edit", "submit"].includes(mw.config.get('wgAction'))) return;
mw.loader.addStyleTag(this.css);
const link = mw.util.addPortletLink('p-cactions', '#', 'IllWill', 'ca-illwill', "Add interlanguage links via Wikidata");
if (link) {
link.addEventListener('click', (e) => {
e.preventDefault();
this.makePanel();
});
}
},
norm(s) {
if (!s) return "";
const t = s.trim().replace(/[\s_]+/g, " ");
return t.charAt(0).toUpperCase() + t.slice(1);
},
async getRedLinks($textbox) {
const txt = $textbox.textSelection('getContents') || "";
const wl = txt.match(/\[\[[^[\]\n]+\]\]/g);
if (!wl) return {
reds: [],
txt
};
const api = new mw.Api();
const textToParse = wl.join("").replace(/\|[^\]]+/g, "");
try {
const data = await api.post({
action: 'parse',
text: textToParse,
contentmodel: 'wikitext',
formatversion: 2
});
const tempDiv = document.createElement('div');
tempDiv.innerHTML = data.parse.text;
const reds = Array.from(tempDiv.querySelectorAll("a.new")).map(x => {
const urlParams = new URLSearchParams(x.href.split('?')[1]);
return urlParams.get('title')?.replace(/_/g, " ");
}).filter(Boolean);
return {
reds: [...new Set(reds)],
txt
};
} catch (e) {
return {
reds: [],
txt
};
}
},
async makePanel() {
const $editform = $('#editform');
const $textbox = $('#wpTextbox1');
$('#illwill').remove();
$editform.hide();
const $wrapper = $('<div id="illwill">Loading redlinks...</div>').insertBefore($editform);
const {
reds,
txt
} = await this.getRedLinks($textbox);
const wtLinks = [];
reds.forEach(title => {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s/g, "[\\s_]+");
const re = new RegExp("\\[\\[\\s*" + escaped + "\\s*(?:\\|[^\\[\\]]+)?\\]\\]", "ig");
const match = txt.match(re);
if (match && !wtLinks.some(z => z.title === title)) {
wtLinks.push({
link: match[0],
title
});
}
});
if (!wtLinks.length) {
const $back = $('<button>Quit</button>').on('click', () => {
$wrapper.remove();
$editform.show();
});
$wrapper.empty().append($('<strong>No valid redlinks found in text.</strong> '), $back);
return;
}
const $table = $(`
<table id="ill-table" class="wikitable">
<caption>IllWill.js (v${this.Version})</caption>
<thead>
<tr>
<th class="ill-x"><input type="checkbox" id="ill-select-all" disabled></th>
<th>Input link</th>
<th>Possible Wikidata matches</th>
<th>Sitelinks (top ${this.Config.MaxLanguages} by size)</th>
<th>Resulting wikitext</th>
</tr>
</thead>
<tbody></tbody>
<tfoot>
<tr>
<td colspan="3" id="ill-help">Carefully examine possible Wikidata matches.</td>
<td colspan="2" id="ill-buttons">
<button id="ill-cancel">Cancel</button>
<button id="ill-ok" disabled>Show changes</button>
</td>
</tr>
</tfoot>
</table>
`);
const $tbody = $table.find('tbody');
wtLinks.forEach((item, i) => {
$tbody.append(`
<tr id="ill-row-${i}" data-index="${i}">
<td class="ill-x"><input type="checkbox" class="ill-row-check" disabled></td>
<td class="ill-input">${this.escapeHTML(item.link)}</td>
<td class="ill-wd-item"><span class="ill-loading-inline">Searching...</span></td>
<td class="ill-sitelinks"></td>
<td class="ill-output"></td>
</tr>
`);
});
$wrapper.empty().append($table);
const chunkSize = 5;
for (let i = 0; i < wtLinks.length; i += chunkSize) {
const chunk = wtLinks.slice(i, i + chunkSize);
await Promise.all(chunk.map((item, idx) => this.fetchWikidataOptimized(item.title, i + idx)));
}
$table.on('click', '#ill-cancel', () => {
$editform.show();
$wrapper.remove();
});
$table.on('click', '#ill-ok', () => this.onOkButton($editform, $wrapper, $textbox));
$table.on('change', '#ill-select-all', (e) => {
const isChecked = $(e.target).is(':checked');
$table.find('.ill-row-check:not(:disabled)').each(function() {
$(this).prop('checked', isChecked).trigger('change');
});
});
$table.on('change', '.ill-row-check', (e) => {
this.onCheckbox(e);
this.updateOkButtonState($table);
});
$table.on('change', 'input[type="radio"]', (e) => {
this.showSiteLinks(e);
});
},
async fetchWikidataOptimized(title, index) {
const query = title.replace(this.Disambiguator, "");
const url = `${this.DataApi}?action=wbsearchentities&search=${encodeURIComponent(query)}&language=${this.LocalLang}&limit=5&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
const results = data.search;
const $cell = $(`#ill-row-${index} .ill-wd-item`);
if (!results || !results.length) {
$cell.html('<span class="ill-na">(no results)</span>');
return;
}
const entityIds = results.map(r => r.id);
await this.fetchEntities(entityIds, index, $cell);
} catch (e) {
$(`#ill-row-${index} .ill-wd-item`).text("Search failed.");
}
},
async fetchEntities(ids, rowIndex, $cell) {
const url = `${this.DataApi}?action=wbgetentities&ids=${ids.join('|')}&props=labels|descriptions|claims|sitelinks&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
$cell.empty();
ids.forEach(qNum => {
const entity = data.entities[qNum];
if (!entity) return;
const isScientific = entity.claims?.P31?.some(s => s.mainsnak?.datavalue?.value?.id === "Q13442814");
if (isScientific && this.Config.IgnoreScientific === HIDE) return;
const desc = entity.descriptions?.[this.LocalLang]?.value || entity.descriptions?.en?.value || "";
const label = entity.labels?.[this.LocalLang]?.value || entity.labels?.en?.value || qNum;
const isStrikeout = isScientific && this.Config.IgnoreScientific === STRIKEOUT;
const $div = $(`
<div class="${isStrikeout ? 'illwill-ignore' : ''}">
<input type="radio" name="selection-${rowIndex}" value="${qNum}" id="radio-${qNum}" ${isStrikeout ? 'disabled' : ''}>
<label for="radio-${qNum}">
<a href="https://wikidata.org/wiki/${qNum}" target="_blank">${this.escapeHTML(label)}</a>:
<span class="ill-desc">${desc ? this.escapeHTML(desc) : '<i>no description</i>'}</span>
</label>
</div>
`);
$cell.append($div);
this.SiteLinks[qNum] = entity.sitelinks || {};
});
} catch (e) {
$cell.text("Detail fetch failed.");
}
},
updateOkButtonState($table) {
const $rows = $table.find('.ill-row-check:not(:disabled)');
const $checkedRows = $rows.filter(':checked');
const anyEnabled = $rows.length > 0;
$table.find('#ill-ok').prop('disabled', $checkedRows.length === 0);
const $master = $table.find('#ill-select-all');
$master.prop('disabled', !anyEnabled);
if (anyEnabled && $rows.length === $checkedRows.length) {
$master.prop('checked', true);
} else {
$master.prop('checked', false);
}
},
async showSiteLinks(e) {
const isManualToggling = e.fromCheckbox || false;
const qNum = e.target.value;
const $row = $(e.target).closest('tr');
const sitelinks = this.SiteLinks[qNum];
const $checkbox = $row.find('.ill-row-check');
const $sitelinkCell = $row.find('.ill-sitelinks');
const $outputCell = $row.find('.ill-output');
const $table = $('#ill-table');
const effectiveMax = Math.min(Math.max(this.Config.MaxLanguages, 3), 6);
const validKeys = Object.keys(sitelinks).filter(k => k.endsWith('wiki') && !k.includes('commons'));
$sitelinkCell.html('<span class="ill-loading-inline">Measuring articles...</span>');
$outputCell.empty();
if (!isManualToggling) {
$checkbox.prop('disabled', true).prop('checked', false);
}
const candidates = validKeys.slice(0, 25);
const sizeMap = [];
await Promise.all(candidates.map(async (key) => {
const lang = key.replace('wiki', '').replace(/_/g, '-');
const title = sitelinks[key].title;
const endpoint = `https://${lang}.wikipedia.org/w/api.php?action=query&titles=${encodeURIComponent(title)}&prop=info&format=json&origin=*`;
try {
const res = await fetch(endpoint).then(r => r.json());
const page = Object.values(res.query.pages)[0];
sizeMap.push({
lang,
title,
length: page.length || 0
});
} catch (err) {
sizeMap.push({
lang,
title,
length: 0
});
}
}));
sizeMap.sort((a, b) => b.length - a.length);
const topLinks = sizeMap.slice(0, effectiveMax);
$sitelinkCell.html(topLinks.map(item => {
const kbValue = Math.round(item.length / 1024);
return `<div><a href="https://${item.lang}.wikipedia.org/wiki/${encodeURIComponent(item.title)}" target="_blank">${item.lang}:${this.escapeHTML(item.title)}</a> <span class="ill-size-tag">(${kbValue} kB)</span></div>`;
}).join(""));
const inputWikitext = $row.find('.ill-input').text();
const match = inputWikitext.match(/\[\[(.*?)\]\]/);
if (match) {
const parts = match[1].split('|');
const targetPage = parts[0].trim();
const surfaceName = (parts[1] || parts[0]).trim();
const illLangs = topLinks.map(item => {
const isSameTitle = this.norm(item.title) === this.norm(targetPage);
return `|${item.lang}|${isSameTitle ? '' : item.title}`;
}).join("");
const ltParam = (this.norm(surfaceName) === this.norm(targetPage)) ? "" : `|lt=${surfaceName}`;
$outputCell.text(`{{ill|${targetPage}${ltParam}${illLangs}}}`);
}
$checkbox.prop('disabled', !topLinks.length);
if (!isManualToggling) {
$checkbox.prop('checked', !!topLinks.length);
}
this.updateOkButtonState($table);
},
onCheckbox(e) {
const $row = $(e.target).closest('tr');
const $radio = $row.find('input[type="radio"]:checked');
const isChecked = $(e.target).is(':checked');
if (isChecked) {
if ($radio.length) {
this.showSiteLinks({
target: $radio[0],
fromCheckbox: true
});
}
} else {
$row.find('.ill-sitelinks, .ill-output').empty();
}
},
onOkButton($editform, $wrapper, $textbox) {
let text = $textbox.textSelection('getContents');
let changed = false;
$wrapper.find('.ill-row-check:checked').each(function() {
const $row = $(this).closest('tr');
const input = $row.find('.ill-input').text();
const output = $row.find('.ill-output').text();
if (output) {
const escapedInput = input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
text = text.replace(new RegExp(escapedInput, "gi"), output);
changed = true;
}
});
if (changed) {
$('#wpSummary').val((i, v) => (v ? v + "; " : "") + this.Summary);
$textbox.textSelection('setContents', text);
$wrapper.remove();
$editform.show();
if (this.Config.AutoDiff) $('#wpDiff').click();
}
},
escapeHTML(str) {
return str.replace(/[&<>"']/g, m => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
})[m]);
}
};
window.IllWill = IllWill;
mw.loader.using(['mediawiki.util', 'mediawiki.api', 'jquery.textSelection'], () => {
$(() => IllWill.setup());
});
})();
(function() {
'use strict';
const config = {
enableNS14: true,
rootWhitelists: ['Materials'],
userWhitelist: ""
};
const categoryDataCache = {};
let scanProgress = 0;
let totalToScan = 0;
async function verifyTreeMembership() {
if (!config.enableNS14) return false;
const api = new mw.Api();
const currentTitle = mw.config.get("wgPageName").replace(/_/g, ' ');
const fullRoots = config.rootWhitelists.concat(config.userWhitelist.split(',').map(s => s.trim()).filter(s => s)).map(cat => (cat.startsWith('Category:') ? cat : `Category:${cat}`));
if (fullRoots.includes(currentTitle)) return true;
let categoriesToTable = mw.config.get('wgCategories') || [];
let checked = new Set();
while (categoriesToTable.length > 0) {
if (categoriesToTable.some(cat => fullRoots.includes('Category:' + cat))) {
return true;
}
categoriesToTable.forEach(cat => checked.add(cat));
try {
const res = await api.get({
formatversion: 2,
prop: 'categories',
titles: categoriesToTable.map(c => 'Category:' + c),
cllimit: 'max'
});
let nextBatch = [];
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.categories) {
page.categories.forEach(parent => {
const parentName = parent.title.substring('Category:'.length);
if (!checked.has(parentName)) {
nextBatch.push(parentName);
}
});
}
});
}
categoriesToTable = [...new Set(nextBatch)];
} catch (e) {
return false;
}
}
return false;
}
function getChemboxBlock(wikitext) {
const chemboxRegex = /\{\{\s*Chembox[\s_]*(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const match = wikitext.match(chemboxRegex);
return match ? match[0] : null;
}
function getFormulaFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const formulaMatch = chembox.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
let rawFormula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
rawFormula = rawFormula.split(/\s+/)[0];
return rawFormula.split(',')[0].trim();
}
const propRegex = /\{\{\s*Chembox[\s_]+Properties(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const propSection = chembox.match(propRegex);
if (propSection) {
const content = propSection[0];
const allElements = ['Ac', 'Ag', 'Al', 'Am', 'Ar', 'As', 'At', 'Au', 'B', 'Ba', 'Be', 'Bh', 'Bi', 'Bk', 'Br', 'C', 'Ca', 'Cd', 'Ce', 'Cf', 'Cl', 'Cm', 'Cn', 'Co', 'Cr', 'Cs', 'Cu', 'D', 'Db', 'Ds', 'Dy', 'Er', 'Es', 'Eu', 'F', 'Fe', 'Fl', 'Fm', 'Fr', 'Ga', 'Gd', 'Ge', 'H', 'He', 'Hf', 'Hg', 'Ho', 'Hs', 'I', 'In', 'Ir', 'K', 'Kr', 'La', 'Li', 'Lr', 'Lu', 'Lv', 'Mc', 'Md', 'Mg', 'Mn', 'Mo', 'Mt', 'N', 'Na', 'Nb', 'Nd', 'Ne', 'Nh', 'Ni', 'No', 'Np', 'O', 'Og', 'Os', 'P', 'Pa', 'Pb', 'Pd', 'Pm', 'Po', 'Pr', 'Pt', 'Pu', 'Ra', 'Rb', 'Re', 'Rf', 'Rg', 'Rh', 'Rn', 'Ru', 'S', 'Sb', 'Sc', 'Se', 'Sg', 'Si', 'Sm', 'Sn', 'Sr', 'Ta', 'Tb', 'Tc', 'Te', 'Th', 'Ti', 'Tl', 'Tm', 'Ts', 'U', 'V', 'W', 'Xe', 'Y', 'Yb', 'Zn', 'Zr'];
let data = {};
allElements.forEach(el => {
const elRegex = new RegExp(`\\|\\s*${el}\\s*=\\s*(\\d*\\.?\\d+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch && elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
});
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
elementsPresent.filter(el => !(hasC && (el === "C" || el === "H"))).sort().forEach(el => {
formulaArray.push(el + data[el]);
});
return formulaArray.join('');
}
}
return null;
}
function getCASFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const idRegex = /\{\{\s*Chembox[\s_]+Identifiers(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const idSection = chembox.match(idRegex);
if (!idSection) return null;
const content = idSection[0];
const nbsp = String.fromCharCode(160);
const casMatch = content.match(/\|\s*CASNo\d*\b(?!\s*_)\s*=\s*([^|}\n]+)/i);
if (casMatch) {
let val = casMatch[1].replace(/\{\{[^}]+\}\}/g, "").replace(/\u00AD/g, "").split(" ").join("");
while (val.indexOf(nbsp) !== -1) val = val.replace(nbsp, "");
while (val.indexOf(" ") !== -1) val = val.replace(" ", "");
const cleanedCas = val.trim();
return /^\d{2,7}-\d{2}-\d$/.test(cleanedCas) ? cleanedCas : null;
}
return null;
}
async function getWikidataValue() {
const qid = mw.config.get('wgWikidataItemId');
if (!qid) return null;
const api = new mw.ForeignApi('https://www.wikidata.org/w/api.php');
const res = await api.get({
action: 'wbgetentities',
ids: qid,
props: 'claims',
format: 'json',
formatversion: 2
});
const claims = res.entities[qid].claims;
const data = {};
if (claims.P274) data.formula = claims.P274[0].mainsnak.datavalue.value;
if (claims.P231) data.cas = claims.P231[0].mainsnak.datavalue.value;
return data;
}
async function startBackgroundCategoryScan() {
const api = new mw.Api();
const titles = $('#mw-pages li a').map((i, el) => $(el).attr('title') || $(el).text()).get();
totalToScan = titles.length;
if (totalToScan === 0) return;
const chunks = arrayChunk(titles, 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: chunk,
rvprop: 'content',
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const f = getFormulaFromWikitext(wikitext);
const c = getCASFromWikitext(wikitext);
if (f || c) categoryDataCache[page.title] = {
formula: f,
cas: c
};
});
}
scanProgress += chunk.length;
$('#caschemassist-cat-btn').text(`Scanning... ${scanProgress}/${totalToScan}`);
}
$('#caschemassist-cat-btn').text('Show missing CAS/Chem').prop('disabled', false);
}
async function renderCategoryAudit() {
const api = new mw.Api();
const missingCheckTitles = [];
Object.values(categoryDataCache).forEach(data => {
if (data.formula) missingCheckTitles.push(data.formula);
if (data.cas) missingCheckTitles.push(data.cas);
});
if (missingCheckTitles.length === 0) {
mw.notify("No valid Chembox data found in this category.");
return;
}
const existenceMap = {};
const chunks = arrayChunk([...new Set(missingCheckTitles)], 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
titles: chunk,
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(p => {
existenceMap[p.title] = !p.missing;
});
}
}
$('#mw-pages li').each(function() {
const title = $(this).find('a').attr('title') || $(this).find('a').text().trim();
const data = categoryDataCache[title];
if (!data) return;
if (data.formula && !existenceMap[data.formula]) {
$(this).append($('<span>').css({
'color': 'red',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [${data.formula}]`));
}
if (data.cas && !existenceMap[data.cas]) {
$(this).append($('<span>').css({
'color': 'darkred',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [CAS: ${data.cas}]`));
}
});
$('#caschemassist-cat-btn').text('Audit complete').prop('disabled', true);
}
async function handleSingleArticle() {
const api = new mw.Api();
const currentTitle = mw.config.get("wgTitle");
const [wikiRes, wikidata] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
getWikidataValue()
]);
const page = wikiRes.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const formula = getFormulaFromWikitext(wikitext);
const cas = getCASFromWikitext(wikitext);
if (wikidata) {
let mismatches = [];
if (formula && wikidata.formula && formula !== wikidata.formula) {
mismatches.push(`Formula mismatch: Chembox "${formula}" vs Wikidata "${wikidata.formula}"`);
}
if (cas && wikidata.cas && cas !== wikidata.cas) {
mismatches.push(`CAS mismatch: Chembox "${cas}" vs Wikidata "${wikidata.cas}"`);
}
if (mismatches.length > 0) {
const $warning = $('<div>').css({
'background': '#fff2f2',
'border': '2px solid #d33',
'padding': '10px',
'margin-bottom': '1em',
'color': '#b32424'
}).append($('<strong>').text('⚠️ Data Mismatch Detected: '), mismatches.join(' | '));
$('#mw-content-text').prepend($warning);
}
}
async function checkAndRender(target, label, customWikitext) {
const check = await api.get({
action: 'query',
titles: target,
formatversion: 2
});
if (check.query.pages[0].missing) {
const $container = $('<div>').css({
'background': '#f8f9fa',
'border': '1px solid #a2a9b1',
'padding': '10px',
'margin-bottom': '1em'
});
const $btn = $('<button>').text(`Create redirect: ${target}`).click(async function() {
await api.postWithEditToken({
action: 'edit',
title: target,
text: customWikitext,
summary: `Creating redirect to [[${currentTitle}]] (${label})`,
createonly: true
});
mw.notify(`Created redirect: ${target}`);
$container.fadeOut(400, function() {
$(this).remove();
});
});
$container.append($('<strong>').text(`${label}: `), `Missing redirect. `, $btn);
$('#mw-content-text').prepend($container);
}
}
if (formula && formula !== currentTitle) {
const formulaWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from chemical formula}}\n}}`;
await checkAndRender(formula, "Chemical formula", formulaWikitext);
}
if (cas && cas !== currentTitle) {
const casWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from CAS Registry Number}}\n{{R unprintworthy}}\n}}`;
await checkAndRender(cas, "CAS number", casWikitext);
}
}
function arrayChunk(arr, size) {
const result = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
$(document).ready(async function() {
const ns = mw.config.get("wgNamespaceNumber");
if (ns === 14) {
if (await verifyTreeMembership()) {
const $catContainer = $('#mw-pages');
if ($catContainer.length) {
const $btn = $('<button>').text('Initializing scan...').attr('id', 'caschemassist-cat-btn').addClass('mw-ui-button').css({
'margin': '0 10px 10px 0',
'float': 'right',
'position': 'relative',
'z-index': '100'
}).prop('disabled', true).click(renderCategoryAudit);
$catContainer.prepend($btn);
startBackgroundCategoryScan();
}
}
} else if (ns === 0 && mw.config.get("wgAction") === "view") {
handleSingleArticle();
}
});
})();
//</nowiki>
f86l0xf9omzkd03zgz66rk2f1o1runc
739301
739300
2026-04-25T08:07:48Z
Sam Sailor
26820
Test
739301
javascript
text/javascript
//<nowiki>
(function() {
mw.util.addCSS(`
.ambox-Orphan { display: block !important; }
.comrade-container {
box-shadow: 0 1px 3px rgba(0,0,0,0.1); border-radius: 2px;
padding: 10px 15px; margin: 10px 0; display: flex;
flex-direction: column; align-items: flex-start; gap: 8px;
border: 1px solid #a2a9b1; background: #fcfcfc; line-height: 1.5;
}
.comrade-header { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.comrade-content { display: flex; flex-direction: column; gap: 5px; width: 100%; }
.comrade-success { background-color: #d5f5e3 !important; border-left: 5px solid #27ae60 !important; color: #1b5e20 !important; }
.comrade-nudge { background: #fff9ea; border-left: 5px solid #f0ad4e; }
.comrade-info { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-status { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-container button:not(.comrade-dismiss) {
background: #f8f9fa; border: 1px solid #a2a9b1; border-radius: 2px;
padding: 2px 10px; cursor: pointer; font-weight: bold; font-size: 0.95em; margin-left: 10px;
}
.comrade-container button:not(.comrade-dismiss):hover { border-color: #36c; color: #36c; background: #fff; }
.comrade-dismiss {
background: transparent; border: none; color: #d33; font-weight: bold;
cursor: pointer; font-size: 0.9em;
}
.comrade-dismiss:hover { text-decoration: underline; }
.comrade-code-block { background: #f1f1f1; padding: 4px 8px; border-radius: 3px; font-family: monospace; display: inline-block; margin-top: 4px; }
`);
function appendDismiss($el) {
$('<button>').addClass('comrade-dismiss').text('Dismiss').on('click', function() {
$(this).closest('.comrade-container').fadeOut();
}).appendTo($el.find('.comrade-header'));
$("#siteSub").after($el);
}
async function checkNamingPrecision(currentTitle, isHuman) {
const disambigMatch = currentTitle.match(/^(.+?)(?:, | \()/);
if (isHuman || !disambigMatch) return;
const baseTitle = disambigMatch[1].trim();
const api = new mw.Api();
try {
const baseRes = await api.get({
action: 'query',
titles: baseTitle,
redirects: 1,
formatversion: 2
});
const page = baseRes.query.pages[0];
const baseExists = !page.missing;
let baseRedirectsToSelf = false;
if (baseRes.query.redirects) {
baseRedirectsToSelf = baseRes.query.redirects.some(r => r.to === currentTitle);
}
const searchRes = await api.get({
action: 'query',
list: 'allpages',
apprefix: baseTitle,
aplimit: 50,
formatversion: 2
});
const competitors = searchRes.query.allpages.filter(p => {
const t = p.title;
return t !== currentTitle && t !== baseTitle && (t.startsWith(baseTitle + ', ') || t.startsWith(baseTitle + ' ('));
});
if (!baseExists || baseRedirectsToSelf) {
const $container = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (competitors.length === 0) {
$header.append($('<strong>').text('Precision:'));
const reason = baseRedirectsToSelf ? "already redirects here" : "is free";
$content.append($('<span>').append(`The base name `, $('<code>').text(baseTitle), ` ${reason}. Consider moving this article.`));
const moveUrl = mw.util.getUrl('Special:MovePage/' + currentTitle, {
wpNewTitle: baseTitle,
wpReason: 'Unnecessary disambiguation; base name is free/redirects here per [[WP:PRECISION]]'
});
$content.append($('<button>').text(`Move to ${baseTitle}`).on('click', () => window.open(moveUrl, '_blank')));
} else {
$header.append($('<strong>').text('Observation:'));
const state = baseRedirectsToSelf ? 'redirect to this page' : 'redlink';
$content.append($('<span>').append(`Base name `, $('<code>').text(baseTitle), ` has ${competitors.length} competitors, but it is currently a ${state}.`));
$content.append($('<span>').text('Consider if a disambiguation page is needed.'));
}
$container.append($header, $content);
appendDismiss($container);
}
} catch (e) {
console.warn("Comrade: Precision check failed", e);
}
}
async function checkAndRenderRedirect(redirTitle, targetTitle, rCategory, labelText, customWikitext) {
const api = new mw.Api();
const res = await api.get({
action: 'query',
titles: redirTitle,
formatversion: 2
});
if (res.query.pages[0].missing) {
const safeId = "comrade-redir-" + redirTitle.replace(/[^a-z0-9]/gi, '-');
const $container = $('<div>').addClass('comrade-container comrade-info').attr('id', safeId);
const $header = $('<div>').addClass('comrade-header');
const summary = `Created redirect from ${labelText.toLowerCase()} to [[${targetTitle}]]`;
const $btn = $('<button>').text('Create redirect').on('click', () => createModernRedirect(redirTitle, targetTitle, rCategory, summary, safeId, customWikitext));
$header.append($('<div>').append($('<strong>').text(`${labelText}: `), `Create redirect from `, $('<code>').text(redirTitle), $btn));
$container.append($header);
appendDismiss($container);
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
if (qid) {
try {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
});
const res = await fetch(url).then(r => r.json());
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
}
} catch (e) {
console.warn("Comrade: Wikidata API call failed.");
}
}
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
if (domain.includes(".") && url.pathname === "/") {
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
try {
const response = await fetch(citationURL);
if (response.ok) {
await checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
}
} catch (e) {
console.warn("Comrade: Citation API check failed.");
}
}
} catch (e) {
console.warn("Comrade: Error parsing URL object.");
}
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan, resolvedTitle) {
const api = new mw.Api();
const candidates = resolvedTitle ? [resolvedTitle] : [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
redirects: 0
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const foundTitle = page.title;
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) break;
const $container = $('<div>').addClass('comrade-container');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (isOrphan) {
$container.addClass('comrade-nudge');
$header.append($('<strong>').text('Comrade nudge:'));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
}
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(foundTitle),
target: '_blank'
}).text(foundTitle), "; should it be?"));
const snippet = '* {{anbl|' + currentTitle + '}}';
const $snippetWrapper = $('<div>').css({
'display': 'flex',
'align-items': 'center',
'gap': '10px',
'margin-top': '4px'
});
const $instructionSpan = $('<span>').append('If the Short description is good, consider adding ', $('<code>').text(snippet).css({
'background-color': '#f8f9fa',
'border': '1px solid #eaecf0',
'border-radius': '2px',
'padding': '1px 4px'
}));
const $copyBtn = $('<button>').text('Copy & insert').css('margin-left', '0').on('click', function() {
navigator.clipboard.writeText(snippet).then(() => {
const $this = $(this);
const originalText = $this.text();
$this.text('Copied!').prop('disabled', true);
const editUrl = mw.util.getUrl(foundTitle, {
action: 'edit',
summary: snippet
});
window.open(editUrl, '_blank');
setTimeout(() => {
$this.text(originalText).prop('disabled', false);
}, 1500);
});
});
$snippetWrapper.append($instructionSpan, $copyBtn);
$content.append($snippetWrapper);
if (!hasShortDesc) {
$content.append($('<span>').css({
'color': '#d33',
'font-weight': 'bold',
'margin-top': '5px'
}).text('⚠️ Missing short description: Please add a concise one before listing.'));
}
$container.append($header, $content);
appendDismiss($container);
break;
}
}
}
}
async function checkQualityConsistency() {
const oresClass = getOresPrediction();
if (!oresClass || oresClass === 'Stub') return;
const talkTitle = 'Talk:' + mw.config.get('wgPageName');
const api = new mw.Api();
try {
const [talkRes, pageRes] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: talkTitle,
rvprop: 'content',
formatversion: 2
}),
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
})
]);
const talkPage = talkRes.query.pages[0];
const wikitext = pageRes.query.pages[0].revisions[0].content;
const hasStubTag = /\{\{[^}]*stub\}\}/i.test(wikitext);
const $raterLink = $('#ca-rater, #t-rater');
const isRaterAvailable = (typeof window.rater !== 'undefined' || $raterLink.length > 0);
let talkClass = null;
if (!talkPage.missing) {
const classMatch = talkPage.revisions[0].content.match(/\|class\s*=\s*([^|}\s]+)/i);
talkClass = classMatch ? classMatch[1].charAt(0).toUpperCase() + classMatch[1].slice(1).toLowerCase() : null;
}
const isStubRated = talkClass === 'Stub';
const lingeringStubTag = (talkClass && talkClass !== 'Stub' && oresClass !== 'Stub' && hasStubTag);
if (talkPage.missing || isStubRated || lingeringStubTag) {
const $container = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').append($('<strong>').text('Quality:'), " ");
let message = `ORES suggests <b>${oresClass}</b>. `;
if (talkPage.missing) {
message = `Talk page missing; ORES suggests <b>${oresClass}</b>. `;
let $btn;
if (isRaterAvailable) {
$btn = $('<button>').text('Open Rater').on('click', () => $raterLink.find('a')[0].click());
} else {
$btn = $('<button>').text('Install Rater').on('click', () => window.open('https://en.wikipedia.org/wiki/User:Evad37/rater', '_blank'));
message += "Consider installing <i>Rater</i> for easy assessment.";
}
$content.append(message, $btn);
} else if (lingeringStubTag && !isStubRated) {
message = `Article is ${talkClass}-class, but stub tags remain. `;
const $btn = $('<button>').text('Remove stub tags').on('click', () => performStubRemoval());
$content.append(message, $btn);
} else if (isStubRated) {
message = `Talk page says Stub, but ORES predicts <b>${oresClass}</b>. `;
const $btnOres = $('<button>').text(`Promote to ${oresClass}`).on('click', () => performPromotion(oresClass));
$content.append(message, $btnOres);
if (oresClass === 'C') {
const $btnStart = $('<button>').text(`Promote to Start`).on('click', () => performPromotion('Start'));
$content.append(" or ", $btnStart);
} else if (oresClass === 'B') {
const $btnC = $('<button>').text(`Promote to C`).on('click', () => performPromotion('C'));
$content.append($('<span>').text(' or ').css('margin', '0 10px'), $btnC);
}
}
$header.append($content);
$container.append($header);
appendDismiss($container);
}
} catch (e) {
console.error("Comrade quality check failed", e);
}
}
async function performStubRemoval() {
const api = new mw.Api();
try {
const pageData = await api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
});
let wikitext = pageData.query.pages[0].revisions[0].content;
const newWikitext = wikitext.replace(/\{\{[^}]*stub\}\}\n?/gi, '').trim();
if (wikitext === newWikitext) {
mw.notify("No stub tags found to remove.");
return;
}
await api.postWithToken('csrf', {
action: 'edit',
title: mw.config.get("wgPageName"),
text: newWikitext,
summary: `Removing lingering stub tags per ORES/Talk assessment`,
nocreate: true
});
location.reload();
} catch (err) {
console.error("Comrade failed to remove stub tags:", err);
mw.notify("Error removing stub tags.", {
type: 'error'
});
}
}
async function createModernRedirect(redirTitle, targetTitle, rCategory, customSummary, elementId, customWikitext) {
const api = new mw.Api();
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
try {
await api.postWithEditToken({
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
});
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
} catch (e) {
mw.notify("Failed to create redirect.", {
type: 'error'
});
}
}
function getOresPrediction() {
// Dependency: [[m:User:EpochFail/ArticleQuality-system.js]] renders the prediction inside .article_quality
const text = $('.article_quality').text();
if (!text) return null;
if (text.includes('Start')) return 'Start';
if (text.includes('C')) return 'C';
if (text.includes('B')) return 'B';
return null;
}
function getSmartSortKey(fullName, entity) {
const countryIds = entity.claims?.P27?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const locationIds = entity.claims?.P17?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const placeIds = [...(entity.claims?.P19 || []), ...(entity.claims?.P119 || [])].map(c => c.mainsnak?.datavalue?.value?.id).filter(Boolean);
const allRelevantIds = [...countryIds, ...locationIds, ...placeIds];
const birthYear = parseInt(entity.claims?.P569?.[0]?.mainsnak?.datavalue?.value?.time?.match(/[+-](\d{4})/)?.[1]) || null;
if (fullName.startsWith("Saint ")) return fullName.replace("Saint ", "");
const arabicParticles = /\b(Abu|Abd|Abdel|Abdul|ben|bin|bint)\b/i;
if (fullName.match(arabicParticles)) {
if (birthYear && birthYear > 1900) {
const parts = fullName.split(' ');
const binIndex = parts.findIndex(p => p.toLowerCase() === 'bin' || p.toLowerCase() === 'ben');
if (binIndex > 0) return parts.slice(binIndex).join(' ') + ', ' + parts.slice(0, binIndex).join(' ');
} else {
return fullName;
}
}
const eastAsianQids = ["Q148", "Q184", "Q884", "Q424", "Q881", "Q17"];
if (allRelevantIds.some(id => eastAsianQids.includes(id))) {
if (allRelevantIds.includes("Q17") && birthYear && birthYear > 1885) return westernSort(fullName);
const parts = fullName.split(' ');
if (parts.length > 1) return `${parts[0]}, ${parts.slice(1).join(' ')}`;
}
return westernSort(fullName);
}
function westernSort(name) {
let cleanName = name;
if (name.startsWith("O'")) cleanName = name.replace("O'", "O");
const parts = cleanName.split(' ');
if (parts.length < 2) return cleanName;
const surname = parts.pop();
if (["Jr.", "Jr", "III", "II", "Sr.", "Sr"].includes(surname)) {
const actualSurname = parts.pop();
return `${actualSurname}, ${parts.join(' ')} ${surname}`;
}
return `${surname}, ${parts.join(' ')}`;
}
async function performDeorphan() {
const api = new mw.Api();
try {
await api.edit(mw.config.get('wgPageName'), function(rev) {
let text = rev.content;
const summary = 'Article has backlinks; removed [[Template:Orphan|{{Orphan}}]]';
text = text.replace(/\{\{Orphan\s*(?:\|[^}]*)?\}\}\s*/gi, '');
text = text.replace(/(\{\{Multiple[ _]issues\s*)\|\s*\|/gi, '$1|');
text = replaceMI(text);
text = text.replace(/\n{3,}/g, '\n\n').trim();
return {
text,
summary,
minor: true
};
});
mw.notify('Orphan tag removed!', {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
setTimeout(() => location.reload(), 700);
} catch (e) {
mw.notify('Failed to remove orphan tag.', {
type: 'error'
});
}
}
async function performPromotion(newClass) {
const api = new mw.Api();
const pageName = mw.config.get('wgPageName');
const talkName = 'Talk:' + pageName;
try {
const talkData = await api.get({
action: 'query',
prop: 'revisions',
titles: talkName,
rvprop: 'content',
formatversion: 2
});
let talkWikitext = talkData.query.pages[0].revisions[0].content;
talkWikitext = talkWikitext.replace(/(\|class\s*=\s*)Stub/i, `$1${newClass}`);
await api.postWithEditToken({
action: 'edit',
title: talkName,
text: talkWikitext,
summary: `Promoting article to ${newClass} class per ORES prediction [[WP:ORES]]`
});
const artData = await api.get({
action: 'query',
prop: 'revisions',
titles: pageName,
rvprop: 'content',
formatversion: 2
});
let artWikitext = artData.query.pages[0].revisions[0].content;
const stubRegex = /\{\{[^}]*?-stub\}\}\s*\n?/gi;
const genericStubRegex = /\{\{stub\}\}\s*/gi;
artWikitext = artWikitext.replace(stubRegex, '').replace(genericStubRegex, '');
await api.postWithEditToken({
action: 'edit',
title: pageName,
text: artWikitext,
summary: `Removing stub tags; article promoted to ${newClass}`
});
location.reload();
} catch (e) {
alert("Promotion failed. Check console for details.");
console.error(e);
}
}
function replaceMI(text) {
const miRegex = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miRegex.exec(text)) !== null) {
const start = match.index;
result += text.slice(lastIndex, start);
let depth = 0;
let i = start;
while (i < text.length) {
if (text[i] === '{' && text[i + 1] === '{') {
depth++;
i += 2;
} else if (text[i] === '}' && text[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
const end = i;
const full = text.slice(start, end);
const pipeIdx = full.indexOf('|');
const inner = full.slice(pipeIdx + 1, full.length - 2).trim();
const topLevel = extractTopLevelTemplates(inner);
if (topLevel.length === 0) {
result += '';
} else if (topLevel.length === 1) {
result += topLevel[0].trim();
} else {
result += full;
}
lastIndex = end;
}
result += text.slice(lastIndex);
return result;
}
function extractTopLevelTemplates(inner) {
const templates = [];
let i = 0;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
let depth = 0;
const start = i;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
depth++;
i += 2;
} else if (inner[i] === '}' && inner[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
templates.push(inner.slice(start, i));
} else {
i++;
}
}
return templates;
}
async function performSortCorrection(newName, type) {
const api = new mw.Api();
const title = mw.config.get("wgPageName");
try {
const data = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2
});
let content = data.query.pages[0].revisions[0].content;
const dsRegex = /\{\{(?:DEFAULTSORT|Defaultsort):[^}]+\}\}/i;
const newTag = `{{DEFAULTSORT:${newName}}}`;
let actionDesc = type === "add" ? "added" : "corrected";
if (dsRegex.test(content)) {
content = content.replace(dsRegex, newTag);
} else {
const catRegex = /\[\[Category:/i;
if (catRegex.test(content)) {
content = content.replace(catRegex, `${newTag}\n[[Category:`);
} else {
content += `\n\n${newTag}`;
}
}
await api.postWithEditToken({
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName}`,
nocreate: true
});
mw.notify(`DEFAULTSORT ${actionDesc} to ${newName}!`, {
title: 'Comrade Success',
type: 'success'
});
setTimeout(() => location.reload(), 700);
} catch (err) {
mw.notify('Failed to save DEFAULTSORT.', {
type: 'error'
});
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
function renderSortNudge(target, reason) {
const $dsNudge = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Correct to: ${target}`).on('click', () => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
async function fetchWikidataNameLabels(entity) {
const familyIds = entity.claims?.P734?.map(c => c.mainsnak.datavalue.value.id) || [];
const givenIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
const allNameIds = [...new Set([...familyIds, ...givenIds])];
if (allNameIds.length === 0) return {
givens: [],
surnames: []
};
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: allNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
});
const nameData = await fetch(url).then(r => r.json());
return {
givens: givenIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean),
surnames: familyIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean)
};
}
function fixNameCasing(part, referenceName) {
return part.split(' ').map(word => {
const match = referenceName.match(new RegExp(`\\b${word}\\b`, 'i'));
if (match) return match[0];
return referenceName.split(' ').find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase())) || word;
}).join(' ');
}
function getLongNameFromWikitext(wikitext) {
const firstLine = wikitext.split('\n').find(l => l.includes("'''"));
if (!firstLine) return null;
const boldMatch = firstLine.match(/'''(.+?)'''/);
if (!boldMatch) return null;
return boldMatch[1].replace(/\s*([“"«《„‹「『'].+?[”"»》”›」』']|\([^)\n]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
}
async function init() {
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") return;
const api = new mw.Api();
const qid = mw.config.get("wgWikibaseItemId");
try {
const [pageData, backlinkData] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
api.get({
action: 'query',
list: 'backlinks',
blfilterredir: 'nonredirects',
bllimit: 50,
blnamespace: 0,
bltitle: mw.config.get("wgPageName"),
formatversion: 2
})
]);
const page = pageData.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const currentTitle = mw.config.get("wgTitle");
const cleanTitle = stripDiacritics(currentTitle);
const isOrphanTagged = /\{\{\s*(Orphan|Do-attempt|Lonely|Orp|Orphaned article)/i.test(wikitext) || /\| *orphan *=/i.test(wikitext);
const backLinkCount = backlinkData.query.backlinks.length;
if (isOrphanTagged && backLinkCount >= 1) {
const $container = $('<div>').addClass('comrade-container comrade-status');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text('Remove orphan tag').on('click', () => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) await checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
await checkDomainRedirect(qid, currentTitle);
let isHuman = false;
let entity = null;
if (qid) {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
});
const wikiData = await fetch(url).then(r => r.json());
entity = wikiData.entities[qid];
isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
}
await checkNamingPrecision(currentTitle, isHuman);
if (qid && entity) {
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const nameParts = tClean.split(' ');
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
const isOrphan = isOrphanTagged && backLinkCount < 1;
const primarySurname = hasFamilyNameHatnote ? nameParts[0] : (getSmartSortKey(tClean, entity).split(',')[0] || nameParts[nameParts.length - 1]);
let targetSortName = "";
if (isHuman) {
const {
givens,
surnames
} = await fetchWikidataNameLabels(entity);
const refName = entity.labels?.en?.value || currentTitle;
if (dsMatch) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixNameCasing(lastPart, refName)}, ${fixNameCasing(firstPart, refName)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field.");
} else if (givens.length > 0 && lastPart.split(' ').length > 1) {
const misplacedGiven = lastPart.split(' ').find(part => givens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastPart.split(' ').filter(p => p !== misplacedGiven).join(' ');
targetSortName = `${fixNameCasing(newSurname, refName)}, ${fixNameCasing(misplacedGiven + ' ' + firstPart, refName)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
}
}
} else {
targetSortName = hasFamilyNameHatnote ? `${nameParts[0]}, ${nameParts.slice(1).join(' ')}` : getSmartSortKey(tClean, entity);
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).on('click', () => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
const longName = getLongNameFromWikitext(wikitext);
if (longName && longName.length > tClean.length) {
let sortKey = hasFamilyNameHatnote ? `${longName.split(' ')[0]}, ${longName.split(' ').slice(1).join(' ')}` : getSmartSortKey(longName, entity);
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
await checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
if (targetSortName) {
let targetPage = currentTitle.includes(' (') ? currentTitle.split(' (')[0] : currentTitle;
let rCat = targetPage !== currentTitle ? "R from ambiguous sort name" : "R from sort name";
if (targetSortName.includes(',')) {
const p = targetSortName.split(',');
rCat += `|${p[0].trim().charAt(0).toUpperCase()}|${p[1].trim().charAt(0).toUpperCase()}`;
}
await checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name");
}
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
surnames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
givens.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) finalGivenNames.add(gn);
});
if (finalFamilyNames.size === 0 && nameParts.length >= 2) finalFamilyNames.add(nameParts[nameParts.length - 1]);
if (finalGivenNames.size === 0 && nameParts.length >= 2) finalGivenNames.add(nameParts[0]);
const getPriorityTarget = async (name, type) => {
const titles = type === 'surname' ? [`List of people with surname ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`] : [`List of people with given name ${name}`, `${name} (given name)`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
return titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
};
for (const name of finalGivenNames) {
if (name.toLowerCase() === primarySurname.toLowerCase()) continue;
const target = await getPriorityTarget(name, 'given');
if (target) await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
for (const name of finalFamilyNames) {
const titles = [name, `${name} (surname)`, `List of people with surname ${name}`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
const target = titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
if (target) await checkNameList(name, 'surname', currentTitle, hasShortDesc, isOrphan, target);
}
}
const oresWait = setInterval(() => {
if ($('.article_quality').length) {
clearInterval(oresWait);
checkQualityConsistency();
}
}, 500);
setTimeout(() => clearInterval(oresWait), 5000);
}
} catch (err) {
console.error("Comrade error:", err);
}
}
init();
})();
(function() {
if (window.IllWill) return;
const STRIKEOUT = true;
const HIDE = 'hide';
const IllWill = {
Version: "1.1.1",
Attribution: 'created by <a target="_blank" href="/wiki/User:Cobaltcigs">cobaltcigs</a>',
Summary: "Added interlanguage links using IllWill.js",
DataApi: "https://www.wikidata.org/w/api.php",
LocalLang: mw.config.get("wgPageContentLanguage"),
Disambiguator: / \(.*\)$/,
SiteLinks: {},
Config: {
MaxLanguages: 5,
IgnoreScientific: STRIKEOUT,
AutoDiff: true
},
css: `
#illwill { clear: both; margin-bottom: 1.5em; border: 1px solid #a2a9b1; padding: 15px; background: #f8f9fa; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.ill-sitelinks a { display: inline-block; margin-bottom: 3px; font-size: 0.9em; color: #36c; }
.ill-size-tag { font-size: 0.8em; color: #72777d; margin-left: 6px; white-space: nowrap; }
.illwill-ignore { text-decoration: line-through; color: #72777d; font-size: 0.85em; }
.ill-x { width: 35px; text-align: center; }
.ill-input, .ill-output { font-family: 'Courier New', monospace; white-space: pre-wrap; font-size: 0.9em; background: #fff; border: 1px solid #eaecf0; padding: 4px; border-radius: 2px; }
#ill-buttons { text-align: right; padding: 10px; border-top: 1px solid #a2a9b1; margin-top: 10px; }
#ill-buttons button { margin-left: 10px; cursor: pointer; padding: 6px 16px; border-radius: 2px; border: 1px solid #a2a9b1; transition: all 0.2s; }
#ill-ok { background: #36c; color: #fff; border-color: #36c !important; font-weight: bold; }
#ill-ok:hover { background: #447ff5; }
#ill-ok:disabled { background: #eaecf0; color: #72777d; border-color: #c8ccd1 !important; cursor: not-allowed; opacity: 0.7; }
#ill-table { width: 100%; border-collapse: collapse; margin-top: 10px; background: white; }
#ill-table th { background: #f2f2f2; text-align: left; padding: 8px; border: 1px solid #a2a9b1; }
#ill-table td { padding: 8px; border: 1px solid #a2a9b1; vertical-align: top; }
#ill-table caption { background: #eaffea; font-weight: bold; padding: 10px; border: 1px solid #a2a9b1; border-bottom: none; font-size: 1.1em; }
#ill-help { font-size: 0.85em; color: #54595d; padding: 10px; line-height: 1.4; }
.ill-loading-inline { font-style: italic; color: #72777d; font-size: 0.9em; display: block; margin: 5px 0; }
`,
setup() {
if (!["edit", "submit"].includes(mw.config.get('wgAction'))) return;
mw.loader.addStyleTag(this.css);
const link = mw.util.addPortletLink('p-cactions', '#', 'IllWill', 'ca-illwill', "Add interlanguage links via Wikidata");
if (link) {
link.addEventListener('click', (e) => {
e.preventDefault();
this.makePanel();
});
}
},
norm(s) {
if (!s) return "";
const t = s.trim().replace(/[\s_]+/g, " ");
return t.charAt(0).toUpperCase() + t.slice(1);
},
async getRedLinks($textbox) {
const txt = $textbox.textSelection('getContents') || "";
const wl = txt.match(/\[\[[^[\]\n]+\]\]/g);
if (!wl) return {
reds: [],
txt
};
const api = new mw.Api();
const textToParse = wl.join("").replace(/\|[^\]]+/g, "");
try {
const data = await api.post({
action: 'parse',
text: textToParse,
contentmodel: 'wikitext',
formatversion: 2
});
const tempDiv = document.createElement('div');
tempDiv.innerHTML = data.parse.text;
const reds = Array.from(tempDiv.querySelectorAll("a.new")).map(x => {
const urlParams = new URLSearchParams(x.href.split('?')[1]);
return urlParams.get('title')?.replace(/_/g, " ");
}).filter(Boolean);
return {
reds: [...new Set(reds)],
txt
};
} catch (e) {
return {
reds: [],
txt
};
}
},
async makePanel() {
const $editform = $('#editform');
const $textbox = $('#wpTextbox1');
$('#illwill').remove();
$editform.hide();
const $wrapper = $('<div id="illwill">Loading redlinks...</div>').insertBefore($editform);
const {
reds,
txt
} = await this.getRedLinks($textbox);
const wtLinks = [];
reds.forEach(title => {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s/g, "[\\s_]+");
const re = new RegExp("\\[\\[\\s*" + escaped + "\\s*(?:\\|[^\\[\\]]+)?\\]\\]", "ig");
const match = txt.match(re);
if (match && !wtLinks.some(z => z.title === title)) {
wtLinks.push({
link: match[0],
title
});
}
});
if (!wtLinks.length) {
const $back = $('<button>Quit</button>').on('click', () => {
$wrapper.remove();
$editform.show();
});
$wrapper.empty().append($('<strong>No valid redlinks found in text.</strong> '), $back);
return;
}
const $table = $(`
<table id="ill-table" class="wikitable">
<caption>IllWill.js (v${this.Version})</caption>
<thead>
<tr>
<th class="ill-x"><input type="checkbox" id="ill-select-all" disabled></th>
<th>Input link</th>
<th>Possible Wikidata matches</th>
<th>Sitelinks (top ${this.Config.MaxLanguages} by size)</th>
<th>Resulting wikitext</th>
</tr>
</thead>
<tbody></tbody>
<tfoot>
<tr>
<td colspan="3" id="ill-help">Carefully examine possible Wikidata matches.</td>
<td colspan="2" id="ill-buttons">
<button id="ill-cancel">Cancel</button>
<button id="ill-ok" disabled>Show changes</button>
</td>
</tr>
</tfoot>
</table>
`);
const $tbody = $table.find('tbody');
wtLinks.forEach((item, i) => {
$tbody.append(`
<tr id="ill-row-${i}" data-index="${i}">
<td class="ill-x"><input type="checkbox" class="ill-row-check" disabled></td>
<td class="ill-input">${this.escapeHTML(item.link)}</td>
<td class="ill-wd-item"><span class="ill-loading-inline">Searching...</span></td>
<td class="ill-sitelinks"></td>
<td class="ill-output"></td>
</tr>
`);
});
$wrapper.empty().append($table);
const chunkSize = 5;
for (let i = 0; i < wtLinks.length; i += chunkSize) {
const chunk = wtLinks.slice(i, i + chunkSize);
await Promise.all(chunk.map((item, idx) => this.fetchWikidataOptimized(item.title, i + idx)));
}
$table.on('click', '#ill-cancel', () => {
$editform.show();
$wrapper.remove();
});
$table.on('click', '#ill-ok', () => this.onOkButton($editform, $wrapper, $textbox));
$table.on('change', '#ill-select-all', (e) => {
const isChecked = $(e.target).is(':checked');
$table.find('.ill-row-check:not(:disabled)').each(function() {
$(this).prop('checked', isChecked).trigger('change');
});
});
$table.on('change', '.ill-row-check', (e) => {
this.onCheckbox(e);
this.updateOkButtonState($table);
});
$table.on('change', 'input[type="radio"]', (e) => {
this.showSiteLinks(e);
});
},
async fetchWikidataOptimized(title, index) {
const query = title.replace(this.Disambiguator, "");
const url = `${this.DataApi}?action=wbsearchentities&search=${encodeURIComponent(query)}&language=${this.LocalLang}&limit=5&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
const results = data.search;
const $cell = $(`#ill-row-${index} .ill-wd-item`);
if (!results || !results.length) {
$cell.html('<span class="ill-na">(no results)</span>');
return;
}
const entityIds = results.map(r => r.id);
await this.fetchEntities(entityIds, index, $cell);
} catch (e) {
$(`#ill-row-${index} .ill-wd-item`).text("Search failed.");
}
},
async fetchEntities(ids, rowIndex, $cell) {
const url = `${this.DataApi}?action=wbgetentities&ids=${ids.join('|')}&props=labels|descriptions|claims|sitelinks&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
$cell.empty();
ids.forEach(qNum => {
const entity = data.entities[qNum];
if (!entity) return;
const isScientific = entity.claims?.P31?.some(s => s.mainsnak?.datavalue?.value?.id === "Q13442814");
if (isScientific && this.Config.IgnoreScientific === HIDE) return;
const desc = entity.descriptions?.[this.LocalLang]?.value || entity.descriptions?.en?.value || "";
const label = entity.labels?.[this.LocalLang]?.value || entity.labels?.en?.value || qNum;
const isStrikeout = isScientific && this.Config.IgnoreScientific === STRIKEOUT;
const $div = $(`
<div class="${isStrikeout ? 'illwill-ignore' : ''}">
<input type="radio" name="selection-${rowIndex}" value="${qNum}" id="radio-${qNum}" ${isStrikeout ? 'disabled' : ''}>
<label for="radio-${qNum}">
<a href="https://wikidata.org/wiki/${qNum}" target="_blank">${this.escapeHTML(label)}</a>:
<span class="ill-desc">${desc ? this.escapeHTML(desc) : '<i>no description</i>'}</span>
</label>
</div>
`);
$cell.append($div);
this.SiteLinks[qNum] = entity.sitelinks || {};
});
} catch (e) {
$cell.text("Detail fetch failed.");
}
},
updateOkButtonState($table) {
const $rows = $table.find('.ill-row-check:not(:disabled)');
const $checkedRows = $rows.filter(':checked');
const anyEnabled = $rows.length > 0;
$table.find('#ill-ok').prop('disabled', $checkedRows.length === 0);
const $master = $table.find('#ill-select-all');
$master.prop('disabled', !anyEnabled);
if (anyEnabled && $rows.length === $checkedRows.length) {
$master.prop('checked', true);
} else {
$master.prop('checked', false);
}
},
async showSiteLinks(e) {
const isManualToggling = e.fromCheckbox || false;
const qNum = e.target.value;
const $row = $(e.target).closest('tr');
const sitelinks = this.SiteLinks[qNum];
const $checkbox = $row.find('.ill-row-check');
const $sitelinkCell = $row.find('.ill-sitelinks');
const $outputCell = $row.find('.ill-output');
const $table = $('#ill-table');
const effectiveMax = Math.min(Math.max(this.Config.MaxLanguages, 3), 6);
const validKeys = Object.keys(sitelinks).filter(k => k.endsWith('wiki') && !k.includes('commons'));
$sitelinkCell.html('<span class="ill-loading-inline">Measuring articles...</span>');
$outputCell.empty();
if (!isManualToggling) {
$checkbox.prop('disabled', true).prop('checked', false);
}
const candidates = validKeys.slice(0, 25);
const sizeMap = [];
await Promise.all(candidates.map(async (key) => {
const lang = key.replace('wiki', '').replace(/_/g, '-');
const title = sitelinks[key].title;
const endpoint = `https://${lang}.wikipedia.org/w/api.php?action=query&titles=${encodeURIComponent(title)}&prop=info&format=json&origin=*`;
try {
const res = await fetch(endpoint).then(r => r.json());
const page = Object.values(res.query.pages)[0];
sizeMap.push({
lang,
title,
length: page.length || 0
});
} catch (err) {
sizeMap.push({
lang,
title,
length: 0
});
}
}));
sizeMap.sort((a, b) => b.length - a.length);
const topLinks = sizeMap.slice(0, effectiveMax);
$sitelinkCell.html(topLinks.map(item => {
const kbValue = Math.round(item.length / 1024);
return `<div><a href="https://${item.lang}.wikipedia.org/wiki/${encodeURIComponent(item.title)}" target="_blank">${item.lang}:${this.escapeHTML(item.title)}</a> <span class="ill-size-tag">(${kbValue} kB)</span></div>`;
}).join(""));
const inputWikitext = $row.find('.ill-input').text();
const match = inputWikitext.match(/\[\[(.*?)\]\]/);
if (match) {
const parts = match[1].split('|');
const targetPage = parts[0].trim();
const surfaceName = (parts[1] || parts[0]).trim();
const illLangs = topLinks.map(item => {
const isSameTitle = this.norm(item.title) === this.norm(targetPage);
return `|${item.lang}|${isSameTitle ? '' : item.title}`;
}).join("");
const ltParam = (this.norm(surfaceName) === this.norm(targetPage)) ? "" : `|lt=${surfaceName}`;
$outputCell.text(`{{ill|${targetPage}${ltParam}${illLangs}}}`);
}
$checkbox.prop('disabled', !topLinks.length);
if (!isManualToggling) {
$checkbox.prop('checked', !!topLinks.length);
}
this.updateOkButtonState($table);
},
onCheckbox(e) {
const $row = $(e.target).closest('tr');
const $radio = $row.find('input[type="radio"]:checked');
const isChecked = $(e.target).is(':checked');
if (isChecked) {
if ($radio.length) {
this.showSiteLinks({
target: $radio[0],
fromCheckbox: true
});
}
} else {
$row.find('.ill-sitelinks, .ill-output').empty();
}
},
onOkButton($editform, $wrapper, $textbox) {
let text = $textbox.textSelection('getContents');
let changed = false;
$wrapper.find('.ill-row-check:checked').each(function() {
const $row = $(this).closest('tr');
const input = $row.find('.ill-input').text();
const output = $row.find('.ill-output').text();
if (output) {
const escapedInput = input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
text = text.replace(new RegExp(escapedInput, "gi"), output);
changed = true;
}
});
if (changed) {
$('#wpSummary').val((i, v) => (v ? v + "; " : "") + this.Summary);
$textbox.textSelection('setContents', text);
$wrapper.remove();
$editform.show();
if (this.Config.AutoDiff) $('#wpDiff').click();
}
},
escapeHTML(str) {
return str.replace(/[&<>"']/g, m => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
})[m]);
}
};
window.IllWill = IllWill;
mw.loader.using(['mediawiki.util', 'mediawiki.api', 'jquery.textSelection'], () => {
$(() => IllWill.setup());
});
})();
(function() {
'use strict';
const config = {
enableNS14: true,
rootWhitelists: ['Materials'],
userWhitelist: ""
};
const categoryDataCache = {};
let scanProgress = 0;
let totalToScan = 0;
async function verifyTreeMembership() {
if (!config.enableNS14) return false;
const api = new mw.Api();
const currentTitle = mw.config.get("wgPageName").replace(/_/g, ' ');
const fullRoots = config.rootWhitelists.concat(config.userWhitelist.split(',').map(s => s.trim()).filter(s => s)).map(cat => (cat.startsWith('Category:') ? cat : `Category:${cat}`));
if (fullRoots.includes(currentTitle)) return true;
let categoriesToTable = mw.config.get('wgCategories') || [];
let checked = new Set();
while (categoriesToTable.length > 0) {
if (categoriesToTable.some(cat => fullRoots.includes('Category:' + cat))) {
return true;
}
categoriesToTable.forEach(cat => checked.add(cat));
try {
const res = await api.get({
formatversion: 2,
prop: 'categories',
titles: categoriesToTable.map(c => 'Category:' + c),
cllimit: 'max'
});
let nextBatch = [];
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.categories) {
page.categories.forEach(parent => {
const parentName = parent.title.substring('Category:'.length);
if (!checked.has(parentName)) {
nextBatch.push(parentName);
}
});
}
});
}
categoriesToTable = [...new Set(nextBatch)];
} catch (e) {
return false;
}
}
return false;
}
function getChemboxBlock(wikitext) {
const chemboxRegex = /\{\{\s*Chembox[\s_]*(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const match = wikitext.match(chemboxRegex);
return match ? match[0] : null;
}
function getFormulaFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const formulaMatch = chembox.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
let rawFormula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
rawFormula = rawFormula.split(/\s+/)[0];
return rawFormula.split(',')[0].trim();
}
const propRegex = /\{\{\s*Chembox[\s_]+Properties(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const propSection = chembox.match(propRegex);
if (propSection) {
const content = propSection[0];
const allElements = ['Ac', 'Ag', 'Al', 'Am', 'Ar', 'As', 'At', 'Au', 'B', 'Ba', 'Be', 'Bh', 'Bi', 'Bk', 'Br', 'C', 'Ca', 'Cd', 'Ce', 'Cf', 'Cl', 'Cm', 'Cn', 'Co', 'Cr', 'Cs', 'Cu', 'D', 'Db', 'Ds', 'Dy', 'Er', 'Es', 'Eu', 'F', 'Fe', 'Fl', 'Fm', 'Fr', 'Ga', 'Gd', 'Ge', 'H', 'He', 'Hf', 'Hg', 'Ho', 'Hs', 'I', 'In', 'Ir', 'K', 'Kr', 'La', 'Li', 'Lr', 'Lu', 'Lv', 'Mc', 'Md', 'Mg', 'Mn', 'Mo', 'Mt', 'N', 'Na', 'Nb', 'Nd', 'Ne', 'Nh', 'Ni', 'No', 'Np', 'O', 'Og', 'Os', 'P', 'Pa', 'Pb', 'Pd', 'Pm', 'Po', 'Pr', 'Pt', 'Pu', 'Ra', 'Rb', 'Re', 'Rf', 'Rg', 'Rh', 'Rn', 'Ru', 'S', 'Sb', 'Sc', 'Se', 'Sg', 'Si', 'Sm', 'Sn', 'Sr', 'Ta', 'Tb', 'Tc', 'Te', 'Th', 'Ti', 'Tl', 'Tm', 'Ts', 'U', 'V', 'W', 'Xe', 'Y', 'Yb', 'Zn', 'Zr'];
let data = {};
allElements.forEach(el => {
const elRegex = new RegExp(`\\|\\s*${el}\\s*=\\s*(\\d*\\.?\\d+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch && elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
});
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
elementsPresent.filter(el => !(hasC && (el === "C" || el === "H"))).sort().forEach(el => {
formulaArray.push(el + data[el]);
});
return formulaArray.join('');
}
}
return null;
}
function getCASFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const idRegex = /\{\{\s*Chembox[\s_]+Identifiers(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const idSection = chembox.match(idRegex);
if (!idSection) return null;
const content = idSection[0];
const nbsp = String.fromCharCode(160);
const casMatch = content.match(/\|\s*CASNo\d*\b(?!\s*_)\s*=\s*([^|}\n]+)/i);
if (casMatch) {
let val = casMatch[1].replace(/\{\{[^}]+\}\}/g, "").replace(/\u00AD/g, "").split(" ").join("");
while (val.indexOf(nbsp) !== -1) val = val.replace(nbsp, "");
while (val.indexOf(" ") !== -1) val = val.replace(" ", "");
const cleanedCas = val.trim();
return /^\d{2,7}-\d{2}-\d$/.test(cleanedCas) ? cleanedCas : null;
}
return null;
}
async function getWikidataValue() {
const qid = mw.config.get('wgWikidataItemId');
if (!qid) return null;
const api = new mw.ForeignApi('https://www.wikidata.org/w/api.php');
const res = await api.get({
action: 'wbgetentities',
ids: qid,
props: 'claims',
format: 'json',
formatversion: 2
});
const claims = res.entities[qid].claims;
const data = {};
if (claims.P274) data.formula = claims.P274[0].mainsnak.datavalue.value;
if (claims.P231) data.cas = claims.P231[0].mainsnak.datavalue.value;
return data;
}
async function startBackgroundCategoryScan() {
const api = new mw.Api();
const titles = $('#mw-pages li a').map((i, el) => $(el).attr('title') || $(el).text()).get();
totalToScan = titles.length;
if (totalToScan === 0) return;
const chunks = arrayChunk(titles, 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: chunk,
rvprop: 'content',
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const f = getFormulaFromWikitext(wikitext);
const c = getCASFromWikitext(wikitext);
if (f || c) categoryDataCache[page.title] = {
formula: f,
cas: c
};
});
}
scanProgress += chunk.length;
$('#caschemassist-cat-btn').text(`Scanning... ${scanProgress}/${totalToScan}`);
}
$('#caschemassist-cat-btn').text('Show missing CAS/Chem').prop('disabled', false);
}
async function renderCategoryAudit() {
const api = new mw.Api();
const missingCheckTitles = [];
Object.values(categoryDataCache).forEach(data => {
if (data.formula) missingCheckTitles.push(data.formula);
if (data.cas) missingCheckTitles.push(data.cas);
});
if (missingCheckTitles.length === 0) {
mw.notify("No valid Chembox data found in this category.");
return;
}
const existenceMap = {};
const chunks = arrayChunk([...new Set(missingCheckTitles)], 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
titles: chunk,
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(p => {
existenceMap[p.title] = !p.missing;
});
}
}
$('#mw-pages li').each(function() {
const title = $(this).find('a').attr('title') || $(this).find('a').text().trim();
const data = categoryDataCache[title];
if (!data) return;
if (data.formula && !existenceMap[data.formula]) {
$(this).append($('<span>').css({
'color': 'red',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [${data.formula}]`));
}
if (data.cas && !existenceMap[data.cas]) {
$(this).append($('<span>').css({
'color': 'darkred',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [CAS: ${data.cas}]`));
}
});
$('#caschemassist-cat-btn').text('Audit complete').prop('disabled', true);
}
async function handleSingleArticle() {
const api = new mw.Api();
const currentTitle = mw.config.get("wgTitle");
const [wikiRes, wikidata] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
getWikidataValue()
]);
const page = wikiRes.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const formula = getFormulaFromWikitext(wikitext);
const cas = getCASFromWikitext(wikitext);
if (wikidata) {
let mismatches = [];
if (formula && wikidata.formula && formula !== wikidata.formula) {
mismatches.push(`Formula mismatch: Chembox "${formula}" vs Wikidata "${wikidata.formula}"`);
}
if (cas && wikidata.cas && cas !== wikidata.cas) {
mismatches.push(`CAS mismatch: Chembox "${cas}" vs Wikidata "${wikidata.cas}"`);
}
if (mismatches.length > 0) {
const $warning = $('<div>').css({
'background': '#fff2f2',
'border': '2px solid #d33',
'padding': '10px',
'margin-bottom': '1em',
'color': '#b32424'
}).append($('<strong>').text('⚠️ Data Mismatch Detected: '), mismatches.join(' | '));
$('#mw-content-text').prepend($warning);
}
}
async function checkAndRender(target, label, customWikitext) {
const check = await api.get({
action: 'query',
titles: target,
formatversion: 2
});
if (check.query.pages[0].missing) {
const $container = $('<div>').css({
'background': '#f8f9fa',
'border': '1px solid #a2a9b1',
'padding': '10px',
'margin-bottom': '1em'
});
const $btn = $('<button>').text(`Create redirect: ${target}`).click(async function() {
await api.postWithEditToken({
action: 'edit',
title: target,
text: customWikitext,
summary: `Creating redirect to [[${currentTitle}]] (${label})`,
createonly: true
});
mw.notify(`Created redirect: ${target}`);
$container.fadeOut(400, function() {
$(this).remove();
});
});
$container.append($('<strong>').text(`${label}: `), `Missing redirect. `, $btn);
$('#mw-content-text').prepend($container);
}
}
if (formula && formula !== currentTitle) {
const formulaWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from chemical formula}}\n}}`;
await checkAndRender(formula, "Chemical formula", formulaWikitext);
}
if (cas && cas !== currentTitle) {
const casWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from CAS Registry Number}}\n{{R unprintworthy}}\n}}`;
await checkAndRender(cas, "CAS number", casWikitext);
}
}
function arrayChunk(arr, size) {
const result = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
$(document).ready(async function() {
const ns = mw.config.get("wgNamespaceNumber");
if (ns === 14) {
if (await verifyTreeMembership()) {
const $catContainer = $('#mw-pages');
if ($catContainer.length) {
const $btn = $('<button>').text('Initializing scan...').attr('id', 'caschemassist-cat-btn').addClass('mw-ui-button').css({
'margin': '0 10px 10px 0',
'float': 'right',
'position': 'relative',
'z-index': '100'
}).prop('disabled', true).click(renderCategoryAudit);
$catContainer.prepend($btn);
startBackgroundCategoryScan();
}
}
} else if (ns === 0 && mw.config.get("wgAction") === "view") {
handleSingleArticle();
}
});
})();
//</nowiki>
7brep01sam9gpm5q482mnmm5aq48zp0
739302
739301
2026-04-25T08:13:47Z
Sam Sailor
26820
Test
739302
javascript
text/javascript
//<nowiki>
(function() {
mw.util.addCSS(`
.ambox-Orphan { display: block !important; }
.comrade-container {
box-shadow: 0 1px 3px rgba(0,0,0,0.1); border-radius: 2px;
padding: 10px 15px; margin: 10px 0; display: flex;
flex-direction: column; align-items: flex-start; gap: 8px;
border: 1px solid #a2a9b1; background: #fcfcfc; line-height: 1.5;
}
.comrade-header { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.comrade-content { display: flex; flex-direction: column; gap: 5px; width: 100%; }
.comrade-success { background-color: #d5f5e3 !important; border-left: 5px solid #27ae60 !important; color: #1b5e20 !important; }
.comrade-nudge { background: #fff9ea; border-left: 5px solid #f0ad4e; }
.comrade-info { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-status { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-container button:not(.comrade-dismiss) {
background: #f8f9fa; border: 1px solid #a2a9b1; border-radius: 2px;
padding: 2px 10px; cursor: pointer; font-weight: bold; font-size: 0.95em; margin-left: 10px;
}
.comrade-container button:not(.comrade-dismiss):hover { border-color: #36c; color: #36c; background: #fff; }
.comrade-dismiss {
background: transparent; border: none; color: #d33; font-weight: bold;
cursor: pointer; font-size: 0.9em;
}
.comrade-dismiss:hover { text-decoration: underline; }
.comrade-code-block { background: #f1f1f1; padding: 4px 8px; border-radius: 3px; font-family: monospace; display: inline-block; margin-top: 4px; }
`);
function appendDismiss($el) {
$('<button>').addClass('comrade-dismiss').text('Dismiss').on('click', function() {
$(this).closest('.comrade-container').fadeOut();
}).appendTo($el.find('.comrade-header'));
$("#siteSub").after($el);
}
async function checkNamingPrecision(currentTitle, isHuman) {
const disambigMatch = currentTitle.match(/^(.+?)(?:, | \()/);
if (isHuman || !disambigMatch) return;
const baseTitle = disambigMatch[1].trim();
const api = new mw.Api();
try {
const baseRes = await api.get({
action: 'query',
titles: baseTitle,
redirects: 1,
formatversion: 2
});
const page = baseRes.query.pages[0];
const baseExists = !page.missing;
let baseRedirectsToSelf = false;
if (baseRes.query.redirects) {
baseRedirectsToSelf = baseRes.query.redirects.some(r => r.to === currentTitle);
}
const searchRes = await api.get({
action: 'query',
list: 'allpages',
apprefix: baseTitle,
aplimit: 50,
formatversion: 2
});
const competitors = searchRes.query.allpages.filter(p => {
const t = p.title;
return t !== currentTitle && t !== baseTitle && (t.startsWith(baseTitle + ', ') || t.startsWith(baseTitle + ' ('));
});
if (!baseExists || baseRedirectsToSelf) {
const $container = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (competitors.length === 0) {
$header.append($('<strong>').text('Precision:'));
const reason = baseRedirectsToSelf ? "already redirects here" : "is free";
$content.append($('<span>').append(`The base name `, $('<code>').text(baseTitle), ` ${reason}. Consider moving this article.`));
const moveUrl = mw.util.getUrl('Special:MovePage/' + currentTitle, {
wpNewTitle: baseTitle,
wpReason: 'Unnecessary disambiguation; base name is free/redirects here per [[WP:PRECISION]]'
});
$content.append($('<button>').text(`Move to ${baseTitle}`).on('click', () => window.open(moveUrl, '_blank')));
} else {
$header.append($('<strong>').text('Observation:'));
const state = baseRedirectsToSelf ? 'redirect to this page' : 'redlink';
$content.append($('<span>').append(`Base name `, $('<code>').text(baseTitle), ` has ${competitors.length} competitors, but it is currently a ${state}.`));
$content.append($('<span>').text('Consider if a disambiguation page is needed.'));
}
$container.append($header, $content);
appendDismiss($container);
}
} catch (e) {
console.warn("Comrade: Precision check failed", e);
}
}
async function checkAndRenderRedirect(redirTitle, targetTitle, rCategory, labelText, customWikitext) {
const api = new mw.Api();
const res = await api.get({
action: 'query',
titles: redirTitle,
formatversion: 2
});
if (res.query.pages[0].missing) {
const safeId = "comrade-redir-" + redirTitle.replace(/[^a-z0-9]/gi, '-');
const $container = $('<div>').addClass('comrade-container comrade-info').attr('id', safeId);
const $header = $('<div>').addClass('comrade-header');
const summary = `Created redirect from ${labelText.toLowerCase()} to [[${targetTitle}]]`;
const $btn = $('<button>').text('Create redirect').on('click', () => createModernRedirect(redirTitle, targetTitle, rCategory, summary, safeId, customWikitext));
$header.append($('<div>').append($('<strong>').text(`${labelText}: `), `Create redirect from `, $('<code>').text(redirTitle), $btn));
$container.append($header);
appendDismiss($container);
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
if (qid) {
try {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
});
const res = await fetch(url).then(r => r.json());
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
}
} catch (e) {
console.warn("Comrade: Wikidata API call failed.");
}
}
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
if (domain.includes(".") && url.pathname === "/") {
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
try {
const response = await fetch(citationURL);
if (response.ok) {
await checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
}
} catch (e) {
console.warn("Comrade: Citation API check failed.");
}
}
} catch (e) {
console.warn("Comrade: Error parsing URL object.");
}
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan, resolvedTitle) {
const api = new mw.Api();
const candidates = resolvedTitle ? [resolvedTitle] : [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
redirects: 0
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const foundTitle = page.title;
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) break;
const $container = $('<div>').addClass('comrade-container');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (isOrphan) {
$container.addClass('comrade-nudge');
$header.append($('<strong>').text('Comrade nudge:'));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
}
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(foundTitle),
target: '_blank'
}).text(foundTitle), "; should it be?"));
const snippet = '* {{anbl|' + currentTitle + '}}';
const $snippetWrapper = $('<div>').css({
'display': 'flex',
'align-items': 'center',
'gap': '10px',
'margin-top': '4px'
});
const $instructionSpan = $('<span>').append('If the Short description is good, consider adding ', $('<code>').text(snippet).css({
'background-color': '#f8f9fa',
'border': '1px solid #eaecf0',
'border-radius': '2px',
'padding': '1px 4px'
}));
const $copyBtn = $('<button>').text('Copy & insert').css('margin-left', '0').on('click', function() {
navigator.clipboard.writeText(snippet).then(() => {
const $this = $(this);
const originalText = $this.text();
$this.text('Copied!').prop('disabled', true);
const editUrl = mw.util.getUrl(foundTitle, {
action: 'edit',
summary: snippet
});
window.open(editUrl, '_blank');
setTimeout(() => {
$this.text(originalText).prop('disabled', false);
}, 1500);
});
});
$snippetWrapper.append($instructionSpan, $copyBtn);
$content.append($snippetWrapper);
if (!hasShortDesc) {
$content.append($('<span>').css({
'color': '#d33',
'font-weight': 'bold',
'margin-top': '5px'
}).text('⚠️ Missing short description: Please add a concise one before listing.'));
}
$container.append($header, $content);
appendDismiss($container);
break;
}
}
}
}
async function checkQualityConsistency() {
const oresClass = getOresPrediction();
if (!oresClass || oresClass === 'Stub') return;
const talkTitle = 'Talk:' + mw.config.get('wgPageName');
const api = new mw.Api();
try {
const [talkRes, pageRes] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: talkTitle,
rvprop: 'content',
formatversion: 2
}),
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
})
]);
const talkPage = talkRes.query.pages[0];
const wikitext = pageRes.query.pages[0].revisions[0].content;
const hasStubTag = /\{\{[^}]*stub\}\}/i.test(wikitext);
const $raterLink = $('#ca-rater, #t-rater');
const isRaterAvailable = (typeof window.rater !== 'undefined' || $raterLink.length > 0);
let talkClass = null;
if (!talkPage.missing) {
const classMatch = talkPage.revisions[0].content.match(/\|class\s*=\s*([^|}\s]+)/i);
talkClass = classMatch ? classMatch[1].charAt(0).toUpperCase() + classMatch[1].slice(1).toLowerCase() : null;
}
const isStubRated = talkClass === 'Stub';
const lingeringStubTag = (talkClass && talkClass !== 'Stub' && oresClass !== 'Stub' && hasStubTag);
if (talkPage.missing || isStubRated || lingeringStubTag) {
const $container = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').append($('<strong>').text('Quality:'), " ");
let message = `ORES suggests <b>${oresClass}</b>. `;
if (talkPage.missing) {
message = `Talk page missing; ORES suggests <b>${oresClass}</b>. `;
let $btn;
if (isRaterAvailable) {
$btn = $('<button>').text('Open Rater').on('click', () => $raterLink.find('a')[0].click());
} else {
$btn = $('<button>').text('Install Rater').on('click', () => window.open('https://en.wikipedia.org/wiki/User:Evad37/rater', '_blank'));
message += "Consider installing <i>Rater</i> for easy assessment.";
}
$content.append(message, $btn);
} else if (lingeringStubTag && !isStubRated) {
message = `Article is ${talkClass}-class, but stub tags remain. `;
const $btn = $('<button>').text('Remove stub tags').on('click', () => performStubRemoval());
$content.append(message, $btn);
} else if (isStubRated) {
message = `Talk page says Stub, but ORES predicts <b>${oresClass}</b>. `;
const $btnOres = $('<button>').text(`Promote to ${oresClass}`).on('click', () => performPromotion(oresClass));
$content.append(message, $btnOres);
if (oresClass === 'C') {
const $btnStart = $('<button>').text(`Promote to Start`).on('click', () => performPromotion('Start'));
$content.append(" or ", $btnStart);
} else if (oresClass === 'B') {
const $btnC = $('<button>').text(`Promote to C`).on('click', () => performPromotion('C'));
$content.append($('<span>').text(' or ').css('margin', '0 5px 0 20px'), $btnC);
}
}
$header.append($content);
$container.append($header);
appendDismiss($container);
}
} catch (e) {
console.error("Comrade quality check failed", e);
}
}
async function performStubRemoval() {
const api = new mw.Api();
try {
const pageData = await api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
});
let wikitext = pageData.query.pages[0].revisions[0].content;
const newWikitext = wikitext.replace(/\{\{[^}]*stub\}\}\n?/gi, '').trim();
if (wikitext === newWikitext) {
mw.notify("No stub tags found to remove.");
return;
}
await api.postWithToken('csrf', {
action: 'edit',
title: mw.config.get("wgPageName"),
text: newWikitext,
summary: `Removing lingering stub tags per ORES/Talk assessment`,
nocreate: true
});
location.reload();
} catch (err) {
console.error("Comrade failed to remove stub tags:", err);
mw.notify("Error removing stub tags.", {
type: 'error'
});
}
}
async function createModernRedirect(redirTitle, targetTitle, rCategory, customSummary, elementId, customWikitext) {
const api = new mw.Api();
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
try {
await api.postWithEditToken({
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
});
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
} catch (e) {
mw.notify("Failed to create redirect.", {
type: 'error'
});
}
}
function getOresPrediction() {
// Dependency: [[m:User:EpochFail/ArticleQuality-system.js]] renders the prediction inside .article_quality
const text = $('.article_quality').text();
if (!text) return null;
if (text.includes('Start')) return 'Start';
if (text.includes('C')) return 'C';
if (text.includes('B')) return 'B';
return null;
}
function getSmartSortKey(fullName, entity) {
const countryIds = entity.claims?.P27?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const locationIds = entity.claims?.P17?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const placeIds = [...(entity.claims?.P19 || []), ...(entity.claims?.P119 || [])].map(c => c.mainsnak?.datavalue?.value?.id).filter(Boolean);
const allRelevantIds = [...countryIds, ...locationIds, ...placeIds];
const birthYear = parseInt(entity.claims?.P569?.[0]?.mainsnak?.datavalue?.value?.time?.match(/[+-](\d{4})/)?.[1]) || null;
if (fullName.startsWith("Saint ")) return fullName.replace("Saint ", "");
const arabicParticles = /\b(Abu|Abd|Abdel|Abdul|ben|bin|bint)\b/i;
if (fullName.match(arabicParticles)) {
if (birthYear && birthYear > 1900) {
const parts = fullName.split(' ');
const binIndex = parts.findIndex(p => p.toLowerCase() === 'bin' || p.toLowerCase() === 'ben');
if (binIndex > 0) return parts.slice(binIndex).join(' ') + ', ' + parts.slice(0, binIndex).join(' ');
} else {
return fullName;
}
}
const eastAsianQids = ["Q148", "Q184", "Q884", "Q424", "Q881", "Q17"];
if (allRelevantIds.some(id => eastAsianQids.includes(id))) {
if (allRelevantIds.includes("Q17") && birthYear && birthYear > 1885) return westernSort(fullName);
const parts = fullName.split(' ');
if (parts.length > 1) return `${parts[0]}, ${parts.slice(1).join(' ')}`;
}
return westernSort(fullName);
}
function westernSort(name) {
let cleanName = name;
if (name.startsWith("O'")) cleanName = name.replace("O'", "O");
const parts = cleanName.split(' ');
if (parts.length < 2) return cleanName;
const surname = parts.pop();
if (["Jr.", "Jr", "III", "II", "Sr.", "Sr"].includes(surname)) {
const actualSurname = parts.pop();
return `${actualSurname}, ${parts.join(' ')} ${surname}`;
}
return `${surname}, ${parts.join(' ')}`;
}
async function performDeorphan() {
const api = new mw.Api();
try {
await api.edit(mw.config.get('wgPageName'), function(rev) {
let text = rev.content;
const summary = 'Article has backlinks; removed [[Template:Orphan|{{Orphan}}]]';
text = text.replace(/\{\{Orphan\s*(?:\|[^}]*)?\}\}\s*/gi, '');
text = text.replace(/(\{\{Multiple[ _]issues\s*)\|\s*\|/gi, '$1|');
text = replaceMI(text);
text = text.replace(/\n{3,}/g, '\n\n').trim();
return {
text,
summary,
minor: true
};
});
mw.notify('Orphan tag removed!', {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
setTimeout(() => location.reload(), 700);
} catch (e) {
mw.notify('Failed to remove orphan tag.', {
type: 'error'
});
}
}
async function performPromotion(newClass) {
const api = new mw.Api();
const pageName = mw.config.get('wgPageName');
const talkName = 'Talk:' + pageName;
try {
const talkData = await api.get({
action: 'query',
prop: 'revisions',
titles: talkName,
rvprop: 'content',
formatversion: 2
});
let talkWikitext = talkData.query.pages[0].revisions[0].content;
talkWikitext = talkWikitext.replace(/(\|class\s*=\s*)Stub/i, `$1${newClass}`);
await api.postWithEditToken({
action: 'edit',
title: talkName,
text: talkWikitext,
summary: `Promoting article to ${newClass} class per ORES prediction [[WP:ORES]]`
});
const artData = await api.get({
action: 'query',
prop: 'revisions',
titles: pageName,
rvprop: 'content',
formatversion: 2
});
let artWikitext = artData.query.pages[0].revisions[0].content;
const stubRegex = /\{\{[^}]*?-stub\}\}\s*\n?/gi;
const genericStubRegex = /\{\{stub\}\}\s*/gi;
artWikitext = artWikitext.replace(stubRegex, '').replace(genericStubRegex, '');
await api.postWithEditToken({
action: 'edit',
title: pageName,
text: artWikitext,
summary: `Removing stub tags; article promoted to ${newClass}`
});
location.reload();
} catch (e) {
alert("Promotion failed. Check console for details.");
console.error(e);
}
}
function replaceMI(text) {
const miRegex = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miRegex.exec(text)) !== null) {
const start = match.index;
result += text.slice(lastIndex, start);
let depth = 0;
let i = start;
while (i < text.length) {
if (text[i] === '{' && text[i + 1] === '{') {
depth++;
i += 2;
} else if (text[i] === '}' && text[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
const end = i;
const full = text.slice(start, end);
const pipeIdx = full.indexOf('|');
const inner = full.slice(pipeIdx + 1, full.length - 2).trim();
const topLevel = extractTopLevelTemplates(inner);
if (topLevel.length === 0) {
result += '';
} else if (topLevel.length === 1) {
result += topLevel[0].trim();
} else {
result += full;
}
lastIndex = end;
}
result += text.slice(lastIndex);
return result;
}
function extractTopLevelTemplates(inner) {
const templates = [];
let i = 0;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
let depth = 0;
const start = i;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
depth++;
i += 2;
} else if (inner[i] === '}' && inner[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
templates.push(inner.slice(start, i));
} else {
i++;
}
}
return templates;
}
async function performSortCorrection(newName, type) {
const api = new mw.Api();
const title = mw.config.get("wgPageName");
try {
const data = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2
});
let content = data.query.pages[0].revisions[0].content;
const dsRegex = /\{\{(?:DEFAULTSORT|Defaultsort):[^}]+\}\}/i;
const newTag = `{{DEFAULTSORT:${newName}}}`;
let actionDesc = type === "add" ? "added" : "corrected";
if (dsRegex.test(content)) {
content = content.replace(dsRegex, newTag);
} else {
const catRegex = /\[\[Category:/i;
if (catRegex.test(content)) {
content = content.replace(catRegex, `${newTag}\n[[Category:`);
} else {
content += `\n\n${newTag}`;
}
}
await api.postWithEditToken({
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName}`,
nocreate: true
});
mw.notify(`DEFAULTSORT ${actionDesc} to ${newName}!`, {
title: 'Comrade Success',
type: 'success'
});
setTimeout(() => location.reload(), 700);
} catch (err) {
mw.notify('Failed to save DEFAULTSORT.', {
type: 'error'
});
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
function renderSortNudge(target, reason) {
const $dsNudge = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Correct to: ${target}`).on('click', () => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
async function fetchWikidataNameLabels(entity) {
const familyIds = entity.claims?.P734?.map(c => c.mainsnak.datavalue.value.id) || [];
const givenIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
const allNameIds = [...new Set([...familyIds, ...givenIds])];
if (allNameIds.length === 0) return {
givens: [],
surnames: []
};
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: allNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
});
const nameData = await fetch(url).then(r => r.json());
return {
givens: givenIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean),
surnames: familyIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean)
};
}
function fixNameCasing(part, referenceName) {
return part.split(' ').map(word => {
const match = referenceName.match(new RegExp(`\\b${word}\\b`, 'i'));
if (match) return match[0];
return referenceName.split(' ').find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase())) || word;
}).join(' ');
}
function getLongNameFromWikitext(wikitext) {
const firstLine = wikitext.split('\n').find(l => l.includes("'''"));
if (!firstLine) return null;
const boldMatch = firstLine.match(/'''(.+?)'''/);
if (!boldMatch) return null;
return boldMatch[1].replace(/\s*([“"«《„‹「『'].+?[”"»》”›」』']|\([^)\n]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
}
async function init() {
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") return;
const api = new mw.Api();
const qid = mw.config.get("wgWikibaseItemId");
try {
const [pageData, backlinkData] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
api.get({
action: 'query',
list: 'backlinks',
blfilterredir: 'nonredirects',
bllimit: 50,
blnamespace: 0,
bltitle: mw.config.get("wgPageName"),
formatversion: 2
})
]);
const page = pageData.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const currentTitle = mw.config.get("wgTitle");
const cleanTitle = stripDiacritics(currentTitle);
const isOrphanTagged = /\{\{\s*(Orphan|Do-attempt|Lonely|Orp|Orphaned article)/i.test(wikitext) || /\| *orphan *=/i.test(wikitext);
const backLinkCount = backlinkData.query.backlinks.length;
if (isOrphanTagged && backLinkCount >= 1) {
const $container = $('<div>').addClass('comrade-container comrade-status');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text('Remove orphan tag').on('click', () => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) await checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
await checkDomainRedirect(qid, currentTitle);
let isHuman = false;
let entity = null;
if (qid) {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
});
const wikiData = await fetch(url).then(r => r.json());
entity = wikiData.entities[qid];
isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
}
await checkNamingPrecision(currentTitle, isHuman);
if (qid && entity) {
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const nameParts = tClean.split(' ');
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
const isOrphan = isOrphanTagged && backLinkCount < 1;
const primarySurname = hasFamilyNameHatnote ? nameParts[0] : (getSmartSortKey(tClean, entity).split(',')[0] || nameParts[nameParts.length - 1]);
let targetSortName = "";
if (isHuman) {
const {
givens,
surnames
} = await fetchWikidataNameLabels(entity);
const refName = entity.labels?.en?.value || currentTitle;
if (dsMatch) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixNameCasing(lastPart, refName)}, ${fixNameCasing(firstPart, refName)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field.");
} else if (givens.length > 0 && lastPart.split(' ').length > 1) {
const misplacedGiven = lastPart.split(' ').find(part => givens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastPart.split(' ').filter(p => p !== misplacedGiven).join(' ');
targetSortName = `${fixNameCasing(newSurname, refName)}, ${fixNameCasing(misplacedGiven + ' ' + firstPart, refName)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
}
}
} else {
targetSortName = hasFamilyNameHatnote ? `${nameParts[0]}, ${nameParts.slice(1).join(' ')}` : getSmartSortKey(tClean, entity);
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).on('click', () => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
const longName = getLongNameFromWikitext(wikitext);
if (longName && longName.length > tClean.length) {
let sortKey = hasFamilyNameHatnote ? `${longName.split(' ')[0]}, ${longName.split(' ').slice(1).join(' ')}` : getSmartSortKey(longName, entity);
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
await checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
if (targetSortName) {
let targetPage = currentTitle.includes(' (') ? currentTitle.split(' (')[0] : currentTitle;
let rCat = targetPage !== currentTitle ? "R from ambiguous sort name" : "R from sort name";
if (targetSortName.includes(',')) {
const p = targetSortName.split(',');
rCat += `|${p[0].trim().charAt(0).toUpperCase()}|${p[1].trim().charAt(0).toUpperCase()}`;
}
await checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name");
}
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
surnames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
givens.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) finalGivenNames.add(gn);
});
if (finalFamilyNames.size === 0 && nameParts.length >= 2) finalFamilyNames.add(nameParts[nameParts.length - 1]);
if (finalGivenNames.size === 0 && nameParts.length >= 2) finalGivenNames.add(nameParts[0]);
const getPriorityTarget = async (name, type) => {
const titles = type === 'surname' ? [`List of people with surname ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`] : [`List of people with given name ${name}`, `${name} (given name)`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
return titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
};
for (const name of finalGivenNames) {
if (name.toLowerCase() === primarySurname.toLowerCase()) continue;
const target = await getPriorityTarget(name, 'given');
if (target) await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
for (const name of finalFamilyNames) {
const titles = [name, `${name} (surname)`, `List of people with surname ${name}`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
const target = titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
if (target) await checkNameList(name, 'surname', currentTitle, hasShortDesc, isOrphan, target);
}
}
const oresWait = setInterval(() => {
if ($('.article_quality').length) {
clearInterval(oresWait);
checkQualityConsistency();
}
}, 500);
setTimeout(() => clearInterval(oresWait), 5000);
}
} catch (err) {
console.error("Comrade error:", err);
}
}
init();
})();
(function() {
if (window.IllWill) return;
const STRIKEOUT = true;
const HIDE = 'hide';
const IllWill = {
Version: "1.1.1",
Attribution: 'created by <a target="_blank" href="/wiki/User:Cobaltcigs">cobaltcigs</a>',
Summary: "Added interlanguage links using IllWill.js",
DataApi: "https://www.wikidata.org/w/api.php",
LocalLang: mw.config.get("wgPageContentLanguage"),
Disambiguator: / \(.*\)$/,
SiteLinks: {},
Config: {
MaxLanguages: 5,
IgnoreScientific: STRIKEOUT,
AutoDiff: true
},
css: `
#illwill { clear: both; margin-bottom: 1.5em; border: 1px solid #a2a9b1; padding: 15px; background: #f8f9fa; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.ill-sitelinks a { display: inline-block; margin-bottom: 3px; font-size: 0.9em; color: #36c; }
.ill-size-tag { font-size: 0.8em; color: #72777d; margin-left: 6px; white-space: nowrap; }
.illwill-ignore { text-decoration: line-through; color: #72777d; font-size: 0.85em; }
.ill-x { width: 35px; text-align: center; }
.ill-input, .ill-output { font-family: 'Courier New', monospace; white-space: pre-wrap; font-size: 0.9em; background: #fff; border: 1px solid #eaecf0; padding: 4px; border-radius: 2px; }
#ill-buttons { text-align: right; padding: 10px; border-top: 1px solid #a2a9b1; margin-top: 10px; }
#ill-buttons button { margin-left: 10px; cursor: pointer; padding: 6px 16px; border-radius: 2px; border: 1px solid #a2a9b1; transition: all 0.2s; }
#ill-ok { background: #36c; color: #fff; border-color: #36c !important; font-weight: bold; }
#ill-ok:hover { background: #447ff5; }
#ill-ok:disabled { background: #eaecf0; color: #72777d; border-color: #c8ccd1 !important; cursor: not-allowed; opacity: 0.7; }
#ill-table { width: 100%; border-collapse: collapse; margin-top: 10px; background: white; }
#ill-table th { background: #f2f2f2; text-align: left; padding: 8px; border: 1px solid #a2a9b1; }
#ill-table td { padding: 8px; border: 1px solid #a2a9b1; vertical-align: top; }
#ill-table caption { background: #eaffea; font-weight: bold; padding: 10px; border: 1px solid #a2a9b1; border-bottom: none; font-size: 1.1em; }
#ill-help { font-size: 0.85em; color: #54595d; padding: 10px; line-height: 1.4; }
.ill-loading-inline { font-style: italic; color: #72777d; font-size: 0.9em; display: block; margin: 5px 0; }
`,
setup() {
if (!["edit", "submit"].includes(mw.config.get('wgAction'))) return;
mw.loader.addStyleTag(this.css);
const link = mw.util.addPortletLink('p-cactions', '#', 'IllWill', 'ca-illwill', "Add interlanguage links via Wikidata");
if (link) {
link.addEventListener('click', (e) => {
e.preventDefault();
this.makePanel();
});
}
},
norm(s) {
if (!s) return "";
const t = s.trim().replace(/[\s_]+/g, " ");
return t.charAt(0).toUpperCase() + t.slice(1);
},
async getRedLinks($textbox) {
const txt = $textbox.textSelection('getContents') || "";
const wl = txt.match(/\[\[[^[\]\n]+\]\]/g);
if (!wl) return {
reds: [],
txt
};
const api = new mw.Api();
const textToParse = wl.join("").replace(/\|[^\]]+/g, "");
try {
const data = await api.post({
action: 'parse',
text: textToParse,
contentmodel: 'wikitext',
formatversion: 2
});
const tempDiv = document.createElement('div');
tempDiv.innerHTML = data.parse.text;
const reds = Array.from(tempDiv.querySelectorAll("a.new")).map(x => {
const urlParams = new URLSearchParams(x.href.split('?')[1]);
return urlParams.get('title')?.replace(/_/g, " ");
}).filter(Boolean);
return {
reds: [...new Set(reds)],
txt
};
} catch (e) {
return {
reds: [],
txt
};
}
},
async makePanel() {
const $editform = $('#editform');
const $textbox = $('#wpTextbox1');
$('#illwill').remove();
$editform.hide();
const $wrapper = $('<div id="illwill">Loading redlinks...</div>').insertBefore($editform);
const {
reds,
txt
} = await this.getRedLinks($textbox);
const wtLinks = [];
reds.forEach(title => {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s/g, "[\\s_]+");
const re = new RegExp("\\[\\[\\s*" + escaped + "\\s*(?:\\|[^\\[\\]]+)?\\]\\]", "ig");
const match = txt.match(re);
if (match && !wtLinks.some(z => z.title === title)) {
wtLinks.push({
link: match[0],
title
});
}
});
if (!wtLinks.length) {
const $back = $('<button>Quit</button>').on('click', () => {
$wrapper.remove();
$editform.show();
});
$wrapper.empty().append($('<strong>No valid redlinks found in text.</strong> '), $back);
return;
}
const $table = $(`
<table id="ill-table" class="wikitable">
<caption>IllWill.js (v${this.Version})</caption>
<thead>
<tr>
<th class="ill-x"><input type="checkbox" id="ill-select-all" disabled></th>
<th>Input link</th>
<th>Possible Wikidata matches</th>
<th>Sitelinks (top ${this.Config.MaxLanguages} by size)</th>
<th>Resulting wikitext</th>
</tr>
</thead>
<tbody></tbody>
<tfoot>
<tr>
<td colspan="3" id="ill-help">Carefully examine possible Wikidata matches.</td>
<td colspan="2" id="ill-buttons">
<button id="ill-cancel">Cancel</button>
<button id="ill-ok" disabled>Show changes</button>
</td>
</tr>
</tfoot>
</table>
`);
const $tbody = $table.find('tbody');
wtLinks.forEach((item, i) => {
$tbody.append(`
<tr id="ill-row-${i}" data-index="${i}">
<td class="ill-x"><input type="checkbox" class="ill-row-check" disabled></td>
<td class="ill-input">${this.escapeHTML(item.link)}</td>
<td class="ill-wd-item"><span class="ill-loading-inline">Searching...</span></td>
<td class="ill-sitelinks"></td>
<td class="ill-output"></td>
</tr>
`);
});
$wrapper.empty().append($table);
const chunkSize = 5;
for (let i = 0; i < wtLinks.length; i += chunkSize) {
const chunk = wtLinks.slice(i, i + chunkSize);
await Promise.all(chunk.map((item, idx) => this.fetchWikidataOptimized(item.title, i + idx)));
}
$table.on('click', '#ill-cancel', () => {
$editform.show();
$wrapper.remove();
});
$table.on('click', '#ill-ok', () => this.onOkButton($editform, $wrapper, $textbox));
$table.on('change', '#ill-select-all', (e) => {
const isChecked = $(e.target).is(':checked');
$table.find('.ill-row-check:not(:disabled)').each(function() {
$(this).prop('checked', isChecked).trigger('change');
});
});
$table.on('change', '.ill-row-check', (e) => {
this.onCheckbox(e);
this.updateOkButtonState($table);
});
$table.on('change', 'input[type="radio"]', (e) => {
this.showSiteLinks(e);
});
},
async fetchWikidataOptimized(title, index) {
const query = title.replace(this.Disambiguator, "");
const url = `${this.DataApi}?action=wbsearchentities&search=${encodeURIComponent(query)}&language=${this.LocalLang}&limit=5&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
const results = data.search;
const $cell = $(`#ill-row-${index} .ill-wd-item`);
if (!results || !results.length) {
$cell.html('<span class="ill-na">(no results)</span>');
return;
}
const entityIds = results.map(r => r.id);
await this.fetchEntities(entityIds, index, $cell);
} catch (e) {
$(`#ill-row-${index} .ill-wd-item`).text("Search failed.");
}
},
async fetchEntities(ids, rowIndex, $cell) {
const url = `${this.DataApi}?action=wbgetentities&ids=${ids.join('|')}&props=labels|descriptions|claims|sitelinks&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
$cell.empty();
ids.forEach(qNum => {
const entity = data.entities[qNum];
if (!entity) return;
const isScientific = entity.claims?.P31?.some(s => s.mainsnak?.datavalue?.value?.id === "Q13442814");
if (isScientific && this.Config.IgnoreScientific === HIDE) return;
const desc = entity.descriptions?.[this.LocalLang]?.value || entity.descriptions?.en?.value || "";
const label = entity.labels?.[this.LocalLang]?.value || entity.labels?.en?.value || qNum;
const isStrikeout = isScientific && this.Config.IgnoreScientific === STRIKEOUT;
const $div = $(`
<div class="${isStrikeout ? 'illwill-ignore' : ''}">
<input type="radio" name="selection-${rowIndex}" value="${qNum}" id="radio-${qNum}" ${isStrikeout ? 'disabled' : ''}>
<label for="radio-${qNum}">
<a href="https://wikidata.org/wiki/${qNum}" target="_blank">${this.escapeHTML(label)}</a>:
<span class="ill-desc">${desc ? this.escapeHTML(desc) : '<i>no description</i>'}</span>
</label>
</div>
`);
$cell.append($div);
this.SiteLinks[qNum] = entity.sitelinks || {};
});
} catch (e) {
$cell.text("Detail fetch failed.");
}
},
updateOkButtonState($table) {
const $rows = $table.find('.ill-row-check:not(:disabled)');
const $checkedRows = $rows.filter(':checked');
const anyEnabled = $rows.length > 0;
$table.find('#ill-ok').prop('disabled', $checkedRows.length === 0);
const $master = $table.find('#ill-select-all');
$master.prop('disabled', !anyEnabled);
if (anyEnabled && $rows.length === $checkedRows.length) {
$master.prop('checked', true);
} else {
$master.prop('checked', false);
}
},
async showSiteLinks(e) {
const isManualToggling = e.fromCheckbox || false;
const qNum = e.target.value;
const $row = $(e.target).closest('tr');
const sitelinks = this.SiteLinks[qNum];
const $checkbox = $row.find('.ill-row-check');
const $sitelinkCell = $row.find('.ill-sitelinks');
const $outputCell = $row.find('.ill-output');
const $table = $('#ill-table');
const effectiveMax = Math.min(Math.max(this.Config.MaxLanguages, 3), 6);
const validKeys = Object.keys(sitelinks).filter(k => k.endsWith('wiki') && !k.includes('commons'));
$sitelinkCell.html('<span class="ill-loading-inline">Measuring articles...</span>');
$outputCell.empty();
if (!isManualToggling) {
$checkbox.prop('disabled', true).prop('checked', false);
}
const candidates = validKeys.slice(0, 25);
const sizeMap = [];
await Promise.all(candidates.map(async (key) => {
const lang = key.replace('wiki', '').replace(/_/g, '-');
const title = sitelinks[key].title;
const endpoint = `https://${lang}.wikipedia.org/w/api.php?action=query&titles=${encodeURIComponent(title)}&prop=info&format=json&origin=*`;
try {
const res = await fetch(endpoint).then(r => r.json());
const page = Object.values(res.query.pages)[0];
sizeMap.push({
lang,
title,
length: page.length || 0
});
} catch (err) {
sizeMap.push({
lang,
title,
length: 0
});
}
}));
sizeMap.sort((a, b) => b.length - a.length);
const topLinks = sizeMap.slice(0, effectiveMax);
$sitelinkCell.html(topLinks.map(item => {
const kbValue = Math.round(item.length / 1024);
return `<div><a href="https://${item.lang}.wikipedia.org/wiki/${encodeURIComponent(item.title)}" target="_blank">${item.lang}:${this.escapeHTML(item.title)}</a> <span class="ill-size-tag">(${kbValue} kB)</span></div>`;
}).join(""));
const inputWikitext = $row.find('.ill-input').text();
const match = inputWikitext.match(/\[\[(.*?)\]\]/);
if (match) {
const parts = match[1].split('|');
const targetPage = parts[0].trim();
const surfaceName = (parts[1] || parts[0]).trim();
const illLangs = topLinks.map(item => {
const isSameTitle = this.norm(item.title) === this.norm(targetPage);
return `|${item.lang}|${isSameTitle ? '' : item.title}`;
}).join("");
const ltParam = (this.norm(surfaceName) === this.norm(targetPage)) ? "" : `|lt=${surfaceName}`;
$outputCell.text(`{{ill|${targetPage}${ltParam}${illLangs}}}`);
}
$checkbox.prop('disabled', !topLinks.length);
if (!isManualToggling) {
$checkbox.prop('checked', !!topLinks.length);
}
this.updateOkButtonState($table);
},
onCheckbox(e) {
const $row = $(e.target).closest('tr');
const $radio = $row.find('input[type="radio"]:checked');
const isChecked = $(e.target).is(':checked');
if (isChecked) {
if ($radio.length) {
this.showSiteLinks({
target: $radio[0],
fromCheckbox: true
});
}
} else {
$row.find('.ill-sitelinks, .ill-output').empty();
}
},
onOkButton($editform, $wrapper, $textbox) {
let text = $textbox.textSelection('getContents');
let changed = false;
$wrapper.find('.ill-row-check:checked').each(function() {
const $row = $(this).closest('tr');
const input = $row.find('.ill-input').text();
const output = $row.find('.ill-output').text();
if (output) {
const escapedInput = input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
text = text.replace(new RegExp(escapedInput, "gi"), output);
changed = true;
}
});
if (changed) {
$('#wpSummary').val((i, v) => (v ? v + "; " : "") + this.Summary);
$textbox.textSelection('setContents', text);
$wrapper.remove();
$editform.show();
if (this.Config.AutoDiff) $('#wpDiff').click();
}
},
escapeHTML(str) {
return str.replace(/[&<>"']/g, m => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
})[m]);
}
};
window.IllWill = IllWill;
mw.loader.using(['mediawiki.util', 'mediawiki.api', 'jquery.textSelection'], () => {
$(() => IllWill.setup());
});
})();
(function() {
'use strict';
const config = {
enableNS14: true,
rootWhitelists: ['Materials'],
userWhitelist: ""
};
const categoryDataCache = {};
let scanProgress = 0;
let totalToScan = 0;
async function verifyTreeMembership() {
if (!config.enableNS14) return false;
const api = new mw.Api();
const currentTitle = mw.config.get("wgPageName").replace(/_/g, ' ');
const fullRoots = config.rootWhitelists.concat(config.userWhitelist.split(',').map(s => s.trim()).filter(s => s)).map(cat => (cat.startsWith('Category:') ? cat : `Category:${cat}`));
if (fullRoots.includes(currentTitle)) return true;
let categoriesToTable = mw.config.get('wgCategories') || [];
let checked = new Set();
while (categoriesToTable.length > 0) {
if (categoriesToTable.some(cat => fullRoots.includes('Category:' + cat))) {
return true;
}
categoriesToTable.forEach(cat => checked.add(cat));
try {
const res = await api.get({
formatversion: 2,
prop: 'categories',
titles: categoriesToTable.map(c => 'Category:' + c),
cllimit: 'max'
});
let nextBatch = [];
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.categories) {
page.categories.forEach(parent => {
const parentName = parent.title.substring('Category:'.length);
if (!checked.has(parentName)) {
nextBatch.push(parentName);
}
});
}
});
}
categoriesToTable = [...new Set(nextBatch)];
} catch (e) {
return false;
}
}
return false;
}
function getChemboxBlock(wikitext) {
const chemboxRegex = /\{\{\s*Chembox[\s_]*(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const match = wikitext.match(chemboxRegex);
return match ? match[0] : null;
}
function getFormulaFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const formulaMatch = chembox.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
let rawFormula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
rawFormula = rawFormula.split(/\s+/)[0];
return rawFormula.split(',')[0].trim();
}
const propRegex = /\{\{\s*Chembox[\s_]+Properties(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const propSection = chembox.match(propRegex);
if (propSection) {
const content = propSection[0];
const allElements = ['Ac', 'Ag', 'Al', 'Am', 'Ar', 'As', 'At', 'Au', 'B', 'Ba', 'Be', 'Bh', 'Bi', 'Bk', 'Br', 'C', 'Ca', 'Cd', 'Ce', 'Cf', 'Cl', 'Cm', 'Cn', 'Co', 'Cr', 'Cs', 'Cu', 'D', 'Db', 'Ds', 'Dy', 'Er', 'Es', 'Eu', 'F', 'Fe', 'Fl', 'Fm', 'Fr', 'Ga', 'Gd', 'Ge', 'H', 'He', 'Hf', 'Hg', 'Ho', 'Hs', 'I', 'In', 'Ir', 'K', 'Kr', 'La', 'Li', 'Lr', 'Lu', 'Lv', 'Mc', 'Md', 'Mg', 'Mn', 'Mo', 'Mt', 'N', 'Na', 'Nb', 'Nd', 'Ne', 'Nh', 'Ni', 'No', 'Np', 'O', 'Og', 'Os', 'P', 'Pa', 'Pb', 'Pd', 'Pm', 'Po', 'Pr', 'Pt', 'Pu', 'Ra', 'Rb', 'Re', 'Rf', 'Rg', 'Rh', 'Rn', 'Ru', 'S', 'Sb', 'Sc', 'Se', 'Sg', 'Si', 'Sm', 'Sn', 'Sr', 'Ta', 'Tb', 'Tc', 'Te', 'Th', 'Ti', 'Tl', 'Tm', 'Ts', 'U', 'V', 'W', 'Xe', 'Y', 'Yb', 'Zn', 'Zr'];
let data = {};
allElements.forEach(el => {
const elRegex = new RegExp(`\\|\\s*${el}\\s*=\\s*(\\d*\\.?\\d+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch && elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
});
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
elementsPresent.filter(el => !(hasC && (el === "C" || el === "H"))).sort().forEach(el => {
formulaArray.push(el + data[el]);
});
return formulaArray.join('');
}
}
return null;
}
function getCASFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const idRegex = /\{\{\s*Chembox[\s_]+Identifiers(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const idSection = chembox.match(idRegex);
if (!idSection) return null;
const content = idSection[0];
const nbsp = String.fromCharCode(160);
const casMatch = content.match(/\|\s*CASNo\d*\b(?!\s*_)\s*=\s*([^|}\n]+)/i);
if (casMatch) {
let val = casMatch[1].replace(/\{\{[^}]+\}\}/g, "").replace(/\u00AD/g, "").split(" ").join("");
while (val.indexOf(nbsp) !== -1) val = val.replace(nbsp, "");
while (val.indexOf(" ") !== -1) val = val.replace(" ", "");
const cleanedCas = val.trim();
return /^\d{2,7}-\d{2}-\d$/.test(cleanedCas) ? cleanedCas : null;
}
return null;
}
async function getWikidataValue() {
const qid = mw.config.get('wgWikidataItemId');
if (!qid) return null;
const api = new mw.ForeignApi('https://www.wikidata.org/w/api.php');
const res = await api.get({
action: 'wbgetentities',
ids: qid,
props: 'claims',
format: 'json',
formatversion: 2
});
const claims = res.entities[qid].claims;
const data = {};
if (claims.P274) data.formula = claims.P274[0].mainsnak.datavalue.value;
if (claims.P231) data.cas = claims.P231[0].mainsnak.datavalue.value;
return data;
}
async function startBackgroundCategoryScan() {
const api = new mw.Api();
const titles = $('#mw-pages li a').map((i, el) => $(el).attr('title') || $(el).text()).get();
totalToScan = titles.length;
if (totalToScan === 0) return;
const chunks = arrayChunk(titles, 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: chunk,
rvprop: 'content',
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const f = getFormulaFromWikitext(wikitext);
const c = getCASFromWikitext(wikitext);
if (f || c) categoryDataCache[page.title] = {
formula: f,
cas: c
};
});
}
scanProgress += chunk.length;
$('#caschemassist-cat-btn').text(`Scanning... ${scanProgress}/${totalToScan}`);
}
$('#caschemassist-cat-btn').text('Show missing CAS/Chem').prop('disabled', false);
}
async function renderCategoryAudit() {
const api = new mw.Api();
const missingCheckTitles = [];
Object.values(categoryDataCache).forEach(data => {
if (data.formula) missingCheckTitles.push(data.formula);
if (data.cas) missingCheckTitles.push(data.cas);
});
if (missingCheckTitles.length === 0) {
mw.notify("No valid Chembox data found in this category.");
return;
}
const existenceMap = {};
const chunks = arrayChunk([...new Set(missingCheckTitles)], 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
titles: chunk,
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(p => {
existenceMap[p.title] = !p.missing;
});
}
}
$('#mw-pages li').each(function() {
const title = $(this).find('a').attr('title') || $(this).find('a').text().trim();
const data = categoryDataCache[title];
if (!data) return;
if (data.formula && !existenceMap[data.formula]) {
$(this).append($('<span>').css({
'color': 'red',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [${data.formula}]`));
}
if (data.cas && !existenceMap[data.cas]) {
$(this).append($('<span>').css({
'color': 'darkred',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [CAS: ${data.cas}]`));
}
});
$('#caschemassist-cat-btn').text('Audit complete').prop('disabled', true);
}
async function handleSingleArticle() {
const api = new mw.Api();
const currentTitle = mw.config.get("wgTitle");
const [wikiRes, wikidata] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
getWikidataValue()
]);
const page = wikiRes.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const formula = getFormulaFromWikitext(wikitext);
const cas = getCASFromWikitext(wikitext);
if (wikidata) {
let mismatches = [];
if (formula && wikidata.formula && formula !== wikidata.formula) {
mismatches.push(`Formula mismatch: Chembox "${formula}" vs Wikidata "${wikidata.formula}"`);
}
if (cas && wikidata.cas && cas !== wikidata.cas) {
mismatches.push(`CAS mismatch: Chembox "${cas}" vs Wikidata "${wikidata.cas}"`);
}
if (mismatches.length > 0) {
const $warning = $('<div>').css({
'background': '#fff2f2',
'border': '2px solid #d33',
'padding': '10px',
'margin-bottom': '1em',
'color': '#b32424'
}).append($('<strong>').text('⚠️ Data Mismatch Detected: '), mismatches.join(' | '));
$('#mw-content-text').prepend($warning);
}
}
async function checkAndRender(target, label, customWikitext) {
const check = await api.get({
action: 'query',
titles: target,
formatversion: 2
});
if (check.query.pages[0].missing) {
const $container = $('<div>').css({
'background': '#f8f9fa',
'border': '1px solid #a2a9b1',
'padding': '10px',
'margin-bottom': '1em'
});
const $btn = $('<button>').text(`Create redirect: ${target}`).click(async function() {
await api.postWithEditToken({
action: 'edit',
title: target,
text: customWikitext,
summary: `Creating redirect to [[${currentTitle}]] (${label})`,
createonly: true
});
mw.notify(`Created redirect: ${target}`);
$container.fadeOut(400, function() {
$(this).remove();
});
});
$container.append($('<strong>').text(`${label}: `), `Missing redirect. `, $btn);
$('#mw-content-text').prepend($container);
}
}
if (formula && formula !== currentTitle) {
const formulaWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from chemical formula}}\n}}`;
await checkAndRender(formula, "Chemical formula", formulaWikitext);
}
if (cas && cas !== currentTitle) {
const casWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from CAS Registry Number}}\n{{R unprintworthy}}\n}}`;
await checkAndRender(cas, "CAS number", casWikitext);
}
}
function arrayChunk(arr, size) {
const result = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
$(document).ready(async function() {
const ns = mw.config.get("wgNamespaceNumber");
if (ns === 14) {
if (await verifyTreeMembership()) {
const $catContainer = $('#mw-pages');
if ($catContainer.length) {
const $btn = $('<button>').text('Initializing scan...').attr('id', 'caschemassist-cat-btn').addClass('mw-ui-button').css({
'margin': '0 10px 10px 0',
'float': 'right',
'position': 'relative',
'z-index': '100'
}).prop('disabled', true).click(renderCategoryAudit);
$catContainer.prepend($btn);
startBackgroundCategoryScan();
}
}
} else if (ns === 0 && mw.config.get("wgAction") === "view") {
handleSingleArticle();
}
});
})();
//</nowiki>
okhbfl61qh7i7aq4tf63hvss2rxo1p4
739303
739302
2026-04-25T08:14:54Z
Sam Sailor
26820
Test
739303
javascript
text/javascript
//<nowiki>
(function() {
mw.util.addCSS(`
.ambox-Orphan { display: block !important; }
.comrade-container {
box-shadow: 0 1px 3px rgba(0,0,0,0.1); border-radius: 2px;
padding: 10px 15px; margin: 10px 0; display: flex;
flex-direction: column; align-items: flex-start; gap: 8px;
border: 1px solid #a2a9b1; background: #fcfcfc; line-height: 1.5;
}
.comrade-header { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.comrade-content { display: flex; flex-direction: column; gap: 5px; width: 100%; }
.comrade-success { background-color: #d5f5e3 !important; border-left: 5px solid #27ae60 !important; color: #1b5e20 !important; }
.comrade-nudge { background: #fff9ea; border-left: 5px solid #f0ad4e; }
.comrade-info { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-status { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-container button:not(.comrade-dismiss) {
background: #f8f9fa; border: 1px solid #a2a9b1; border-radius: 2px;
padding: 2px 10px; cursor: pointer; font-weight: bold; font-size: 0.95em; margin-left: 10px;
}
.comrade-container button:not(.comrade-dismiss):hover { border-color: #36c; color: #36c; background: #fff; }
.comrade-dismiss {
background: transparent; border: none; color: #d33; font-weight: bold;
cursor: pointer; font-size: 0.9em;
}
.comrade-dismiss:hover { text-decoration: underline; }
.comrade-code-block { background: #f1f1f1; padding: 4px 8px; border-radius: 3px; font-family: monospace; display: inline-block; margin-top: 4px; }
`);
function appendDismiss($el) {
$('<button>').addClass('comrade-dismiss').text('Dismiss').on('click', function() {
$(this).closest('.comrade-container').fadeOut();
}).appendTo($el.find('.comrade-header'));
$("#siteSub").after($el);
}
async function checkNamingPrecision(currentTitle, isHuman) {
const disambigMatch = currentTitle.match(/^(.+?)(?:, | \()/);
if (isHuman || !disambigMatch) return;
const baseTitle = disambigMatch[1].trim();
const api = new mw.Api();
try {
const baseRes = await api.get({
action: 'query',
titles: baseTitle,
redirects: 1,
formatversion: 2
});
const page = baseRes.query.pages[0];
const baseExists = !page.missing;
let baseRedirectsToSelf = false;
if (baseRes.query.redirects) {
baseRedirectsToSelf = baseRes.query.redirects.some(r => r.to === currentTitle);
}
const searchRes = await api.get({
action: 'query',
list: 'allpages',
apprefix: baseTitle,
aplimit: 50,
formatversion: 2
});
const competitors = searchRes.query.allpages.filter(p => {
const t = p.title;
return t !== currentTitle && t !== baseTitle && (t.startsWith(baseTitle + ', ') || t.startsWith(baseTitle + ' ('));
});
if (!baseExists || baseRedirectsToSelf) {
const $container = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (competitors.length === 0) {
$header.append($('<strong>').text('Precision:'));
const reason = baseRedirectsToSelf ? "already redirects here" : "is free";
$content.append($('<span>').append(`The base name `, $('<code>').text(baseTitle), ` ${reason}. Consider moving this article.`));
const moveUrl = mw.util.getUrl('Special:MovePage/' + currentTitle, {
wpNewTitle: baseTitle,
wpReason: 'Unnecessary disambiguation; base name is free/redirects here per [[WP:PRECISION]]'
});
$content.append($('<button>').text(`Move to ${baseTitle}`).on('click', () => window.open(moveUrl, '_blank')));
} else {
$header.append($('<strong>').text('Observation:'));
const state = baseRedirectsToSelf ? 'redirect to this page' : 'redlink';
$content.append($('<span>').append(`Base name `, $('<code>').text(baseTitle), ` has ${competitors.length} competitors, but it is currently a ${state}.`));
$content.append($('<span>').text('Consider if a disambiguation page is needed.'));
}
$container.append($header, $content);
appendDismiss($container);
}
} catch (e) {
console.warn("Comrade: Precision check failed", e);
}
}
async function checkAndRenderRedirect(redirTitle, targetTitle, rCategory, labelText, customWikitext) {
const api = new mw.Api();
const res = await api.get({
action: 'query',
titles: redirTitle,
formatversion: 2
});
if (res.query.pages[0].missing) {
const safeId = "comrade-redir-" + redirTitle.replace(/[^a-z0-9]/gi, '-');
const $container = $('<div>').addClass('comrade-container comrade-info').attr('id', safeId);
const $header = $('<div>').addClass('comrade-header');
const summary = `Created redirect from ${labelText.toLowerCase()} to [[${targetTitle}]]`;
const $btn = $('<button>').text('Create redirect').on('click', () => createModernRedirect(redirTitle, targetTitle, rCategory, summary, safeId, customWikitext));
$header.append($('<div>').append($('<strong>').text(`${labelText}: `), `Create redirect from `, $('<code>').text(redirTitle), $btn));
$container.append($header);
appendDismiss($container);
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
if (qid) {
try {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
});
const res = await fetch(url).then(r => r.json());
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
}
} catch (e) {
console.warn("Comrade: Wikidata API call failed.");
}
}
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
if (domain.includes(".") && url.pathname === "/") {
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
try {
const response = await fetch(citationURL);
if (response.ok) {
await checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
}
} catch (e) {
console.warn("Comrade: Citation API check failed.");
}
}
} catch (e) {
console.warn("Comrade: Error parsing URL object.");
}
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan, resolvedTitle) {
const api = new mw.Api();
const candidates = resolvedTitle ? [resolvedTitle] : [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
redirects: 0
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const foundTitle = page.title;
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) break;
const $container = $('<div>').addClass('comrade-container');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (isOrphan) {
$container.addClass('comrade-nudge');
$header.append($('<strong>').text('Comrade nudge:'));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
}
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(foundTitle),
target: '_blank'
}).text(foundTitle), "; should it be?"));
const snippet = '* {{anbl|' + currentTitle + '}}';
const $snippetWrapper = $('<div>').css({
'display': 'flex',
'align-items': 'center',
'gap': '10px',
'margin-top': '4px'
});
const $instructionSpan = $('<span>').append('If the Short description is good, consider adding ', $('<code>').text(snippet).css({
'background-color': '#f8f9fa',
'border': '1px solid #eaecf0',
'border-radius': '2px',
'padding': '1px 4px'
}));
const $copyBtn = $('<button>').text('Copy & insert').css('margin-left', '0').on('click', function() {
navigator.clipboard.writeText(snippet).then(() => {
const $this = $(this);
const originalText = $this.text();
$this.text('Copied!').prop('disabled', true);
const editUrl = mw.util.getUrl(foundTitle, {
action: 'edit',
summary: snippet
});
window.open(editUrl, '_blank');
setTimeout(() => {
$this.text(originalText).prop('disabled', false);
}, 1500);
});
});
$snippetWrapper.append($instructionSpan, $copyBtn);
$content.append($snippetWrapper);
if (!hasShortDesc) {
$content.append($('<span>').css({
'color': '#d33',
'font-weight': 'bold',
'margin-top': '5px'
}).text('⚠️ Missing short description: Please add a concise one before listing.'));
}
$container.append($header, $content);
appendDismiss($container);
break;
}
}
}
}
async function checkQualityConsistency() {
const oresClass = getOresPrediction();
if (!oresClass || oresClass === 'Stub') return;
const talkTitle = 'Talk:' + mw.config.get('wgPageName');
const api = new mw.Api();
try {
const [talkRes, pageRes] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: talkTitle,
rvprop: 'content',
formatversion: 2
}),
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
})
]);
const talkPage = talkRes.query.pages[0];
const wikitext = pageRes.query.pages[0].revisions[0].content;
const hasStubTag = /\{\{[^}]*stub\}\}/i.test(wikitext);
const $raterLink = $('#ca-rater, #t-rater');
const isRaterAvailable = (typeof window.rater !== 'undefined' || $raterLink.length > 0);
let talkClass = null;
if (!talkPage.missing) {
const classMatch = talkPage.revisions[0].content.match(/\|class\s*=\s*([^|}\s]+)/i);
talkClass = classMatch ? classMatch[1].charAt(0).toUpperCase() + classMatch[1].slice(1).toLowerCase() : null;
}
const isStubRated = talkClass === 'Stub';
const lingeringStubTag = (talkClass && talkClass !== 'Stub' && oresClass !== 'Stub' && hasStubTag);
if (talkPage.missing || isStubRated || lingeringStubTag) {
const $container = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').append($('<strong>').text('Quality:'), " ");
let message = `ORES suggests <b>${oresClass}</b>. `;
if (talkPage.missing) {
message = `Talk page missing; ORES suggests <b>${oresClass}</b>. `;
let $btn;
if (isRaterAvailable) {
$btn = $('<button>').text('Open Rater').on('click', () => $raterLink.find('a')[0].click());
} else {
$btn = $('<button>').text('Install Rater').on('click', () => window.open('https://en.wikipedia.org/wiki/User:Evad37/rater', '_blank'));
message += "Consider installing <i>Rater</i> for easy assessment.";
}
$content.append(message, $btn);
} else if (lingeringStubTag && !isStubRated) {
message = `Article is ${talkClass}-class, but stub tags remain. `;
const $btn = $('<button>').text('Remove stub tags').on('click', () => performStubRemoval());
$content.append(message, $btn);
} else if (isStubRated) {
message = `Talk page says Stub, but ORES predicts <b>${oresClass}</b>. `;
const $btnOres = $('<button>').text(`Promote to ${oresClass}`).on('click', () => performPromotion(oresClass));
$content.append(message, $btnOres);
if (oresClass === 'C') {
const $btnStart = $('<button>').text(`Promote to Start`).on('click', () => performPromotion('Start'));
$content.append(" or ", $btnStart);
} else if (oresClass === 'B') {
const $btnC = $('<button>').text(`Promote to C`).on('click', () => performPromotion('C'));
$content.append($('<span>').text(' or ').css('margin', '0 5px 0 50px'), $btnC);
}
}
$header.append($content);
$container.append($header);
appendDismiss($container);
}
} catch (e) {
console.error("Comrade quality check failed", e);
}
}
async function performStubRemoval() {
const api = new mw.Api();
try {
const pageData = await api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
});
let wikitext = pageData.query.pages[0].revisions[0].content;
const newWikitext = wikitext.replace(/\{\{[^}]*stub\}\}\n?/gi, '').trim();
if (wikitext === newWikitext) {
mw.notify("No stub tags found to remove.");
return;
}
await api.postWithToken('csrf', {
action: 'edit',
title: mw.config.get("wgPageName"),
text: newWikitext,
summary: `Removing lingering stub tags per ORES/Talk assessment`,
nocreate: true
});
location.reload();
} catch (err) {
console.error("Comrade failed to remove stub tags:", err);
mw.notify("Error removing stub tags.", {
type: 'error'
});
}
}
async function createModernRedirect(redirTitle, targetTitle, rCategory, customSummary, elementId, customWikitext) {
const api = new mw.Api();
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
try {
await api.postWithEditToken({
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
});
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
} catch (e) {
mw.notify("Failed to create redirect.", {
type: 'error'
});
}
}
function getOresPrediction() {
// Dependency: [[m:User:EpochFail/ArticleQuality-system.js]] renders the prediction inside .article_quality
const text = $('.article_quality').text();
if (!text) return null;
if (text.includes('Start')) return 'Start';
if (text.includes('C')) return 'C';
if (text.includes('B')) return 'B';
return null;
}
function getSmartSortKey(fullName, entity) {
const countryIds = entity.claims?.P27?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const locationIds = entity.claims?.P17?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const placeIds = [...(entity.claims?.P19 || []), ...(entity.claims?.P119 || [])].map(c => c.mainsnak?.datavalue?.value?.id).filter(Boolean);
const allRelevantIds = [...countryIds, ...locationIds, ...placeIds];
const birthYear = parseInt(entity.claims?.P569?.[0]?.mainsnak?.datavalue?.value?.time?.match(/[+-](\d{4})/)?.[1]) || null;
if (fullName.startsWith("Saint ")) return fullName.replace("Saint ", "");
const arabicParticles = /\b(Abu|Abd|Abdel|Abdul|ben|bin|bint)\b/i;
if (fullName.match(arabicParticles)) {
if (birthYear && birthYear > 1900) {
const parts = fullName.split(' ');
const binIndex = parts.findIndex(p => p.toLowerCase() === 'bin' || p.toLowerCase() === 'ben');
if (binIndex > 0) return parts.slice(binIndex).join(' ') + ', ' + parts.slice(0, binIndex).join(' ');
} else {
return fullName;
}
}
const eastAsianQids = ["Q148", "Q184", "Q884", "Q424", "Q881", "Q17"];
if (allRelevantIds.some(id => eastAsianQids.includes(id))) {
if (allRelevantIds.includes("Q17") && birthYear && birthYear > 1885) return westernSort(fullName);
const parts = fullName.split(' ');
if (parts.length > 1) return `${parts[0]}, ${parts.slice(1).join(' ')}`;
}
return westernSort(fullName);
}
function westernSort(name) {
let cleanName = name;
if (name.startsWith("O'")) cleanName = name.replace("O'", "O");
const parts = cleanName.split(' ');
if (parts.length < 2) return cleanName;
const surname = parts.pop();
if (["Jr.", "Jr", "III", "II", "Sr.", "Sr"].includes(surname)) {
const actualSurname = parts.pop();
return `${actualSurname}, ${parts.join(' ')} ${surname}`;
}
return `${surname}, ${parts.join(' ')}`;
}
async function performDeorphan() {
const api = new mw.Api();
try {
await api.edit(mw.config.get('wgPageName'), function(rev) {
let text = rev.content;
const summary = 'Article has backlinks; removed [[Template:Orphan|{{Orphan}}]]';
text = text.replace(/\{\{Orphan\s*(?:\|[^}]*)?\}\}\s*/gi, '');
text = text.replace(/(\{\{Multiple[ _]issues\s*)\|\s*\|/gi, '$1|');
text = replaceMI(text);
text = text.replace(/\n{3,}/g, '\n\n').trim();
return {
text,
summary,
minor: true
};
});
mw.notify('Orphan tag removed!', {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
setTimeout(() => location.reload(), 700);
} catch (e) {
mw.notify('Failed to remove orphan tag.', {
type: 'error'
});
}
}
async function performPromotion(newClass) {
const api = new mw.Api();
const pageName = mw.config.get('wgPageName');
const talkName = 'Talk:' + pageName;
try {
const talkData = await api.get({
action: 'query',
prop: 'revisions',
titles: talkName,
rvprop: 'content',
formatversion: 2
});
let talkWikitext = talkData.query.pages[0].revisions[0].content;
talkWikitext = talkWikitext.replace(/(\|class\s*=\s*)Stub/i, `$1${newClass}`);
await api.postWithEditToken({
action: 'edit',
title: talkName,
text: talkWikitext,
summary: `Promoting article to ${newClass} class per ORES prediction [[WP:ORES]]`
});
const artData = await api.get({
action: 'query',
prop: 'revisions',
titles: pageName,
rvprop: 'content',
formatversion: 2
});
let artWikitext = artData.query.pages[0].revisions[0].content;
const stubRegex = /\{\{[^}]*?-stub\}\}\s*\n?/gi;
const genericStubRegex = /\{\{stub\}\}\s*/gi;
artWikitext = artWikitext.replace(stubRegex, '').replace(genericStubRegex, '');
await api.postWithEditToken({
action: 'edit',
title: pageName,
text: artWikitext,
summary: `Removing stub tags; article promoted to ${newClass}`
});
location.reload();
} catch (e) {
alert("Promotion failed. Check console for details.");
console.error(e);
}
}
function replaceMI(text) {
const miRegex = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miRegex.exec(text)) !== null) {
const start = match.index;
result += text.slice(lastIndex, start);
let depth = 0;
let i = start;
while (i < text.length) {
if (text[i] === '{' && text[i + 1] === '{') {
depth++;
i += 2;
} else if (text[i] === '}' && text[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
const end = i;
const full = text.slice(start, end);
const pipeIdx = full.indexOf('|');
const inner = full.slice(pipeIdx + 1, full.length - 2).trim();
const topLevel = extractTopLevelTemplates(inner);
if (topLevel.length === 0) {
result += '';
} else if (topLevel.length === 1) {
result += topLevel[0].trim();
} else {
result += full;
}
lastIndex = end;
}
result += text.slice(lastIndex);
return result;
}
function extractTopLevelTemplates(inner) {
const templates = [];
let i = 0;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
let depth = 0;
const start = i;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
depth++;
i += 2;
} else if (inner[i] === '}' && inner[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
templates.push(inner.slice(start, i));
} else {
i++;
}
}
return templates;
}
async function performSortCorrection(newName, type) {
const api = new mw.Api();
const title = mw.config.get("wgPageName");
try {
const data = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2
});
let content = data.query.pages[0].revisions[0].content;
const dsRegex = /\{\{(?:DEFAULTSORT|Defaultsort):[^}]+\}\}/i;
const newTag = `{{DEFAULTSORT:${newName}}}`;
let actionDesc = type === "add" ? "added" : "corrected";
if (dsRegex.test(content)) {
content = content.replace(dsRegex, newTag);
} else {
const catRegex = /\[\[Category:/i;
if (catRegex.test(content)) {
content = content.replace(catRegex, `${newTag}\n[[Category:`);
} else {
content += `\n\n${newTag}`;
}
}
await api.postWithEditToken({
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName}`,
nocreate: true
});
mw.notify(`DEFAULTSORT ${actionDesc} to ${newName}!`, {
title: 'Comrade Success',
type: 'success'
});
setTimeout(() => location.reload(), 700);
} catch (err) {
mw.notify('Failed to save DEFAULTSORT.', {
type: 'error'
});
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
function renderSortNudge(target, reason) {
const $dsNudge = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Correct to: ${target}`).on('click', () => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
async function fetchWikidataNameLabels(entity) {
const familyIds = entity.claims?.P734?.map(c => c.mainsnak.datavalue.value.id) || [];
const givenIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
const allNameIds = [...new Set([...familyIds, ...givenIds])];
if (allNameIds.length === 0) return {
givens: [],
surnames: []
};
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: allNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
});
const nameData = await fetch(url).then(r => r.json());
return {
givens: givenIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean),
surnames: familyIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean)
};
}
function fixNameCasing(part, referenceName) {
return part.split(' ').map(word => {
const match = referenceName.match(new RegExp(`\\b${word}\\b`, 'i'));
if (match) return match[0];
return referenceName.split(' ').find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase())) || word;
}).join(' ');
}
function getLongNameFromWikitext(wikitext) {
const firstLine = wikitext.split('\n').find(l => l.includes("'''"));
if (!firstLine) return null;
const boldMatch = firstLine.match(/'''(.+?)'''/);
if (!boldMatch) return null;
return boldMatch[1].replace(/\s*([“"«《„‹「『'].+?[”"»》”›」』']|\([^)\n]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
}
async function init() {
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") return;
const api = new mw.Api();
const qid = mw.config.get("wgWikibaseItemId");
try {
const [pageData, backlinkData] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
api.get({
action: 'query',
list: 'backlinks',
blfilterredir: 'nonredirects',
bllimit: 50,
blnamespace: 0,
bltitle: mw.config.get("wgPageName"),
formatversion: 2
})
]);
const page = pageData.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const currentTitle = mw.config.get("wgTitle");
const cleanTitle = stripDiacritics(currentTitle);
const isOrphanTagged = /\{\{\s*(Orphan|Do-attempt|Lonely|Orp|Orphaned article)/i.test(wikitext) || /\| *orphan *=/i.test(wikitext);
const backLinkCount = backlinkData.query.backlinks.length;
if (isOrphanTagged && backLinkCount >= 1) {
const $container = $('<div>').addClass('comrade-container comrade-status');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text('Remove orphan tag').on('click', () => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) await checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
await checkDomainRedirect(qid, currentTitle);
let isHuman = false;
let entity = null;
if (qid) {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
});
const wikiData = await fetch(url).then(r => r.json());
entity = wikiData.entities[qid];
isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
}
await checkNamingPrecision(currentTitle, isHuman);
if (qid && entity) {
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const nameParts = tClean.split(' ');
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
const isOrphan = isOrphanTagged && backLinkCount < 1;
const primarySurname = hasFamilyNameHatnote ? nameParts[0] : (getSmartSortKey(tClean, entity).split(',')[0] || nameParts[nameParts.length - 1]);
let targetSortName = "";
if (isHuman) {
const {
givens,
surnames
} = await fetchWikidataNameLabels(entity);
const refName = entity.labels?.en?.value || currentTitle;
if (dsMatch) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixNameCasing(lastPart, refName)}, ${fixNameCasing(firstPart, refName)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field.");
} else if (givens.length > 0 && lastPart.split(' ').length > 1) {
const misplacedGiven = lastPart.split(' ').find(part => givens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastPart.split(' ').filter(p => p !== misplacedGiven).join(' ');
targetSortName = `${fixNameCasing(newSurname, refName)}, ${fixNameCasing(misplacedGiven + ' ' + firstPart, refName)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
}
}
} else {
targetSortName = hasFamilyNameHatnote ? `${nameParts[0]}, ${nameParts.slice(1).join(' ')}` : getSmartSortKey(tClean, entity);
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).on('click', () => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
const longName = getLongNameFromWikitext(wikitext);
if (longName && longName.length > tClean.length) {
let sortKey = hasFamilyNameHatnote ? `${longName.split(' ')[0]}, ${longName.split(' ').slice(1).join(' ')}` : getSmartSortKey(longName, entity);
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
await checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
if (targetSortName) {
let targetPage = currentTitle.includes(' (') ? currentTitle.split(' (')[0] : currentTitle;
let rCat = targetPage !== currentTitle ? "R from ambiguous sort name" : "R from sort name";
if (targetSortName.includes(',')) {
const p = targetSortName.split(',');
rCat += `|${p[0].trim().charAt(0).toUpperCase()}|${p[1].trim().charAt(0).toUpperCase()}`;
}
await checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name");
}
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
surnames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
givens.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) finalGivenNames.add(gn);
});
if (finalFamilyNames.size === 0 && nameParts.length >= 2) finalFamilyNames.add(nameParts[nameParts.length - 1]);
if (finalGivenNames.size === 0 && nameParts.length >= 2) finalGivenNames.add(nameParts[0]);
const getPriorityTarget = async (name, type) => {
const titles = type === 'surname' ? [`List of people with surname ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`] : [`List of people with given name ${name}`, `${name} (given name)`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
return titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
};
for (const name of finalGivenNames) {
if (name.toLowerCase() === primarySurname.toLowerCase()) continue;
const target = await getPriorityTarget(name, 'given');
if (target) await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
for (const name of finalFamilyNames) {
const titles = [name, `${name} (surname)`, `List of people with surname ${name}`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
const target = titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
if (target) await checkNameList(name, 'surname', currentTitle, hasShortDesc, isOrphan, target);
}
}
const oresWait = setInterval(() => {
if ($('.article_quality').length) {
clearInterval(oresWait);
checkQualityConsistency();
}
}, 500);
setTimeout(() => clearInterval(oresWait), 5000);
}
} catch (err) {
console.error("Comrade error:", err);
}
}
init();
})();
(function() {
if (window.IllWill) return;
const STRIKEOUT = true;
const HIDE = 'hide';
const IllWill = {
Version: "1.1.1",
Attribution: 'created by <a target="_blank" href="/wiki/User:Cobaltcigs">cobaltcigs</a>',
Summary: "Added interlanguage links using IllWill.js",
DataApi: "https://www.wikidata.org/w/api.php",
LocalLang: mw.config.get("wgPageContentLanguage"),
Disambiguator: / \(.*\)$/,
SiteLinks: {},
Config: {
MaxLanguages: 5,
IgnoreScientific: STRIKEOUT,
AutoDiff: true
},
css: `
#illwill { clear: both; margin-bottom: 1.5em; border: 1px solid #a2a9b1; padding: 15px; background: #f8f9fa; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.ill-sitelinks a { display: inline-block; margin-bottom: 3px; font-size: 0.9em; color: #36c; }
.ill-size-tag { font-size: 0.8em; color: #72777d; margin-left: 6px; white-space: nowrap; }
.illwill-ignore { text-decoration: line-through; color: #72777d; font-size: 0.85em; }
.ill-x { width: 35px; text-align: center; }
.ill-input, .ill-output { font-family: 'Courier New', monospace; white-space: pre-wrap; font-size: 0.9em; background: #fff; border: 1px solid #eaecf0; padding: 4px; border-radius: 2px; }
#ill-buttons { text-align: right; padding: 10px; border-top: 1px solid #a2a9b1; margin-top: 10px; }
#ill-buttons button { margin-left: 10px; cursor: pointer; padding: 6px 16px; border-radius: 2px; border: 1px solid #a2a9b1; transition: all 0.2s; }
#ill-ok { background: #36c; color: #fff; border-color: #36c !important; font-weight: bold; }
#ill-ok:hover { background: #447ff5; }
#ill-ok:disabled { background: #eaecf0; color: #72777d; border-color: #c8ccd1 !important; cursor: not-allowed; opacity: 0.7; }
#ill-table { width: 100%; border-collapse: collapse; margin-top: 10px; background: white; }
#ill-table th { background: #f2f2f2; text-align: left; padding: 8px; border: 1px solid #a2a9b1; }
#ill-table td { padding: 8px; border: 1px solid #a2a9b1; vertical-align: top; }
#ill-table caption { background: #eaffea; font-weight: bold; padding: 10px; border: 1px solid #a2a9b1; border-bottom: none; font-size: 1.1em; }
#ill-help { font-size: 0.85em; color: #54595d; padding: 10px; line-height: 1.4; }
.ill-loading-inline { font-style: italic; color: #72777d; font-size: 0.9em; display: block; margin: 5px 0; }
`,
setup() {
if (!["edit", "submit"].includes(mw.config.get('wgAction'))) return;
mw.loader.addStyleTag(this.css);
const link = mw.util.addPortletLink('p-cactions', '#', 'IllWill', 'ca-illwill', "Add interlanguage links via Wikidata");
if (link) {
link.addEventListener('click', (e) => {
e.preventDefault();
this.makePanel();
});
}
},
norm(s) {
if (!s) return "";
const t = s.trim().replace(/[\s_]+/g, " ");
return t.charAt(0).toUpperCase() + t.slice(1);
},
async getRedLinks($textbox) {
const txt = $textbox.textSelection('getContents') || "";
const wl = txt.match(/\[\[[^[\]\n]+\]\]/g);
if (!wl) return {
reds: [],
txt
};
const api = new mw.Api();
const textToParse = wl.join("").replace(/\|[^\]]+/g, "");
try {
const data = await api.post({
action: 'parse',
text: textToParse,
contentmodel: 'wikitext',
formatversion: 2
});
const tempDiv = document.createElement('div');
tempDiv.innerHTML = data.parse.text;
const reds = Array.from(tempDiv.querySelectorAll("a.new")).map(x => {
const urlParams = new URLSearchParams(x.href.split('?')[1]);
return urlParams.get('title')?.replace(/_/g, " ");
}).filter(Boolean);
return {
reds: [...new Set(reds)],
txt
};
} catch (e) {
return {
reds: [],
txt
};
}
},
async makePanel() {
const $editform = $('#editform');
const $textbox = $('#wpTextbox1');
$('#illwill').remove();
$editform.hide();
const $wrapper = $('<div id="illwill">Loading redlinks...</div>').insertBefore($editform);
const {
reds,
txt
} = await this.getRedLinks($textbox);
const wtLinks = [];
reds.forEach(title => {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s/g, "[\\s_]+");
const re = new RegExp("\\[\\[\\s*" + escaped + "\\s*(?:\\|[^\\[\\]]+)?\\]\\]", "ig");
const match = txt.match(re);
if (match && !wtLinks.some(z => z.title === title)) {
wtLinks.push({
link: match[0],
title
});
}
});
if (!wtLinks.length) {
const $back = $('<button>Quit</button>').on('click', () => {
$wrapper.remove();
$editform.show();
});
$wrapper.empty().append($('<strong>No valid redlinks found in text.</strong> '), $back);
return;
}
const $table = $(`
<table id="ill-table" class="wikitable">
<caption>IllWill.js (v${this.Version})</caption>
<thead>
<tr>
<th class="ill-x"><input type="checkbox" id="ill-select-all" disabled></th>
<th>Input link</th>
<th>Possible Wikidata matches</th>
<th>Sitelinks (top ${this.Config.MaxLanguages} by size)</th>
<th>Resulting wikitext</th>
</tr>
</thead>
<tbody></tbody>
<tfoot>
<tr>
<td colspan="3" id="ill-help">Carefully examine possible Wikidata matches.</td>
<td colspan="2" id="ill-buttons">
<button id="ill-cancel">Cancel</button>
<button id="ill-ok" disabled>Show changes</button>
</td>
</tr>
</tfoot>
</table>
`);
const $tbody = $table.find('tbody');
wtLinks.forEach((item, i) => {
$tbody.append(`
<tr id="ill-row-${i}" data-index="${i}">
<td class="ill-x"><input type="checkbox" class="ill-row-check" disabled></td>
<td class="ill-input">${this.escapeHTML(item.link)}</td>
<td class="ill-wd-item"><span class="ill-loading-inline">Searching...</span></td>
<td class="ill-sitelinks"></td>
<td class="ill-output"></td>
</tr>
`);
});
$wrapper.empty().append($table);
const chunkSize = 5;
for (let i = 0; i < wtLinks.length; i += chunkSize) {
const chunk = wtLinks.slice(i, i + chunkSize);
await Promise.all(chunk.map((item, idx) => this.fetchWikidataOptimized(item.title, i + idx)));
}
$table.on('click', '#ill-cancel', () => {
$editform.show();
$wrapper.remove();
});
$table.on('click', '#ill-ok', () => this.onOkButton($editform, $wrapper, $textbox));
$table.on('change', '#ill-select-all', (e) => {
const isChecked = $(e.target).is(':checked');
$table.find('.ill-row-check:not(:disabled)').each(function() {
$(this).prop('checked', isChecked).trigger('change');
});
});
$table.on('change', '.ill-row-check', (e) => {
this.onCheckbox(e);
this.updateOkButtonState($table);
});
$table.on('change', 'input[type="radio"]', (e) => {
this.showSiteLinks(e);
});
},
async fetchWikidataOptimized(title, index) {
const query = title.replace(this.Disambiguator, "");
const url = `${this.DataApi}?action=wbsearchentities&search=${encodeURIComponent(query)}&language=${this.LocalLang}&limit=5&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
const results = data.search;
const $cell = $(`#ill-row-${index} .ill-wd-item`);
if (!results || !results.length) {
$cell.html('<span class="ill-na">(no results)</span>');
return;
}
const entityIds = results.map(r => r.id);
await this.fetchEntities(entityIds, index, $cell);
} catch (e) {
$(`#ill-row-${index} .ill-wd-item`).text("Search failed.");
}
},
async fetchEntities(ids, rowIndex, $cell) {
const url = `${this.DataApi}?action=wbgetentities&ids=${ids.join('|')}&props=labels|descriptions|claims|sitelinks&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
$cell.empty();
ids.forEach(qNum => {
const entity = data.entities[qNum];
if (!entity) return;
const isScientific = entity.claims?.P31?.some(s => s.mainsnak?.datavalue?.value?.id === "Q13442814");
if (isScientific && this.Config.IgnoreScientific === HIDE) return;
const desc = entity.descriptions?.[this.LocalLang]?.value || entity.descriptions?.en?.value || "";
const label = entity.labels?.[this.LocalLang]?.value || entity.labels?.en?.value || qNum;
const isStrikeout = isScientific && this.Config.IgnoreScientific === STRIKEOUT;
const $div = $(`
<div class="${isStrikeout ? 'illwill-ignore' : ''}">
<input type="radio" name="selection-${rowIndex}" value="${qNum}" id="radio-${qNum}" ${isStrikeout ? 'disabled' : ''}>
<label for="radio-${qNum}">
<a href="https://wikidata.org/wiki/${qNum}" target="_blank">${this.escapeHTML(label)}</a>:
<span class="ill-desc">${desc ? this.escapeHTML(desc) : '<i>no description</i>'}</span>
</label>
</div>
`);
$cell.append($div);
this.SiteLinks[qNum] = entity.sitelinks || {};
});
} catch (e) {
$cell.text("Detail fetch failed.");
}
},
updateOkButtonState($table) {
const $rows = $table.find('.ill-row-check:not(:disabled)');
const $checkedRows = $rows.filter(':checked');
const anyEnabled = $rows.length > 0;
$table.find('#ill-ok').prop('disabled', $checkedRows.length === 0);
const $master = $table.find('#ill-select-all');
$master.prop('disabled', !anyEnabled);
if (anyEnabled && $rows.length === $checkedRows.length) {
$master.prop('checked', true);
} else {
$master.prop('checked', false);
}
},
async showSiteLinks(e) {
const isManualToggling = e.fromCheckbox || false;
const qNum = e.target.value;
const $row = $(e.target).closest('tr');
const sitelinks = this.SiteLinks[qNum];
const $checkbox = $row.find('.ill-row-check');
const $sitelinkCell = $row.find('.ill-sitelinks');
const $outputCell = $row.find('.ill-output');
const $table = $('#ill-table');
const effectiveMax = Math.min(Math.max(this.Config.MaxLanguages, 3), 6);
const validKeys = Object.keys(sitelinks).filter(k => k.endsWith('wiki') && !k.includes('commons'));
$sitelinkCell.html('<span class="ill-loading-inline">Measuring articles...</span>');
$outputCell.empty();
if (!isManualToggling) {
$checkbox.prop('disabled', true).prop('checked', false);
}
const candidates = validKeys.slice(0, 25);
const sizeMap = [];
await Promise.all(candidates.map(async (key) => {
const lang = key.replace('wiki', '').replace(/_/g, '-');
const title = sitelinks[key].title;
const endpoint = `https://${lang}.wikipedia.org/w/api.php?action=query&titles=${encodeURIComponent(title)}&prop=info&format=json&origin=*`;
try {
const res = await fetch(endpoint).then(r => r.json());
const page = Object.values(res.query.pages)[0];
sizeMap.push({
lang,
title,
length: page.length || 0
});
} catch (err) {
sizeMap.push({
lang,
title,
length: 0
});
}
}));
sizeMap.sort((a, b) => b.length - a.length);
const topLinks = sizeMap.slice(0, effectiveMax);
$sitelinkCell.html(topLinks.map(item => {
const kbValue = Math.round(item.length / 1024);
return `<div><a href="https://${item.lang}.wikipedia.org/wiki/${encodeURIComponent(item.title)}" target="_blank">${item.lang}:${this.escapeHTML(item.title)}</a> <span class="ill-size-tag">(${kbValue} kB)</span></div>`;
}).join(""));
const inputWikitext = $row.find('.ill-input').text();
const match = inputWikitext.match(/\[\[(.*?)\]\]/);
if (match) {
const parts = match[1].split('|');
const targetPage = parts[0].trim();
const surfaceName = (parts[1] || parts[0]).trim();
const illLangs = topLinks.map(item => {
const isSameTitle = this.norm(item.title) === this.norm(targetPage);
return `|${item.lang}|${isSameTitle ? '' : item.title}`;
}).join("");
const ltParam = (this.norm(surfaceName) === this.norm(targetPage)) ? "" : `|lt=${surfaceName}`;
$outputCell.text(`{{ill|${targetPage}${ltParam}${illLangs}}}`);
}
$checkbox.prop('disabled', !topLinks.length);
if (!isManualToggling) {
$checkbox.prop('checked', !!topLinks.length);
}
this.updateOkButtonState($table);
},
onCheckbox(e) {
const $row = $(e.target).closest('tr');
const $radio = $row.find('input[type="radio"]:checked');
const isChecked = $(e.target).is(':checked');
if (isChecked) {
if ($radio.length) {
this.showSiteLinks({
target: $radio[0],
fromCheckbox: true
});
}
} else {
$row.find('.ill-sitelinks, .ill-output').empty();
}
},
onOkButton($editform, $wrapper, $textbox) {
let text = $textbox.textSelection('getContents');
let changed = false;
$wrapper.find('.ill-row-check:checked').each(function() {
const $row = $(this).closest('tr');
const input = $row.find('.ill-input').text();
const output = $row.find('.ill-output').text();
if (output) {
const escapedInput = input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
text = text.replace(new RegExp(escapedInput, "gi"), output);
changed = true;
}
});
if (changed) {
$('#wpSummary').val((i, v) => (v ? v + "; " : "") + this.Summary);
$textbox.textSelection('setContents', text);
$wrapper.remove();
$editform.show();
if (this.Config.AutoDiff) $('#wpDiff').click();
}
},
escapeHTML(str) {
return str.replace(/[&<>"']/g, m => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
})[m]);
}
};
window.IllWill = IllWill;
mw.loader.using(['mediawiki.util', 'mediawiki.api', 'jquery.textSelection'], () => {
$(() => IllWill.setup());
});
})();
(function() {
'use strict';
const config = {
enableNS14: true,
rootWhitelists: ['Materials'],
userWhitelist: ""
};
const categoryDataCache = {};
let scanProgress = 0;
let totalToScan = 0;
async function verifyTreeMembership() {
if (!config.enableNS14) return false;
const api = new mw.Api();
const currentTitle = mw.config.get("wgPageName").replace(/_/g, ' ');
const fullRoots = config.rootWhitelists.concat(config.userWhitelist.split(',').map(s => s.trim()).filter(s => s)).map(cat => (cat.startsWith('Category:') ? cat : `Category:${cat}`));
if (fullRoots.includes(currentTitle)) return true;
let categoriesToTable = mw.config.get('wgCategories') || [];
let checked = new Set();
while (categoriesToTable.length > 0) {
if (categoriesToTable.some(cat => fullRoots.includes('Category:' + cat))) {
return true;
}
categoriesToTable.forEach(cat => checked.add(cat));
try {
const res = await api.get({
formatversion: 2,
prop: 'categories',
titles: categoriesToTable.map(c => 'Category:' + c),
cllimit: 'max'
});
let nextBatch = [];
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.categories) {
page.categories.forEach(parent => {
const parentName = parent.title.substring('Category:'.length);
if (!checked.has(parentName)) {
nextBatch.push(parentName);
}
});
}
});
}
categoriesToTable = [...new Set(nextBatch)];
} catch (e) {
return false;
}
}
return false;
}
function getChemboxBlock(wikitext) {
const chemboxRegex = /\{\{\s*Chembox[\s_]*(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const match = wikitext.match(chemboxRegex);
return match ? match[0] : null;
}
function getFormulaFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const formulaMatch = chembox.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
let rawFormula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
rawFormula = rawFormula.split(/\s+/)[0];
return rawFormula.split(',')[0].trim();
}
const propRegex = /\{\{\s*Chembox[\s_]+Properties(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const propSection = chembox.match(propRegex);
if (propSection) {
const content = propSection[0];
const allElements = ['Ac', 'Ag', 'Al', 'Am', 'Ar', 'As', 'At', 'Au', 'B', 'Ba', 'Be', 'Bh', 'Bi', 'Bk', 'Br', 'C', 'Ca', 'Cd', 'Ce', 'Cf', 'Cl', 'Cm', 'Cn', 'Co', 'Cr', 'Cs', 'Cu', 'D', 'Db', 'Ds', 'Dy', 'Er', 'Es', 'Eu', 'F', 'Fe', 'Fl', 'Fm', 'Fr', 'Ga', 'Gd', 'Ge', 'H', 'He', 'Hf', 'Hg', 'Ho', 'Hs', 'I', 'In', 'Ir', 'K', 'Kr', 'La', 'Li', 'Lr', 'Lu', 'Lv', 'Mc', 'Md', 'Mg', 'Mn', 'Mo', 'Mt', 'N', 'Na', 'Nb', 'Nd', 'Ne', 'Nh', 'Ni', 'No', 'Np', 'O', 'Og', 'Os', 'P', 'Pa', 'Pb', 'Pd', 'Pm', 'Po', 'Pr', 'Pt', 'Pu', 'Ra', 'Rb', 'Re', 'Rf', 'Rg', 'Rh', 'Rn', 'Ru', 'S', 'Sb', 'Sc', 'Se', 'Sg', 'Si', 'Sm', 'Sn', 'Sr', 'Ta', 'Tb', 'Tc', 'Te', 'Th', 'Ti', 'Tl', 'Tm', 'Ts', 'U', 'V', 'W', 'Xe', 'Y', 'Yb', 'Zn', 'Zr'];
let data = {};
allElements.forEach(el => {
const elRegex = new RegExp(`\\|\\s*${el}\\s*=\\s*(\\d*\\.?\\d+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch && elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
});
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
elementsPresent.filter(el => !(hasC && (el === "C" || el === "H"))).sort().forEach(el => {
formulaArray.push(el + data[el]);
});
return formulaArray.join('');
}
}
return null;
}
function getCASFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const idRegex = /\{\{\s*Chembox[\s_]+Identifiers(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const idSection = chembox.match(idRegex);
if (!idSection) return null;
const content = idSection[0];
const nbsp = String.fromCharCode(160);
const casMatch = content.match(/\|\s*CASNo\d*\b(?!\s*_)\s*=\s*([^|}\n]+)/i);
if (casMatch) {
let val = casMatch[1].replace(/\{\{[^}]+\}\}/g, "").replace(/\u00AD/g, "").split(" ").join("");
while (val.indexOf(nbsp) !== -1) val = val.replace(nbsp, "");
while (val.indexOf(" ") !== -1) val = val.replace(" ", "");
const cleanedCas = val.trim();
return /^\d{2,7}-\d{2}-\d$/.test(cleanedCas) ? cleanedCas : null;
}
return null;
}
async function getWikidataValue() {
const qid = mw.config.get('wgWikidataItemId');
if (!qid) return null;
const api = new mw.ForeignApi('https://www.wikidata.org/w/api.php');
const res = await api.get({
action: 'wbgetentities',
ids: qid,
props: 'claims',
format: 'json',
formatversion: 2
});
const claims = res.entities[qid].claims;
const data = {};
if (claims.P274) data.formula = claims.P274[0].mainsnak.datavalue.value;
if (claims.P231) data.cas = claims.P231[0].mainsnak.datavalue.value;
return data;
}
async function startBackgroundCategoryScan() {
const api = new mw.Api();
const titles = $('#mw-pages li a').map((i, el) => $(el).attr('title') || $(el).text()).get();
totalToScan = titles.length;
if (totalToScan === 0) return;
const chunks = arrayChunk(titles, 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: chunk,
rvprop: 'content',
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const f = getFormulaFromWikitext(wikitext);
const c = getCASFromWikitext(wikitext);
if (f || c) categoryDataCache[page.title] = {
formula: f,
cas: c
};
});
}
scanProgress += chunk.length;
$('#caschemassist-cat-btn').text(`Scanning... ${scanProgress}/${totalToScan}`);
}
$('#caschemassist-cat-btn').text('Show missing CAS/Chem').prop('disabled', false);
}
async function renderCategoryAudit() {
const api = new mw.Api();
const missingCheckTitles = [];
Object.values(categoryDataCache).forEach(data => {
if (data.formula) missingCheckTitles.push(data.formula);
if (data.cas) missingCheckTitles.push(data.cas);
});
if (missingCheckTitles.length === 0) {
mw.notify("No valid Chembox data found in this category.");
return;
}
const existenceMap = {};
const chunks = arrayChunk([...new Set(missingCheckTitles)], 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
titles: chunk,
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(p => {
existenceMap[p.title] = !p.missing;
});
}
}
$('#mw-pages li').each(function() {
const title = $(this).find('a').attr('title') || $(this).find('a').text().trim();
const data = categoryDataCache[title];
if (!data) return;
if (data.formula && !existenceMap[data.formula]) {
$(this).append($('<span>').css({
'color': 'red',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [${data.formula}]`));
}
if (data.cas && !existenceMap[data.cas]) {
$(this).append($('<span>').css({
'color': 'darkred',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [CAS: ${data.cas}]`));
}
});
$('#caschemassist-cat-btn').text('Audit complete').prop('disabled', true);
}
async function handleSingleArticle() {
const api = new mw.Api();
const currentTitle = mw.config.get("wgTitle");
const [wikiRes, wikidata] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
getWikidataValue()
]);
const page = wikiRes.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const formula = getFormulaFromWikitext(wikitext);
const cas = getCASFromWikitext(wikitext);
if (wikidata) {
let mismatches = [];
if (formula && wikidata.formula && formula !== wikidata.formula) {
mismatches.push(`Formula mismatch: Chembox "${formula}" vs Wikidata "${wikidata.formula}"`);
}
if (cas && wikidata.cas && cas !== wikidata.cas) {
mismatches.push(`CAS mismatch: Chembox "${cas}" vs Wikidata "${wikidata.cas}"`);
}
if (mismatches.length > 0) {
const $warning = $('<div>').css({
'background': '#fff2f2',
'border': '2px solid #d33',
'padding': '10px',
'margin-bottom': '1em',
'color': '#b32424'
}).append($('<strong>').text('⚠️ Data Mismatch Detected: '), mismatches.join(' | '));
$('#mw-content-text').prepend($warning);
}
}
async function checkAndRender(target, label, customWikitext) {
const check = await api.get({
action: 'query',
titles: target,
formatversion: 2
});
if (check.query.pages[0].missing) {
const $container = $('<div>').css({
'background': '#f8f9fa',
'border': '1px solid #a2a9b1',
'padding': '10px',
'margin-bottom': '1em'
});
const $btn = $('<button>').text(`Create redirect: ${target}`).click(async function() {
await api.postWithEditToken({
action: 'edit',
title: target,
text: customWikitext,
summary: `Creating redirect to [[${currentTitle}]] (${label})`,
createonly: true
});
mw.notify(`Created redirect: ${target}`);
$container.fadeOut(400, function() {
$(this).remove();
});
});
$container.append($('<strong>').text(`${label}: `), `Missing redirect. `, $btn);
$('#mw-content-text').prepend($container);
}
}
if (formula && formula !== currentTitle) {
const formulaWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from chemical formula}}\n}}`;
await checkAndRender(formula, "Chemical formula", formulaWikitext);
}
if (cas && cas !== currentTitle) {
const casWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from CAS Registry Number}}\n{{R unprintworthy}}\n}}`;
await checkAndRender(cas, "CAS number", casWikitext);
}
}
function arrayChunk(arr, size) {
const result = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
$(document).ready(async function() {
const ns = mw.config.get("wgNamespaceNumber");
if (ns === 14) {
if (await verifyTreeMembership()) {
const $catContainer = $('#mw-pages');
if ($catContainer.length) {
const $btn = $('<button>').text('Initializing scan...').attr('id', 'caschemassist-cat-btn').addClass('mw-ui-button').css({
'margin': '0 10px 10px 0',
'float': 'right',
'position': 'relative',
'z-index': '100'
}).prop('disabled', true).click(renderCategoryAudit);
$catContainer.prepend($btn);
startBackgroundCategoryScan();
}
}
} else if (ns === 0 && mw.config.get("wgAction") === "view") {
handleSingleArticle();
}
});
})();
//</nowiki>
n28hc5dhmy4ppdwugrpf5x2sm5g9n3f
739304
739303
2026-04-25T08:20:06Z
Sam Sailor
26820
Test
739304
javascript
text/javascript
//<nowiki>
(function() {
mw.util.addCSS(`
.ambox-Orphan { display: block !important; }
.comrade-container {
box-shadow: 0 1px 3px rgba(0,0,0,0.1); border-radius: 2px;
padding: 10px 15px; margin: 10px 0; display: flex;
flex-direction: column; align-items: flex-start; gap: 8px;
border: 1px solid #a2a9b1; background: #fcfcfc; line-height: 1.5;
}
.comrade-header { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.comrade-content { display: flex; flex-direction: column; gap: 5px; width: 100%; }
.comrade-success { background-color: #d5f5e3 !important; border-left: 5px solid #27ae60 !important; color: #1b5e20 !important; }
.comrade-nudge { background: #fff9ea; border-left: 5px solid #f0ad4e; }
.comrade-info { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-status { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-container button:not(.comrade-dismiss) {
background: #f8f9fa; border: 1px solid #a2a9b1; border-radius: 2px;
padding: 2px 10px; cursor: pointer; font-weight: bold; font-size: 0.95em; margin-left: 10px;
}
.comrade-container button:not(.comrade-dismiss):hover { border-color: #36c; color: #36c; background: #fff; }
.comrade-dismiss {
background: transparent; border: none; color: #d33; font-weight: bold;
cursor: pointer; font-size: 0.9em;
}
.comrade-dismiss:hover { text-decoration: underline; }
.comrade-code-block { background: #f1f1f1; padding: 4px 8px; border-radius: 3px; font-family: monospace; display: inline-block; margin-top: 4px; }
`);
function appendDismiss($el) {
$('<button>').addClass('comrade-dismiss').text('Dismiss').on('click', function() {
$(this).closest('.comrade-container').fadeOut();
}).appendTo($el.find('.comrade-header'));
$("#siteSub").after($el);
}
async function checkNamingPrecision(currentTitle, isHuman) {
const disambigMatch = currentTitle.match(/^(.+?)(?:, | \()/);
if (isHuman || !disambigMatch) return;
const baseTitle = disambigMatch[1].trim();
const api = new mw.Api();
try {
const baseRes = await api.get({
action: 'query',
titles: baseTitle,
redirects: 1,
formatversion: 2
});
const page = baseRes.query.pages[0];
const baseExists = !page.missing;
let baseRedirectsToSelf = false;
if (baseRes.query.redirects) {
baseRedirectsToSelf = baseRes.query.redirects.some(r => r.to === currentTitle);
}
const searchRes = await api.get({
action: 'query',
list: 'allpages',
apprefix: baseTitle,
aplimit: 50,
formatversion: 2
});
const competitors = searchRes.query.allpages.filter(p => {
const t = p.title;
return t !== currentTitle && t !== baseTitle && (t.startsWith(baseTitle + ', ') || t.startsWith(baseTitle + ' ('));
});
if (!baseExists || baseRedirectsToSelf) {
const $container = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (competitors.length === 0) {
$header.append($('<strong>').text('Precision:'));
const reason = baseRedirectsToSelf ? "already redirects here" : "is free";
$content.append($('<span>').append(`The base name `, $('<code>').text(baseTitle), ` ${reason}. Consider moving this article.`));
const moveUrl = mw.util.getUrl('Special:MovePage/' + currentTitle, {
wpNewTitle: baseTitle,
wpReason: 'Unnecessary disambiguation; base name is free/redirects here per [[WP:PRECISION]]'
});
$content.append($('<button>').text(`Move to ${baseTitle}`).on('click', () => window.open(moveUrl, '_blank')));
} else {
$header.append($('<strong>').text('Observation:'));
const state = baseRedirectsToSelf ? 'redirect to this page' : 'redlink';
$content.append($('<span>').append(`Base name `, $('<code>').text(baseTitle), ` has ${competitors.length} competitors, but it is currently a ${state}.`));
$content.append($('<span>').text('Consider if a disambiguation page is needed.'));
}
$container.append($header, $content);
appendDismiss($container);
}
} catch (e) {
console.warn("Comrade: Precision check failed", e);
}
}
async function checkAndRenderRedirect(redirTitle, targetTitle, rCategory, labelText, customWikitext) {
const api = new mw.Api();
const res = await api.get({
action: 'query',
titles: redirTitle,
formatversion: 2
});
if (res.query.pages[0].missing) {
const safeId = "comrade-redir-" + redirTitle.replace(/[^a-z0-9]/gi, '-');
const $container = $('<div>').addClass('comrade-container comrade-info').attr('id', safeId);
const $header = $('<div>').addClass('comrade-header');
const summary = `Created redirect from ${labelText.toLowerCase()} to [[${targetTitle}]]`;
const $btn = $('<button>').text('Create redirect').on('click', () => createModernRedirect(redirTitle, targetTitle, rCategory, summary, safeId, customWikitext));
$header.append($('<div>').append($('<strong>').text(`${labelText}: `), `Create redirect from `, $('<code>').text(redirTitle), $btn));
$container.append($header);
appendDismiss($container);
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
if (qid) {
try {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
});
const res = await fetch(url).then(r => r.json());
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
}
} catch (e) {
console.warn("Comrade: Wikidata API call failed.");
}
}
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
if (domain.includes(".") && url.pathname === "/") {
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
try {
const response = await fetch(citationURL);
if (response.ok) {
await checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
}
} catch (e) {
console.warn("Comrade: Citation API check failed.");
}
}
} catch (e) {
console.warn("Comrade: Error parsing URL object.");
}
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan, resolvedTitle) {
const api = new mw.Api();
const candidates = resolvedTitle ? [resolvedTitle] : [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
redirects: 0
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const foundTitle = page.title;
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) break;
const $container = $('<div>').addClass('comrade-container');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (isOrphan) {
$container.addClass('comrade-nudge');
$header.append($('<strong>').text('Comrade nudge:'));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
}
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(foundTitle),
target: '_blank'
}).text(foundTitle), "; should it be?"));
const snippet = '* {{anbl|' + currentTitle + '}}';
const $snippetWrapper = $('<div>').css({
'display': 'flex',
'align-items': 'center',
'gap': '10px',
'margin-top': '4px'
});
const $instructionSpan = $('<span>').append('If the Short description is good, consider adding ', $('<code>').text(snippet).css({
'background-color': '#f8f9fa',
'border': '1px solid #eaecf0',
'border-radius': '2px',
'padding': '1px 4px'
}));
const $copyBtn = $('<button>').text('Copy & insert').css('margin-left', '0').on('click', function() {
navigator.clipboard.writeText(snippet).then(() => {
const $this = $(this);
const originalText = $this.text();
$this.text('Copied!').prop('disabled', true);
const editUrl = mw.util.getUrl(foundTitle, {
action: 'edit',
summary: snippet
});
window.open(editUrl, '_blank');
setTimeout(() => {
$this.text(originalText).prop('disabled', false);
}, 1500);
});
});
$snippetWrapper.append($instructionSpan, $copyBtn);
$content.append($snippetWrapper);
if (!hasShortDesc) {
$content.append($('<span>').css({
'color': '#d33',
'font-weight': 'bold',
'margin-top': '5px'
}).text('⚠️ Missing short description: Please add a concise one before listing.'));
}
$container.append($header, $content);
appendDismiss($container);
break;
}
}
}
}
async function checkQualityConsistency() {
const oresClass = getOresPrediction();
if (!oresClass || oresClass === 'Stub') return;
const talkTitle = 'Talk:' + mw.config.get('wgPageName');
const api = new mw.Api();
try {
const [talkRes, pageRes] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: talkTitle,
rvprop: 'content',
formatversion: 2
}),
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
})
]);
const talkPage = talkRes.query.pages[0];
const wikitext = pageRes.query.pages[0].revisions[0].content;
const hasStubTag = /\{\{[^}]*stub\}\}/i.test(wikitext);
const $raterLink = $('#ca-rater, #t-rater');
const isRaterAvailable = (typeof window.rater !== 'undefined' || $raterLink.length > 0);
let talkClass = null;
if (!talkPage.missing) {
const classMatch = talkPage.revisions[0].content.match(/\|class\s*=\s*([^|}\s]+)/i);
talkClass = classMatch ? classMatch[1].charAt(0).toUpperCase() + classMatch[1].slice(1).toLowerCase() : null;
}
const isStubRated = talkClass === 'Stub';
const lingeringStubTag = (talkClass && talkClass !== 'Stub' && oresClass !== 'Stub' && hasStubTag);
if (talkPage.missing || isStubRated || lingeringStubTag) {
const $container = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').append($('<strong>').text('Quality:'), " ");
let message = `ORES suggests <b>${oresClass}</b>. `;
if (talkPage.missing) {
message = `Talk page missing; ORES suggests <b>${oresClass}</b>. `;
let $btn;
if (isRaterAvailable) {
$btn = $('<button>').text('Open Rater').on('click', () => $raterLink.find('a')[0].click());
} else {
$btn = $('<button>').text('Install Rater').on('click', () => window.open('https://en.wikipedia.org/wiki/User:Evad37/rater', '_blank'));
message += "Consider installing <i>Rater</i> for easy assessment.";
}
$content.append(message, $btn);
} else if (lingeringStubTag && !isStubRated) {
message = `Article is ${talkClass}-class, but stub tags remain. `;
const $btn = $('<button>').text('Remove stub tags').on('click', () => performStubRemoval());
$content.append(message, $btn);
} else if (isStubRated) {
message = `Talk page says Stub, but ORES predicts <b>${oresClass}</b>. `;
const $btnOres = $('<button>').text(`Promote to ${oresClass}`).on('click', () => performPromotion(oresClass));
$content.append(message, $btnOres);
if (oresClass === 'C') {
const $btnStart = $('<button>').text(`Promote to Start`).on('click', () => performPromotion('Start'));
$content.append(" or ", $btnStart);
} else if (oresClass === 'B') {
const $btnC = $('<button>').text(`Promote to C`).on('click', () => performPromotion('C'));
$content.append($('<span>').text(' or ').css({
'display': 'inline-block',
'margin': '0 5px 0 50px'
}), $btnC);
}
}
$header.append($content);
$container.append($header);
appendDismiss($container);
}
} catch (e) {
console.error("Comrade quality check failed", e);
}
}
async function performStubRemoval() {
const api = new mw.Api();
try {
const pageData = await api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
});
let wikitext = pageData.query.pages[0].revisions[0].content;
const newWikitext = wikitext.replace(/\{\{[^}]*stub\}\}\n?/gi, '').trim();
if (wikitext === newWikitext) {
mw.notify("No stub tags found to remove.");
return;
}
await api.postWithToken('csrf', {
action: 'edit',
title: mw.config.get("wgPageName"),
text: newWikitext,
summary: `Removing lingering stub tags per ORES/Talk assessment`,
nocreate: true
});
location.reload();
} catch (err) {
console.error("Comrade failed to remove stub tags:", err);
mw.notify("Error removing stub tags.", {
type: 'error'
});
}
}
async function createModernRedirect(redirTitle, targetTitle, rCategory, customSummary, elementId, customWikitext) {
const api = new mw.Api();
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
try {
await api.postWithEditToken({
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
});
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
} catch (e) {
mw.notify("Failed to create redirect.", {
type: 'error'
});
}
}
function getOresPrediction() {
// Dependency: [[m:User:EpochFail/ArticleQuality-system.js]] renders the prediction inside .article_quality
const text = $('.article_quality').text();
if (!text) return null;
if (text.includes('Start')) return 'Start';
if (text.includes('C')) return 'C';
if (text.includes('B')) return 'B';
return null;
}
function getSmartSortKey(fullName, entity) {
const countryIds = entity.claims?.P27?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const locationIds = entity.claims?.P17?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const placeIds = [...(entity.claims?.P19 || []), ...(entity.claims?.P119 || [])].map(c => c.mainsnak?.datavalue?.value?.id).filter(Boolean);
const allRelevantIds = [...countryIds, ...locationIds, ...placeIds];
const birthYear = parseInt(entity.claims?.P569?.[0]?.mainsnak?.datavalue?.value?.time?.match(/[+-](\d{4})/)?.[1]) || null;
if (fullName.startsWith("Saint ")) return fullName.replace("Saint ", "");
const arabicParticles = /\b(Abu|Abd|Abdel|Abdul|ben|bin|bint)\b/i;
if (fullName.match(arabicParticles)) {
if (birthYear && birthYear > 1900) {
const parts = fullName.split(' ');
const binIndex = parts.findIndex(p => p.toLowerCase() === 'bin' || p.toLowerCase() === 'ben');
if (binIndex > 0) return parts.slice(binIndex).join(' ') + ', ' + parts.slice(0, binIndex).join(' ');
} else {
return fullName;
}
}
const eastAsianQids = ["Q148", "Q184", "Q884", "Q424", "Q881", "Q17"];
if (allRelevantIds.some(id => eastAsianQids.includes(id))) {
if (allRelevantIds.includes("Q17") && birthYear && birthYear > 1885) return westernSort(fullName);
const parts = fullName.split(' ');
if (parts.length > 1) return `${parts[0]}, ${parts.slice(1).join(' ')}`;
}
return westernSort(fullName);
}
function westernSort(name) {
let cleanName = name;
if (name.startsWith("O'")) cleanName = name.replace("O'", "O");
const parts = cleanName.split(' ');
if (parts.length < 2) return cleanName;
const surname = parts.pop();
if (["Jr.", "Jr", "III", "II", "Sr.", "Sr"].includes(surname)) {
const actualSurname = parts.pop();
return `${actualSurname}, ${parts.join(' ')} ${surname}`;
}
return `${surname}, ${parts.join(' ')}`;
}
async function performDeorphan() {
const api = new mw.Api();
try {
await api.edit(mw.config.get('wgPageName'), function(rev) {
let text = rev.content;
const summary = 'Article has backlinks; removed [[Template:Orphan|{{Orphan}}]]';
text = text.replace(/\{\{Orphan\s*(?:\|[^}]*)?\}\}\s*/gi, '');
text = text.replace(/(\{\{Multiple[ _]issues\s*)\|\s*\|/gi, '$1|');
text = replaceMI(text);
text = text.replace(/\n{3,}/g, '\n\n').trim();
return {
text,
summary,
minor: true
};
});
mw.notify('Orphan tag removed!', {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
setTimeout(() => location.reload(), 700);
} catch (e) {
mw.notify('Failed to remove orphan tag.', {
type: 'error'
});
}
}
async function performPromotion(newClass) {
const api = new mw.Api();
const pageName = mw.config.get('wgPageName');
const talkName = 'Talk:' + pageName;
try {
const talkData = await api.get({
action: 'query',
prop: 'revisions',
titles: talkName,
rvprop: 'content',
formatversion: 2
});
let talkWikitext = talkData.query.pages[0].revisions[0].content;
talkWikitext = talkWikitext.replace(/(\|class\s*=\s*)Stub/i, `$1${newClass}`);
await api.postWithEditToken({
action: 'edit',
title: talkName,
text: talkWikitext,
summary: `Promoting article to ${newClass} class per ORES prediction [[WP:ORES]]`
});
const artData = await api.get({
action: 'query',
prop: 'revisions',
titles: pageName,
rvprop: 'content',
formatversion: 2
});
let artWikitext = artData.query.pages[0].revisions[0].content;
const stubRegex = /\{\{[^}]*?-stub\}\}\s*\n?/gi;
const genericStubRegex = /\{\{stub\}\}\s*/gi;
artWikitext = artWikitext.replace(stubRegex, '').replace(genericStubRegex, '');
await api.postWithEditToken({
action: 'edit',
title: pageName,
text: artWikitext,
summary: `Removing stub tags; article promoted to ${newClass}`
});
location.reload();
} catch (e) {
alert("Promotion failed. Check console for details.");
console.error(e);
}
}
function replaceMI(text) {
const miRegex = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miRegex.exec(text)) !== null) {
const start = match.index;
result += text.slice(lastIndex, start);
let depth = 0;
let i = start;
while (i < text.length) {
if (text[i] === '{' && text[i + 1] === '{') {
depth++;
i += 2;
} else if (text[i] === '}' && text[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
const end = i;
const full = text.slice(start, end);
const pipeIdx = full.indexOf('|');
const inner = full.slice(pipeIdx + 1, full.length - 2).trim();
const topLevel = extractTopLevelTemplates(inner);
if (topLevel.length === 0) {
result += '';
} else if (topLevel.length === 1) {
result += topLevel[0].trim();
} else {
result += full;
}
lastIndex = end;
}
result += text.slice(lastIndex);
return result;
}
function extractTopLevelTemplates(inner) {
const templates = [];
let i = 0;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
let depth = 0;
const start = i;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
depth++;
i += 2;
} else if (inner[i] === '}' && inner[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
templates.push(inner.slice(start, i));
} else {
i++;
}
}
return templates;
}
async function performSortCorrection(newName, type) {
const api = new mw.Api();
const title = mw.config.get("wgPageName");
try {
const data = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2
});
let content = data.query.pages[0].revisions[0].content;
const dsRegex = /\{\{(?:DEFAULTSORT|Defaultsort):[^}]+\}\}/i;
const newTag = `{{DEFAULTSORT:${newName}}}`;
let actionDesc = type === "add" ? "added" : "corrected";
if (dsRegex.test(content)) {
content = content.replace(dsRegex, newTag);
} else {
const catRegex = /\[\[Category:/i;
if (catRegex.test(content)) {
content = content.replace(catRegex, `${newTag}\n[[Category:`);
} else {
content += `\n\n${newTag}`;
}
}
await api.postWithEditToken({
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName}`,
nocreate: true
});
mw.notify(`DEFAULTSORT ${actionDesc} to ${newName}!`, {
title: 'Comrade Success',
type: 'success'
});
setTimeout(() => location.reload(), 700);
} catch (err) {
mw.notify('Failed to save DEFAULTSORT.', {
type: 'error'
});
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
function renderSortNudge(target, reason) {
const $dsNudge = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Correct to: ${target}`).on('click', () => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
async function fetchWikidataNameLabels(entity) {
const familyIds = entity.claims?.P734?.map(c => c.mainsnak.datavalue.value.id) || [];
const givenIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
const allNameIds = [...new Set([...familyIds, ...givenIds])];
if (allNameIds.length === 0) return {
givens: [],
surnames: []
};
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: allNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
});
const nameData = await fetch(url).then(r => r.json());
return {
givens: givenIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean),
surnames: familyIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean)
};
}
function fixNameCasing(part, referenceName) {
return part.split(' ').map(word => {
const match = referenceName.match(new RegExp(`\\b${word}\\b`, 'i'));
if (match) return match[0];
return referenceName.split(' ').find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase())) || word;
}).join(' ');
}
function getLongNameFromWikitext(wikitext) {
const firstLine = wikitext.split('\n').find(l => l.includes("'''"));
if (!firstLine) return null;
const boldMatch = firstLine.match(/'''(.+?)'''/);
if (!boldMatch) return null;
return boldMatch[1].replace(/\s*([“"«《„‹「『'].+?[”"»》”›」』']|\([^)\n]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
}
async function init() {
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") return;
const api = new mw.Api();
const qid = mw.config.get("wgWikibaseItemId");
try {
const [pageData, backlinkData] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
api.get({
action: 'query',
list: 'backlinks',
blfilterredir: 'nonredirects',
bllimit: 50,
blnamespace: 0,
bltitle: mw.config.get("wgPageName"),
formatversion: 2
})
]);
const page = pageData.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const currentTitle = mw.config.get("wgTitle");
const cleanTitle = stripDiacritics(currentTitle);
const isOrphanTagged = /\{\{\s*(Orphan|Do-attempt|Lonely|Orp|Orphaned article)/i.test(wikitext) || /\| *orphan *=/i.test(wikitext);
const backLinkCount = backlinkData.query.backlinks.length;
if (isOrphanTagged && backLinkCount >= 1) {
const $container = $('<div>').addClass('comrade-container comrade-status');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text('Remove orphan tag').on('click', () => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) await checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
await checkDomainRedirect(qid, currentTitle);
let isHuman = false;
let entity = null;
if (qid) {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
});
const wikiData = await fetch(url).then(r => r.json());
entity = wikiData.entities[qid];
isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
}
await checkNamingPrecision(currentTitle, isHuman);
if (qid && entity) {
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const nameParts = tClean.split(' ');
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
const isOrphan = isOrphanTagged && backLinkCount < 1;
const primarySurname = hasFamilyNameHatnote ? nameParts[0] : (getSmartSortKey(tClean, entity).split(',')[0] || nameParts[nameParts.length - 1]);
let targetSortName = "";
if (isHuman) {
const {
givens,
surnames
} = await fetchWikidataNameLabels(entity);
const refName = entity.labels?.en?.value || currentTitle;
if (dsMatch) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixNameCasing(lastPart, refName)}, ${fixNameCasing(firstPart, refName)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field.");
} else if (givens.length > 0 && lastPart.split(' ').length > 1) {
const misplacedGiven = lastPart.split(' ').find(part => givens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastPart.split(' ').filter(p => p !== misplacedGiven).join(' ');
targetSortName = `${fixNameCasing(newSurname, refName)}, ${fixNameCasing(misplacedGiven + ' ' + firstPart, refName)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
}
}
} else {
targetSortName = hasFamilyNameHatnote ? `${nameParts[0]}, ${nameParts.slice(1).join(' ')}` : getSmartSortKey(tClean, entity);
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).on('click', () => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
const longName = getLongNameFromWikitext(wikitext);
if (longName && longName.length > tClean.length) {
let sortKey = hasFamilyNameHatnote ? `${longName.split(' ')[0]}, ${longName.split(' ').slice(1).join(' ')}` : getSmartSortKey(longName, entity);
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
await checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
if (targetSortName) {
let targetPage = currentTitle.includes(' (') ? currentTitle.split(' (')[0] : currentTitle;
let rCat = targetPage !== currentTitle ? "R from ambiguous sort name" : "R from sort name";
if (targetSortName.includes(',')) {
const p = targetSortName.split(',');
rCat += `|${p[0].trim().charAt(0).toUpperCase()}|${p[1].trim().charAt(0).toUpperCase()}`;
}
await checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name");
}
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
surnames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
givens.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) finalGivenNames.add(gn);
});
if (finalFamilyNames.size === 0 && nameParts.length >= 2) finalFamilyNames.add(nameParts[nameParts.length - 1]);
if (finalGivenNames.size === 0 && nameParts.length >= 2) finalGivenNames.add(nameParts[0]);
const getPriorityTarget = async (name, type) => {
const titles = type === 'surname' ? [`List of people with surname ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`] : [`List of people with given name ${name}`, `${name} (given name)`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
return titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
};
for (const name of finalGivenNames) {
if (name.toLowerCase() === primarySurname.toLowerCase()) continue;
const target = await getPriorityTarget(name, 'given');
if (target) await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
for (const name of finalFamilyNames) {
const titles = [name, `${name} (surname)`, `List of people with surname ${name}`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
const target = titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
if (target) await checkNameList(name, 'surname', currentTitle, hasShortDesc, isOrphan, target);
}
}
const oresWait = setInterval(() => {
if ($('.article_quality').length) {
clearInterval(oresWait);
checkQualityConsistency();
}
}, 500);
setTimeout(() => clearInterval(oresWait), 5000);
}
} catch (err) {
console.error("Comrade error:", err);
}
}
init();
})();
(function() {
if (window.IllWill) return;
const STRIKEOUT = true;
const HIDE = 'hide';
const IllWill = {
Version: "1.1.1",
Attribution: 'created by <a target="_blank" href="/wiki/User:Cobaltcigs">cobaltcigs</a>',
Summary: "Added interlanguage links using IllWill.js",
DataApi: "https://www.wikidata.org/w/api.php",
LocalLang: mw.config.get("wgPageContentLanguage"),
Disambiguator: / \(.*\)$/,
SiteLinks: {},
Config: {
MaxLanguages: 5,
IgnoreScientific: STRIKEOUT,
AutoDiff: true
},
css: `
#illwill { clear: both; margin-bottom: 1.5em; border: 1px solid #a2a9b1; padding: 15px; background: #f8f9fa; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.ill-sitelinks a { display: inline-block; margin-bottom: 3px; font-size: 0.9em; color: #36c; }
.ill-size-tag { font-size: 0.8em; color: #72777d; margin-left: 6px; white-space: nowrap; }
.illwill-ignore { text-decoration: line-through; color: #72777d; font-size: 0.85em; }
.ill-x { width: 35px; text-align: center; }
.ill-input, .ill-output { font-family: 'Courier New', monospace; white-space: pre-wrap; font-size: 0.9em; background: #fff; border: 1px solid #eaecf0; padding: 4px; border-radius: 2px; }
#ill-buttons { text-align: right; padding: 10px; border-top: 1px solid #a2a9b1; margin-top: 10px; }
#ill-buttons button { margin-left: 10px; cursor: pointer; padding: 6px 16px; border-radius: 2px; border: 1px solid #a2a9b1; transition: all 0.2s; }
#ill-ok { background: #36c; color: #fff; border-color: #36c !important; font-weight: bold; }
#ill-ok:hover { background: #447ff5; }
#ill-ok:disabled { background: #eaecf0; color: #72777d; border-color: #c8ccd1 !important; cursor: not-allowed; opacity: 0.7; }
#ill-table { width: 100%; border-collapse: collapse; margin-top: 10px; background: white; }
#ill-table th { background: #f2f2f2; text-align: left; padding: 8px; border: 1px solid #a2a9b1; }
#ill-table td { padding: 8px; border: 1px solid #a2a9b1; vertical-align: top; }
#ill-table caption { background: #eaffea; font-weight: bold; padding: 10px; border: 1px solid #a2a9b1; border-bottom: none; font-size: 1.1em; }
#ill-help { font-size: 0.85em; color: #54595d; padding: 10px; line-height: 1.4; }
.ill-loading-inline { font-style: italic; color: #72777d; font-size: 0.9em; display: block; margin: 5px 0; }
`,
setup() {
if (!["edit", "submit"].includes(mw.config.get('wgAction'))) return;
mw.loader.addStyleTag(this.css);
const link = mw.util.addPortletLink('p-cactions', '#', 'IllWill', 'ca-illwill', "Add interlanguage links via Wikidata");
if (link) {
link.addEventListener('click', (e) => {
e.preventDefault();
this.makePanel();
});
}
},
norm(s) {
if (!s) return "";
const t = s.trim().replace(/[\s_]+/g, " ");
return t.charAt(0).toUpperCase() + t.slice(1);
},
async getRedLinks($textbox) {
const txt = $textbox.textSelection('getContents') || "";
const wl = txt.match(/\[\[[^[\]\n]+\]\]/g);
if (!wl) return {
reds: [],
txt
};
const api = new mw.Api();
const textToParse = wl.join("").replace(/\|[^\]]+/g, "");
try {
const data = await api.post({
action: 'parse',
text: textToParse,
contentmodel: 'wikitext',
formatversion: 2
});
const tempDiv = document.createElement('div');
tempDiv.innerHTML = data.parse.text;
const reds = Array.from(tempDiv.querySelectorAll("a.new")).map(x => {
const urlParams = new URLSearchParams(x.href.split('?')[1]);
return urlParams.get('title')?.replace(/_/g, " ");
}).filter(Boolean);
return {
reds: [...new Set(reds)],
txt
};
} catch (e) {
return {
reds: [],
txt
};
}
},
async makePanel() {
const $editform = $('#editform');
const $textbox = $('#wpTextbox1');
$('#illwill').remove();
$editform.hide();
const $wrapper = $('<div id="illwill">Loading redlinks...</div>').insertBefore($editform);
const {
reds,
txt
} = await this.getRedLinks($textbox);
const wtLinks = [];
reds.forEach(title => {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s/g, "[\\s_]+");
const re = new RegExp("\\[\\[\\s*" + escaped + "\\s*(?:\\|[^\\[\\]]+)?\\]\\]", "ig");
const match = txt.match(re);
if (match && !wtLinks.some(z => z.title === title)) {
wtLinks.push({
link: match[0],
title
});
}
});
if (!wtLinks.length) {
const $back = $('<button>Quit</button>').on('click', () => {
$wrapper.remove();
$editform.show();
});
$wrapper.empty().append($('<strong>No valid redlinks found in text.</strong> '), $back);
return;
}
const $table = $(`
<table id="ill-table" class="wikitable">
<caption>IllWill.js (v${this.Version})</caption>
<thead>
<tr>
<th class="ill-x"><input type="checkbox" id="ill-select-all" disabled></th>
<th>Input link</th>
<th>Possible Wikidata matches</th>
<th>Sitelinks (top ${this.Config.MaxLanguages} by size)</th>
<th>Resulting wikitext</th>
</tr>
</thead>
<tbody></tbody>
<tfoot>
<tr>
<td colspan="3" id="ill-help">Carefully examine possible Wikidata matches.</td>
<td colspan="2" id="ill-buttons">
<button id="ill-cancel">Cancel</button>
<button id="ill-ok" disabled>Show changes</button>
</td>
</tr>
</tfoot>
</table>
`);
const $tbody = $table.find('tbody');
wtLinks.forEach((item, i) => {
$tbody.append(`
<tr id="ill-row-${i}" data-index="${i}">
<td class="ill-x"><input type="checkbox" class="ill-row-check" disabled></td>
<td class="ill-input">${this.escapeHTML(item.link)}</td>
<td class="ill-wd-item"><span class="ill-loading-inline">Searching...</span></td>
<td class="ill-sitelinks"></td>
<td class="ill-output"></td>
</tr>
`);
});
$wrapper.empty().append($table);
const chunkSize = 5;
for (let i = 0; i < wtLinks.length; i += chunkSize) {
const chunk = wtLinks.slice(i, i + chunkSize);
await Promise.all(chunk.map((item, idx) => this.fetchWikidataOptimized(item.title, i + idx)));
}
$table.on('click', '#ill-cancel', () => {
$editform.show();
$wrapper.remove();
});
$table.on('click', '#ill-ok', () => this.onOkButton($editform, $wrapper, $textbox));
$table.on('change', '#ill-select-all', (e) => {
const isChecked = $(e.target).is(':checked');
$table.find('.ill-row-check:not(:disabled)').each(function() {
$(this).prop('checked', isChecked).trigger('change');
});
});
$table.on('change', '.ill-row-check', (e) => {
this.onCheckbox(e);
this.updateOkButtonState($table);
});
$table.on('change', 'input[type="radio"]', (e) => {
this.showSiteLinks(e);
});
},
async fetchWikidataOptimized(title, index) {
const query = title.replace(this.Disambiguator, "");
const url = `${this.DataApi}?action=wbsearchentities&search=${encodeURIComponent(query)}&language=${this.LocalLang}&limit=5&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
const results = data.search;
const $cell = $(`#ill-row-${index} .ill-wd-item`);
if (!results || !results.length) {
$cell.html('<span class="ill-na">(no results)</span>');
return;
}
const entityIds = results.map(r => r.id);
await this.fetchEntities(entityIds, index, $cell);
} catch (e) {
$(`#ill-row-${index} .ill-wd-item`).text("Search failed.");
}
},
async fetchEntities(ids, rowIndex, $cell) {
const url = `${this.DataApi}?action=wbgetentities&ids=${ids.join('|')}&props=labels|descriptions|claims|sitelinks&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
$cell.empty();
ids.forEach(qNum => {
const entity = data.entities[qNum];
if (!entity) return;
const isScientific = entity.claims?.P31?.some(s => s.mainsnak?.datavalue?.value?.id === "Q13442814");
if (isScientific && this.Config.IgnoreScientific === HIDE) return;
const desc = entity.descriptions?.[this.LocalLang]?.value || entity.descriptions?.en?.value || "";
const label = entity.labels?.[this.LocalLang]?.value || entity.labels?.en?.value || qNum;
const isStrikeout = isScientific && this.Config.IgnoreScientific === STRIKEOUT;
const $div = $(`
<div class="${isStrikeout ? 'illwill-ignore' : ''}">
<input type="radio" name="selection-${rowIndex}" value="${qNum}" id="radio-${qNum}" ${isStrikeout ? 'disabled' : ''}>
<label for="radio-${qNum}">
<a href="https://wikidata.org/wiki/${qNum}" target="_blank">${this.escapeHTML(label)}</a>:
<span class="ill-desc">${desc ? this.escapeHTML(desc) : '<i>no description</i>'}</span>
</label>
</div>
`);
$cell.append($div);
this.SiteLinks[qNum] = entity.sitelinks || {};
});
} catch (e) {
$cell.text("Detail fetch failed.");
}
},
updateOkButtonState($table) {
const $rows = $table.find('.ill-row-check:not(:disabled)');
const $checkedRows = $rows.filter(':checked');
const anyEnabled = $rows.length > 0;
$table.find('#ill-ok').prop('disabled', $checkedRows.length === 0);
const $master = $table.find('#ill-select-all');
$master.prop('disabled', !anyEnabled);
if (anyEnabled && $rows.length === $checkedRows.length) {
$master.prop('checked', true);
} else {
$master.prop('checked', false);
}
},
async showSiteLinks(e) {
const isManualToggling = e.fromCheckbox || false;
const qNum = e.target.value;
const $row = $(e.target).closest('tr');
const sitelinks = this.SiteLinks[qNum];
const $checkbox = $row.find('.ill-row-check');
const $sitelinkCell = $row.find('.ill-sitelinks');
const $outputCell = $row.find('.ill-output');
const $table = $('#ill-table');
const effectiveMax = Math.min(Math.max(this.Config.MaxLanguages, 3), 6);
const validKeys = Object.keys(sitelinks).filter(k => k.endsWith('wiki') && !k.includes('commons'));
$sitelinkCell.html('<span class="ill-loading-inline">Measuring articles...</span>');
$outputCell.empty();
if (!isManualToggling) {
$checkbox.prop('disabled', true).prop('checked', false);
}
const candidates = validKeys.slice(0, 25);
const sizeMap = [];
await Promise.all(candidates.map(async (key) => {
const lang = key.replace('wiki', '').replace(/_/g, '-');
const title = sitelinks[key].title;
const endpoint = `https://${lang}.wikipedia.org/w/api.php?action=query&titles=${encodeURIComponent(title)}&prop=info&format=json&origin=*`;
try {
const res = await fetch(endpoint).then(r => r.json());
const page = Object.values(res.query.pages)[0];
sizeMap.push({
lang,
title,
length: page.length || 0
});
} catch (err) {
sizeMap.push({
lang,
title,
length: 0
});
}
}));
sizeMap.sort((a, b) => b.length - a.length);
const topLinks = sizeMap.slice(0, effectiveMax);
$sitelinkCell.html(topLinks.map(item => {
const kbValue = Math.round(item.length / 1024);
return `<div><a href="https://${item.lang}.wikipedia.org/wiki/${encodeURIComponent(item.title)}" target="_blank">${item.lang}:${this.escapeHTML(item.title)}</a> <span class="ill-size-tag">(${kbValue} kB)</span></div>`;
}).join(""));
const inputWikitext = $row.find('.ill-input').text();
const match = inputWikitext.match(/\[\[(.*?)\]\]/);
if (match) {
const parts = match[1].split('|');
const targetPage = parts[0].trim();
const surfaceName = (parts[1] || parts[0]).trim();
const illLangs = topLinks.map(item => {
const isSameTitle = this.norm(item.title) === this.norm(targetPage);
return `|${item.lang}|${isSameTitle ? '' : item.title}`;
}).join("");
const ltParam = (this.norm(surfaceName) === this.norm(targetPage)) ? "" : `|lt=${surfaceName}`;
$outputCell.text(`{{ill|${targetPage}${ltParam}${illLangs}}}`);
}
$checkbox.prop('disabled', !topLinks.length);
if (!isManualToggling) {
$checkbox.prop('checked', !!topLinks.length);
}
this.updateOkButtonState($table);
},
onCheckbox(e) {
const $row = $(e.target).closest('tr');
const $radio = $row.find('input[type="radio"]:checked');
const isChecked = $(e.target).is(':checked');
if (isChecked) {
if ($radio.length) {
this.showSiteLinks({
target: $radio[0],
fromCheckbox: true
});
}
} else {
$row.find('.ill-sitelinks, .ill-output').empty();
}
},
onOkButton($editform, $wrapper, $textbox) {
let text = $textbox.textSelection('getContents');
let changed = false;
$wrapper.find('.ill-row-check:checked').each(function() {
const $row = $(this).closest('tr');
const input = $row.find('.ill-input').text();
const output = $row.find('.ill-output').text();
if (output) {
const escapedInput = input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
text = text.replace(new RegExp(escapedInput, "gi"), output);
changed = true;
}
});
if (changed) {
$('#wpSummary').val((i, v) => (v ? v + "; " : "") + this.Summary);
$textbox.textSelection('setContents', text);
$wrapper.remove();
$editform.show();
if (this.Config.AutoDiff) $('#wpDiff').click();
}
},
escapeHTML(str) {
return str.replace(/[&<>"']/g, m => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
})[m]);
}
};
window.IllWill = IllWill;
mw.loader.using(['mediawiki.util', 'mediawiki.api', 'jquery.textSelection'], () => {
$(() => IllWill.setup());
});
})();
(function() {
'use strict';
const config = {
enableNS14: true,
rootWhitelists: ['Materials'],
userWhitelist: ""
};
const categoryDataCache = {};
let scanProgress = 0;
let totalToScan = 0;
async function verifyTreeMembership() {
if (!config.enableNS14) return false;
const api = new mw.Api();
const currentTitle = mw.config.get("wgPageName").replace(/_/g, ' ');
const fullRoots = config.rootWhitelists.concat(config.userWhitelist.split(',').map(s => s.trim()).filter(s => s)).map(cat => (cat.startsWith('Category:') ? cat : `Category:${cat}`));
if (fullRoots.includes(currentTitle)) return true;
let categoriesToTable = mw.config.get('wgCategories') || [];
let checked = new Set();
while (categoriesToTable.length > 0) {
if (categoriesToTable.some(cat => fullRoots.includes('Category:' + cat))) {
return true;
}
categoriesToTable.forEach(cat => checked.add(cat));
try {
const res = await api.get({
formatversion: 2,
prop: 'categories',
titles: categoriesToTable.map(c => 'Category:' + c),
cllimit: 'max'
});
let nextBatch = [];
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.categories) {
page.categories.forEach(parent => {
const parentName = parent.title.substring('Category:'.length);
if (!checked.has(parentName)) {
nextBatch.push(parentName);
}
});
}
});
}
categoriesToTable = [...new Set(nextBatch)];
} catch (e) {
return false;
}
}
return false;
}
function getChemboxBlock(wikitext) {
const chemboxRegex = /\{\{\s*Chembox[\s_]*(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const match = wikitext.match(chemboxRegex);
return match ? match[0] : null;
}
function getFormulaFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const formulaMatch = chembox.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
let rawFormula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
rawFormula = rawFormula.split(/\s+/)[0];
return rawFormula.split(',')[0].trim();
}
const propRegex = /\{\{\s*Chembox[\s_]+Properties(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const propSection = chembox.match(propRegex);
if (propSection) {
const content = propSection[0];
const allElements = ['Ac', 'Ag', 'Al', 'Am', 'Ar', 'As', 'At', 'Au', 'B', 'Ba', 'Be', 'Bh', 'Bi', 'Bk', 'Br', 'C', 'Ca', 'Cd', 'Ce', 'Cf', 'Cl', 'Cm', 'Cn', 'Co', 'Cr', 'Cs', 'Cu', 'D', 'Db', 'Ds', 'Dy', 'Er', 'Es', 'Eu', 'F', 'Fe', 'Fl', 'Fm', 'Fr', 'Ga', 'Gd', 'Ge', 'H', 'He', 'Hf', 'Hg', 'Ho', 'Hs', 'I', 'In', 'Ir', 'K', 'Kr', 'La', 'Li', 'Lr', 'Lu', 'Lv', 'Mc', 'Md', 'Mg', 'Mn', 'Mo', 'Mt', 'N', 'Na', 'Nb', 'Nd', 'Ne', 'Nh', 'Ni', 'No', 'Np', 'O', 'Og', 'Os', 'P', 'Pa', 'Pb', 'Pd', 'Pm', 'Po', 'Pr', 'Pt', 'Pu', 'Ra', 'Rb', 'Re', 'Rf', 'Rg', 'Rh', 'Rn', 'Ru', 'S', 'Sb', 'Sc', 'Se', 'Sg', 'Si', 'Sm', 'Sn', 'Sr', 'Ta', 'Tb', 'Tc', 'Te', 'Th', 'Ti', 'Tl', 'Tm', 'Ts', 'U', 'V', 'W', 'Xe', 'Y', 'Yb', 'Zn', 'Zr'];
let data = {};
allElements.forEach(el => {
const elRegex = new RegExp(`\\|\\s*${el}\\s*=\\s*(\\d*\\.?\\d+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch && elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
});
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
elementsPresent.filter(el => !(hasC && (el === "C" || el === "H"))).sort().forEach(el => {
formulaArray.push(el + data[el]);
});
return formulaArray.join('');
}
}
return null;
}
function getCASFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const idRegex = /\{\{\s*Chembox[\s_]+Identifiers(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const idSection = chembox.match(idRegex);
if (!idSection) return null;
const content = idSection[0];
const nbsp = String.fromCharCode(160);
const casMatch = content.match(/\|\s*CASNo\d*\b(?!\s*_)\s*=\s*([^|}\n]+)/i);
if (casMatch) {
let val = casMatch[1].replace(/\{\{[^}]+\}\}/g, "").replace(/\u00AD/g, "").split(" ").join("");
while (val.indexOf(nbsp) !== -1) val = val.replace(nbsp, "");
while (val.indexOf(" ") !== -1) val = val.replace(" ", "");
const cleanedCas = val.trim();
return /^\d{2,7}-\d{2}-\d$/.test(cleanedCas) ? cleanedCas : null;
}
return null;
}
async function getWikidataValue() {
const qid = mw.config.get('wgWikidataItemId');
if (!qid) return null;
const api = new mw.ForeignApi('https://www.wikidata.org/w/api.php');
const res = await api.get({
action: 'wbgetentities',
ids: qid,
props: 'claims',
format: 'json',
formatversion: 2
});
const claims = res.entities[qid].claims;
const data = {};
if (claims.P274) data.formula = claims.P274[0].mainsnak.datavalue.value;
if (claims.P231) data.cas = claims.P231[0].mainsnak.datavalue.value;
return data;
}
async function startBackgroundCategoryScan() {
const api = new mw.Api();
const titles = $('#mw-pages li a').map((i, el) => $(el).attr('title') || $(el).text()).get();
totalToScan = titles.length;
if (totalToScan === 0) return;
const chunks = arrayChunk(titles, 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: chunk,
rvprop: 'content',
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const f = getFormulaFromWikitext(wikitext);
const c = getCASFromWikitext(wikitext);
if (f || c) categoryDataCache[page.title] = {
formula: f,
cas: c
};
});
}
scanProgress += chunk.length;
$('#caschemassist-cat-btn').text(`Scanning... ${scanProgress}/${totalToScan}`);
}
$('#caschemassist-cat-btn').text('Show missing CAS/Chem').prop('disabled', false);
}
async function renderCategoryAudit() {
const api = new mw.Api();
const missingCheckTitles = [];
Object.values(categoryDataCache).forEach(data => {
if (data.formula) missingCheckTitles.push(data.formula);
if (data.cas) missingCheckTitles.push(data.cas);
});
if (missingCheckTitles.length === 0) {
mw.notify("No valid Chembox data found in this category.");
return;
}
const existenceMap = {};
const chunks = arrayChunk([...new Set(missingCheckTitles)], 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
titles: chunk,
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(p => {
existenceMap[p.title] = !p.missing;
});
}
}
$('#mw-pages li').each(function() {
const title = $(this).find('a').attr('title') || $(this).find('a').text().trim();
const data = categoryDataCache[title];
if (!data) return;
if (data.formula && !existenceMap[data.formula]) {
$(this).append($('<span>').css({
'color': 'red',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [${data.formula}]`));
}
if (data.cas && !existenceMap[data.cas]) {
$(this).append($('<span>').css({
'color': 'darkred',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [CAS: ${data.cas}]`));
}
});
$('#caschemassist-cat-btn').text('Audit complete').prop('disabled', true);
}
async function handleSingleArticle() {
const api = new mw.Api();
const currentTitle = mw.config.get("wgTitle");
const [wikiRes, wikidata] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
getWikidataValue()
]);
const page = wikiRes.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const formula = getFormulaFromWikitext(wikitext);
const cas = getCASFromWikitext(wikitext);
if (wikidata) {
let mismatches = [];
if (formula && wikidata.formula && formula !== wikidata.formula) {
mismatches.push(`Formula mismatch: Chembox "${formula}" vs Wikidata "${wikidata.formula}"`);
}
if (cas && wikidata.cas && cas !== wikidata.cas) {
mismatches.push(`CAS mismatch: Chembox "${cas}" vs Wikidata "${wikidata.cas}"`);
}
if (mismatches.length > 0) {
const $warning = $('<div>').css({
'background': '#fff2f2',
'border': '2px solid #d33',
'padding': '10px',
'margin-bottom': '1em',
'color': '#b32424'
}).append($('<strong>').text('⚠️ Data Mismatch Detected: '), mismatches.join(' | '));
$('#mw-content-text').prepend($warning);
}
}
async function checkAndRender(target, label, customWikitext) {
const check = await api.get({
action: 'query',
titles: target,
formatversion: 2
});
if (check.query.pages[0].missing) {
const $container = $('<div>').css({
'background': '#f8f9fa',
'border': '1px solid #a2a9b1',
'padding': '10px',
'margin-bottom': '1em'
});
const $btn = $('<button>').text(`Create redirect: ${target}`).click(async function() {
await api.postWithEditToken({
action: 'edit',
title: target,
text: customWikitext,
summary: `Creating redirect to [[${currentTitle}]] (${label})`,
createonly: true
});
mw.notify(`Created redirect: ${target}`);
$container.fadeOut(400, function() {
$(this).remove();
});
});
$container.append($('<strong>').text(`${label}: `), `Missing redirect. `, $btn);
$('#mw-content-text').prepend($container);
}
}
if (formula && formula !== currentTitle) {
const formulaWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from chemical formula}}\n}}`;
await checkAndRender(formula, "Chemical formula", formulaWikitext);
}
if (cas && cas !== currentTitle) {
const casWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from CAS Registry Number}}\n{{R unprintworthy}}\n}}`;
await checkAndRender(cas, "CAS number", casWikitext);
}
}
function arrayChunk(arr, size) {
const result = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
$(document).ready(async function() {
const ns = mw.config.get("wgNamespaceNumber");
if (ns === 14) {
if (await verifyTreeMembership()) {
const $catContainer = $('#mw-pages');
if ($catContainer.length) {
const $btn = $('<button>').text('Initializing scan...').attr('id', 'caschemassist-cat-btn').addClass('mw-ui-button').css({
'margin': '0 10px 10px 0',
'float': 'right',
'position': 'relative',
'z-index': '100'
}).prop('disabled', true).click(renderCategoryAudit);
$catContainer.prepend($btn);
startBackgroundCategoryScan();
}
}
} else if (ns === 0 && mw.config.get("wgAction") === "view") {
handleSingleArticle();
}
});
})();
//</nowiki>
8cnlotsf9rmipfeqva7jbhica99ydy0
739305
739304
2026-04-25T08:22:13Z
Sam Sailor
26820
Test
739305
javascript
text/javascript
//<nowiki>
(function() {
mw.util.addCSS(`
.ambox-Orphan { display: block !important; }
.comrade-container {
box-shadow: 0 1px 3px rgba(0,0,0,0.1); border-radius: 2px;
padding: 10px 15px; margin: 10px 0; display: flex;
flex-direction: column; align-items: flex-start; gap: 8px;
border: 1px solid #a2a9b1; background: #fcfcfc; line-height: 1.5;
}
.comrade-header { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.comrade-content { display: flex; flex-direction: column; gap: 5px; width: 100%; }
.comrade-success { background-color: #d5f5e3 !important; border-left: 5px solid #27ae60 !important; color: #1b5e20 !important; }
.comrade-nudge { background: #fff9ea; border-left: 5px solid #f0ad4e; }
.comrade-info { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-status { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-container button:not(.comrade-dismiss) {
background: #f8f9fa; border: 1px solid #a2a9b1; border-radius: 2px;
padding: 2px 10px; cursor: pointer; font-weight: bold; font-size: 0.95em; margin-left: 10px;
}
.comrade-container button:not(.comrade-dismiss):hover { border-color: #36c; color: #36c; background: #fff; }
.comrade-dismiss {
background: transparent; border: none; color: #d33; font-weight: bold;
cursor: pointer; font-size: 0.9em;
}
.comrade-dismiss:hover { text-decoration: underline; }
.comrade-code-block { background: #f1f1f1; padding: 4px 8px; border-radius: 3px; font-family: monospace; display: inline-block; margin-top: 4px; }
`);
function appendDismiss($el) {
$('<button>').addClass('comrade-dismiss').text('Dismiss').on('click', function() {
$(this).closest('.comrade-container').fadeOut();
}).appendTo($el.find('.comrade-header'));
$("#siteSub").after($el);
}
async function checkNamingPrecision(currentTitle, isHuman) {
const disambigMatch = currentTitle.match(/^(.+?)(?:, | \()/);
if (isHuman || !disambigMatch) return;
const baseTitle = disambigMatch[1].trim();
const api = new mw.Api();
try {
const baseRes = await api.get({
action: 'query',
titles: baseTitle,
redirects: 1,
formatversion: 2
});
const page = baseRes.query.pages[0];
const baseExists = !page.missing;
let baseRedirectsToSelf = false;
if (baseRes.query.redirects) {
baseRedirectsToSelf = baseRes.query.redirects.some(r => r.to === currentTitle);
}
const searchRes = await api.get({
action: 'query',
list: 'allpages',
apprefix: baseTitle,
aplimit: 50,
formatversion: 2
});
const competitors = searchRes.query.allpages.filter(p => {
const t = p.title;
return t !== currentTitle && t !== baseTitle && (t.startsWith(baseTitle + ', ') || t.startsWith(baseTitle + ' ('));
});
if (!baseExists || baseRedirectsToSelf) {
const $container = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (competitors.length === 0) {
$header.append($('<strong>').text('Precision:'));
const reason = baseRedirectsToSelf ? "already redirects here" : "is free";
$content.append($('<span>').append(`The base name `, $('<code>').text(baseTitle), ` ${reason}. Consider moving this article.`));
const moveUrl = mw.util.getUrl('Special:MovePage/' + currentTitle, {
wpNewTitle: baseTitle,
wpReason: 'Unnecessary disambiguation; base name is free/redirects here per [[WP:PRECISION]]'
});
$content.append($('<button>').text(`Move to ${baseTitle}`).on('click', () => window.open(moveUrl, '_blank')));
} else {
$header.append($('<strong>').text('Observation:'));
const state = baseRedirectsToSelf ? 'redirect to this page' : 'redlink';
$content.append($('<span>').append(`Base name `, $('<code>').text(baseTitle), ` has ${competitors.length} competitors, but it is currently a ${state}.`));
$content.append($('<span>').text('Consider if a disambiguation page is needed.'));
}
$container.append($header, $content);
appendDismiss($container);
}
} catch (e) {
console.warn("Comrade: Precision check failed", e);
}
}
async function checkAndRenderRedirect(redirTitle, targetTitle, rCategory, labelText, customWikitext) {
const api = new mw.Api();
const res = await api.get({
action: 'query',
titles: redirTitle,
formatversion: 2
});
if (res.query.pages[0].missing) {
const safeId = "comrade-redir-" + redirTitle.replace(/[^a-z0-9]/gi, '-');
const $container = $('<div>').addClass('comrade-container comrade-info').attr('id', safeId);
const $header = $('<div>').addClass('comrade-header');
const summary = `Created redirect from ${labelText.toLowerCase()} to [[${targetTitle}]]`;
const $btn = $('<button>').text('Create redirect').on('click', () => createModernRedirect(redirTitle, targetTitle, rCategory, summary, safeId, customWikitext));
$header.append($('<div>').append($('<strong>').text(`${labelText}: `), `Create redirect from `, $('<code>').text(redirTitle), $btn));
$container.append($header);
appendDismiss($container);
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
if (qid) {
try {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
});
const res = await fetch(url).then(r => r.json());
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
}
} catch (e) {
console.warn("Comrade: Wikidata API call failed.");
}
}
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
if (domain.includes(".") && url.pathname === "/") {
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
try {
const response = await fetch(citationURL);
if (response.ok) {
await checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
}
} catch (e) {
console.warn("Comrade: Citation API check failed.");
}
}
} catch (e) {
console.warn("Comrade: Error parsing URL object.");
}
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan, resolvedTitle) {
const api = new mw.Api();
const candidates = resolvedTitle ? [resolvedTitle] : [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
redirects: 0
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const foundTitle = page.title;
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) break;
const $container = $('<div>').addClass('comrade-container');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (isOrphan) {
$container.addClass('comrade-nudge');
$header.append($('<strong>').text('Comrade nudge:'));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
}
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(foundTitle),
target: '_blank'
}).text(foundTitle), "; should it be?"));
const snippet = '* {{anbl|' + currentTitle + '}}';
const $snippetWrapper = $('<div>').css({
'display': 'flex',
'align-items': 'center',
'gap': '10px',
'margin-top': '4px'
});
const $instructionSpan = $('<span>').append('If the Short description is good, consider adding ', $('<code>').text(snippet).css({
'background-color': '#f8f9fa',
'border': '1px solid #eaecf0',
'border-radius': '2px',
'padding': '1px 4px'
}));
const $copyBtn = $('<button>').text('Copy & insert').css('margin-left', '0').on('click', function() {
navigator.clipboard.writeText(snippet).then(() => {
const $this = $(this);
const originalText = $this.text();
$this.text('Copied!').prop('disabled', true);
const editUrl = mw.util.getUrl(foundTitle, {
action: 'edit',
summary: snippet
});
window.open(editUrl, '_blank');
setTimeout(() => {
$this.text(originalText).prop('disabled', false);
}, 1500);
});
});
$snippetWrapper.append($instructionSpan, $copyBtn);
$content.append($snippetWrapper);
if (!hasShortDesc) {
$content.append($('<span>').css({
'color': '#d33',
'font-weight': 'bold',
'margin-top': '5px'
}).text('⚠️ Missing short description: Please add a concise one before listing.'));
}
$container.append($header, $content);
appendDismiss($container);
break;
}
}
}
}
async function checkQualityConsistency() {
const oresClass = getOresPrediction();
if (!oresClass || oresClass === 'Stub') return;
const talkTitle = 'Talk:' + mw.config.get('wgPageName');
const api = new mw.Api();
try {
const [talkRes, pageRes] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: talkTitle,
rvprop: 'content',
formatversion: 2
}),
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
})
]);
const talkPage = talkRes.query.pages[0];
const wikitext = pageRes.query.pages[0].revisions[0].content;
const hasStubTag = /\{\{[^}]*stub\}\}/i.test(wikitext);
const $raterLink = $('#ca-rater, #t-rater');
const isRaterAvailable = (typeof window.rater !== 'undefined' || $raterLink.length > 0);
let talkClass = null;
if (!talkPage.missing) {
const classMatch = talkPage.revisions[0].content.match(/\|class\s*=\s*([^|}\s]+)/i);
talkClass = classMatch ? classMatch[1].charAt(0).toUpperCase() + classMatch[1].slice(1).toLowerCase() : null;
}
const isStubRated = talkClass === 'Stub';
const lingeringStubTag = (talkClass && talkClass !== 'Stub' && oresClass !== 'Stub' && hasStubTag);
if (talkPage.missing || isStubRated || lingeringStubTag) {
const $container = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').append($('<strong>').text('Quality:'), " ");
let message = `ORES suggests <b>${oresClass}</b>. `;
if (talkPage.missing) {
message = `Talk page missing; ORES suggests <b>${oresClass}</b>. `;
let $btn;
if (isRaterAvailable) {
$btn = $('<button>').text('Open Rater').on('click', () => $raterLink.find('a')[0].click());
} else {
$btn = $('<button>').text('Install Rater').on('click', () => window.open('https://en.wikipedia.org/wiki/User:Evad37/rater', '_blank'));
message += "Consider installing <i>Rater</i> for easy assessment.";
}
$content.append(message, $btn);
} else if (lingeringStubTag && !isStubRated) {
message = `Article is ${talkClass}-class, but stub tags remain. `;
const $btn = $('<button>').text('Remove stub tags').on('click', () => performStubRemoval());
$content.append(message, $btn);
} else if (isStubRated) {
message = `Talk page says Stub, but ORES predicts <b>${oresClass}</b>. `;
const $btnOres = $('<button>').text(`Promote to ${oresClass}`).on('click', () => performPromotion(oresClass));
$content.append(message, $btnOres);
if (oresClass === 'C') {
const $btnStart = $('<button>').text(`Promote to Start`).on('click', () => performPromotion('Start'));
$content.append(" or ", $btnStart);
} else if (oresClass === 'B') {
const $btnC = $('<button>').text(`Promote to C`).on('click', () => performPromotion('C'));
$content.append($('<span>').text(' or ').css({
'display': 'inline-block',
'margin-left': '50px',
'margin-right': '5px'
}), $btnC);
}
}
$header.append($content);
$container.append($header);
appendDismiss($container);
}
} catch (e) {
console.error("Comrade quality check failed", e);
}
}
async function performStubRemoval() {
const api = new mw.Api();
try {
const pageData = await api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
});
let wikitext = pageData.query.pages[0].revisions[0].content;
const newWikitext = wikitext.replace(/\{\{[^}]*stub\}\}\n?/gi, '').trim();
if (wikitext === newWikitext) {
mw.notify("No stub tags found to remove.");
return;
}
await api.postWithToken('csrf', {
action: 'edit',
title: mw.config.get("wgPageName"),
text: newWikitext,
summary: `Removing lingering stub tags per ORES/Talk assessment`,
nocreate: true
});
location.reload();
} catch (err) {
console.error("Comrade failed to remove stub tags:", err);
mw.notify("Error removing stub tags.", {
type: 'error'
});
}
}
async function createModernRedirect(redirTitle, targetTitle, rCategory, customSummary, elementId, customWikitext) {
const api = new mw.Api();
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
try {
await api.postWithEditToken({
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
});
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
} catch (e) {
mw.notify("Failed to create redirect.", {
type: 'error'
});
}
}
function getOresPrediction() {
// Dependency: [[m:User:EpochFail/ArticleQuality-system.js]] renders the prediction inside .article_quality
const text = $('.article_quality').text();
if (!text) return null;
if (text.includes('Start')) return 'Start';
if (text.includes('C')) return 'C';
if (text.includes('B')) return 'B';
return null;
}
function getSmartSortKey(fullName, entity) {
const countryIds = entity.claims?.P27?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const locationIds = entity.claims?.P17?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const placeIds = [...(entity.claims?.P19 || []), ...(entity.claims?.P119 || [])].map(c => c.mainsnak?.datavalue?.value?.id).filter(Boolean);
const allRelevantIds = [...countryIds, ...locationIds, ...placeIds];
const birthYear = parseInt(entity.claims?.P569?.[0]?.mainsnak?.datavalue?.value?.time?.match(/[+-](\d{4})/)?.[1]) || null;
if (fullName.startsWith("Saint ")) return fullName.replace("Saint ", "");
const arabicParticles = /\b(Abu|Abd|Abdel|Abdul|ben|bin|bint)\b/i;
if (fullName.match(arabicParticles)) {
if (birthYear && birthYear > 1900) {
const parts = fullName.split(' ');
const binIndex = parts.findIndex(p => p.toLowerCase() === 'bin' || p.toLowerCase() === 'ben');
if (binIndex > 0) return parts.slice(binIndex).join(' ') + ', ' + parts.slice(0, binIndex).join(' ');
} else {
return fullName;
}
}
const eastAsianQids = ["Q148", "Q184", "Q884", "Q424", "Q881", "Q17"];
if (allRelevantIds.some(id => eastAsianQids.includes(id))) {
if (allRelevantIds.includes("Q17") && birthYear && birthYear > 1885) return westernSort(fullName);
const parts = fullName.split(' ');
if (parts.length > 1) return `${parts[0]}, ${parts.slice(1).join(' ')}`;
}
return westernSort(fullName);
}
function westernSort(name) {
let cleanName = name;
if (name.startsWith("O'")) cleanName = name.replace("O'", "O");
const parts = cleanName.split(' ');
if (parts.length < 2) return cleanName;
const surname = parts.pop();
if (["Jr.", "Jr", "III", "II", "Sr.", "Sr"].includes(surname)) {
const actualSurname = parts.pop();
return `${actualSurname}, ${parts.join(' ')} ${surname}`;
}
return `${surname}, ${parts.join(' ')}`;
}
async function performDeorphan() {
const api = new mw.Api();
try {
await api.edit(mw.config.get('wgPageName'), function(rev) {
let text = rev.content;
const summary = 'Article has backlinks; removed [[Template:Orphan|{{Orphan}}]]';
text = text.replace(/\{\{Orphan\s*(?:\|[^}]*)?\}\}\s*/gi, '');
text = text.replace(/(\{\{Multiple[ _]issues\s*)\|\s*\|/gi, '$1|');
text = replaceMI(text);
text = text.replace(/\n{3,}/g, '\n\n').trim();
return {
text,
summary,
minor: true
};
});
mw.notify('Orphan tag removed!', {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
setTimeout(() => location.reload(), 700);
} catch (e) {
mw.notify('Failed to remove orphan tag.', {
type: 'error'
});
}
}
async function performPromotion(newClass) {
const api = new mw.Api();
const pageName = mw.config.get('wgPageName');
const talkName = 'Talk:' + pageName;
try {
const talkData = await api.get({
action: 'query',
prop: 'revisions',
titles: talkName,
rvprop: 'content',
formatversion: 2
});
let talkWikitext = talkData.query.pages[0].revisions[0].content;
talkWikitext = talkWikitext.replace(/(\|class\s*=\s*)Stub/i, `$1${newClass}`);
await api.postWithEditToken({
action: 'edit',
title: talkName,
text: talkWikitext,
summary: `Promoting article to ${newClass} class per ORES prediction [[WP:ORES]]`
});
const artData = await api.get({
action: 'query',
prop: 'revisions',
titles: pageName,
rvprop: 'content',
formatversion: 2
});
let artWikitext = artData.query.pages[0].revisions[0].content;
const stubRegex = /\{\{[^}]*?-stub\}\}\s*\n?/gi;
const genericStubRegex = /\{\{stub\}\}\s*/gi;
artWikitext = artWikitext.replace(stubRegex, '').replace(genericStubRegex, '');
await api.postWithEditToken({
action: 'edit',
title: pageName,
text: artWikitext,
summary: `Removing stub tags; article promoted to ${newClass}`
});
location.reload();
} catch (e) {
alert("Promotion failed. Check console for details.");
console.error(e);
}
}
function replaceMI(text) {
const miRegex = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miRegex.exec(text)) !== null) {
const start = match.index;
result += text.slice(lastIndex, start);
let depth = 0;
let i = start;
while (i < text.length) {
if (text[i] === '{' && text[i + 1] === '{') {
depth++;
i += 2;
} else if (text[i] === '}' && text[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
const end = i;
const full = text.slice(start, end);
const pipeIdx = full.indexOf('|');
const inner = full.slice(pipeIdx + 1, full.length - 2).trim();
const topLevel = extractTopLevelTemplates(inner);
if (topLevel.length === 0) {
result += '';
} else if (topLevel.length === 1) {
result += topLevel[0].trim();
} else {
result += full;
}
lastIndex = end;
}
result += text.slice(lastIndex);
return result;
}
function extractTopLevelTemplates(inner) {
const templates = [];
let i = 0;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
let depth = 0;
const start = i;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
depth++;
i += 2;
} else if (inner[i] === '}' && inner[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
templates.push(inner.slice(start, i));
} else {
i++;
}
}
return templates;
}
async function performSortCorrection(newName, type) {
const api = new mw.Api();
const title = mw.config.get("wgPageName");
try {
const data = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2
});
let content = data.query.pages[0].revisions[0].content;
const dsRegex = /\{\{(?:DEFAULTSORT|Defaultsort):[^}]+\}\}/i;
const newTag = `{{DEFAULTSORT:${newName}}}`;
let actionDesc = type === "add" ? "added" : "corrected";
if (dsRegex.test(content)) {
content = content.replace(dsRegex, newTag);
} else {
const catRegex = /\[\[Category:/i;
if (catRegex.test(content)) {
content = content.replace(catRegex, `${newTag}\n[[Category:`);
} else {
content += `\n\n${newTag}`;
}
}
await api.postWithEditToken({
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName}`,
nocreate: true
});
mw.notify(`DEFAULTSORT ${actionDesc} to ${newName}!`, {
title: 'Comrade Success',
type: 'success'
});
setTimeout(() => location.reload(), 700);
} catch (err) {
mw.notify('Failed to save DEFAULTSORT.', {
type: 'error'
});
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
function renderSortNudge(target, reason) {
const $dsNudge = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Correct to: ${target}`).on('click', () => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
async function fetchWikidataNameLabels(entity) {
const familyIds = entity.claims?.P734?.map(c => c.mainsnak.datavalue.value.id) || [];
const givenIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
const allNameIds = [...new Set([...familyIds, ...givenIds])];
if (allNameIds.length === 0) return {
givens: [],
surnames: []
};
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: allNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
});
const nameData = await fetch(url).then(r => r.json());
return {
givens: givenIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean),
surnames: familyIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean)
};
}
function fixNameCasing(part, referenceName) {
return part.split(' ').map(word => {
const match = referenceName.match(new RegExp(`\\b${word}\\b`, 'i'));
if (match) return match[0];
return referenceName.split(' ').find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase())) || word;
}).join(' ');
}
function getLongNameFromWikitext(wikitext) {
const firstLine = wikitext.split('\n').find(l => l.includes("'''"));
if (!firstLine) return null;
const boldMatch = firstLine.match(/'''(.+?)'''/);
if (!boldMatch) return null;
return boldMatch[1].replace(/\s*([“"«《„‹「『'].+?[”"»》”›」』']|\([^)\n]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
}
async function init() {
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") return;
const api = new mw.Api();
const qid = mw.config.get("wgWikibaseItemId");
try {
const [pageData, backlinkData] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
api.get({
action: 'query',
list: 'backlinks',
blfilterredir: 'nonredirects',
bllimit: 50,
blnamespace: 0,
bltitle: mw.config.get("wgPageName"),
formatversion: 2
})
]);
const page = pageData.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const currentTitle = mw.config.get("wgTitle");
const cleanTitle = stripDiacritics(currentTitle);
const isOrphanTagged = /\{\{\s*(Orphan|Do-attempt|Lonely|Orp|Orphaned article)/i.test(wikitext) || /\| *orphan *=/i.test(wikitext);
const backLinkCount = backlinkData.query.backlinks.length;
if (isOrphanTagged && backLinkCount >= 1) {
const $container = $('<div>').addClass('comrade-container comrade-status');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text('Remove orphan tag').on('click', () => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) await checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
await checkDomainRedirect(qid, currentTitle);
let isHuman = false;
let entity = null;
if (qid) {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
});
const wikiData = await fetch(url).then(r => r.json());
entity = wikiData.entities[qid];
isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
}
await checkNamingPrecision(currentTitle, isHuman);
if (qid && entity) {
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const nameParts = tClean.split(' ');
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
const isOrphan = isOrphanTagged && backLinkCount < 1;
const primarySurname = hasFamilyNameHatnote ? nameParts[0] : (getSmartSortKey(tClean, entity).split(',')[0] || nameParts[nameParts.length - 1]);
let targetSortName = "";
if (isHuman) {
const {
givens,
surnames
} = await fetchWikidataNameLabels(entity);
const refName = entity.labels?.en?.value || currentTitle;
if (dsMatch) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixNameCasing(lastPart, refName)}, ${fixNameCasing(firstPart, refName)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field.");
} else if (givens.length > 0 && lastPart.split(' ').length > 1) {
const misplacedGiven = lastPart.split(' ').find(part => givens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastPart.split(' ').filter(p => p !== misplacedGiven).join(' ');
targetSortName = `${fixNameCasing(newSurname, refName)}, ${fixNameCasing(misplacedGiven + ' ' + firstPart, refName)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
}
}
} else {
targetSortName = hasFamilyNameHatnote ? `${nameParts[0]}, ${nameParts.slice(1).join(' ')}` : getSmartSortKey(tClean, entity);
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).on('click', () => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
const longName = getLongNameFromWikitext(wikitext);
if (longName && longName.length > tClean.length) {
let sortKey = hasFamilyNameHatnote ? `${longName.split(' ')[0]}, ${longName.split(' ').slice(1).join(' ')}` : getSmartSortKey(longName, entity);
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
await checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
if (targetSortName) {
let targetPage = currentTitle.includes(' (') ? currentTitle.split(' (')[0] : currentTitle;
let rCat = targetPage !== currentTitle ? "R from ambiguous sort name" : "R from sort name";
if (targetSortName.includes(',')) {
const p = targetSortName.split(',');
rCat += `|${p[0].trim().charAt(0).toUpperCase()}|${p[1].trim().charAt(0).toUpperCase()}`;
}
await checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name");
}
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
surnames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
givens.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) finalGivenNames.add(gn);
});
if (finalFamilyNames.size === 0 && nameParts.length >= 2) finalFamilyNames.add(nameParts[nameParts.length - 1]);
if (finalGivenNames.size === 0 && nameParts.length >= 2) finalGivenNames.add(nameParts[0]);
const getPriorityTarget = async (name, type) => {
const titles = type === 'surname' ? [`List of people with surname ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`] : [`List of people with given name ${name}`, `${name} (given name)`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
return titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
};
for (const name of finalGivenNames) {
if (name.toLowerCase() === primarySurname.toLowerCase()) continue;
const target = await getPriorityTarget(name, 'given');
if (target) await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
for (const name of finalFamilyNames) {
const titles = [name, `${name} (surname)`, `List of people with surname ${name}`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
const target = titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
if (target) await checkNameList(name, 'surname', currentTitle, hasShortDesc, isOrphan, target);
}
}
const oresWait = setInterval(() => {
if ($('.article_quality').length) {
clearInterval(oresWait);
checkQualityConsistency();
}
}, 500);
setTimeout(() => clearInterval(oresWait), 5000);
}
} catch (err) {
console.error("Comrade error:", err);
}
}
init();
})();
(function() {
if (window.IllWill) return;
const STRIKEOUT = true;
const HIDE = 'hide';
const IllWill = {
Version: "1.1.1",
Attribution: 'created by <a target="_blank" href="/wiki/User:Cobaltcigs">cobaltcigs</a>',
Summary: "Added interlanguage links using IllWill.js",
DataApi: "https://www.wikidata.org/w/api.php",
LocalLang: mw.config.get("wgPageContentLanguage"),
Disambiguator: / \(.*\)$/,
SiteLinks: {},
Config: {
MaxLanguages: 5,
IgnoreScientific: STRIKEOUT,
AutoDiff: true
},
css: `
#illwill { clear: both; margin-bottom: 1.5em; border: 1px solid #a2a9b1; padding: 15px; background: #f8f9fa; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.ill-sitelinks a { display: inline-block; margin-bottom: 3px; font-size: 0.9em; color: #36c; }
.ill-size-tag { font-size: 0.8em; color: #72777d; margin-left: 6px; white-space: nowrap; }
.illwill-ignore { text-decoration: line-through; color: #72777d; font-size: 0.85em; }
.ill-x { width: 35px; text-align: center; }
.ill-input, .ill-output { font-family: 'Courier New', monospace; white-space: pre-wrap; font-size: 0.9em; background: #fff; border: 1px solid #eaecf0; padding: 4px; border-radius: 2px; }
#ill-buttons { text-align: right; padding: 10px; border-top: 1px solid #a2a9b1; margin-top: 10px; }
#ill-buttons button { margin-left: 10px; cursor: pointer; padding: 6px 16px; border-radius: 2px; border: 1px solid #a2a9b1; transition: all 0.2s; }
#ill-ok { background: #36c; color: #fff; border-color: #36c !important; font-weight: bold; }
#ill-ok:hover { background: #447ff5; }
#ill-ok:disabled { background: #eaecf0; color: #72777d; border-color: #c8ccd1 !important; cursor: not-allowed; opacity: 0.7; }
#ill-table { width: 100%; border-collapse: collapse; margin-top: 10px; background: white; }
#ill-table th { background: #f2f2f2; text-align: left; padding: 8px; border: 1px solid #a2a9b1; }
#ill-table td { padding: 8px; border: 1px solid #a2a9b1; vertical-align: top; }
#ill-table caption { background: #eaffea; font-weight: bold; padding: 10px; border: 1px solid #a2a9b1; border-bottom: none; font-size: 1.1em; }
#ill-help { font-size: 0.85em; color: #54595d; padding: 10px; line-height: 1.4; }
.ill-loading-inline { font-style: italic; color: #72777d; font-size: 0.9em; display: block; margin: 5px 0; }
`,
setup() {
if (!["edit", "submit"].includes(mw.config.get('wgAction'))) return;
mw.loader.addStyleTag(this.css);
const link = mw.util.addPortletLink('p-cactions', '#', 'IllWill', 'ca-illwill', "Add interlanguage links via Wikidata");
if (link) {
link.addEventListener('click', (e) => {
e.preventDefault();
this.makePanel();
});
}
},
norm(s) {
if (!s) return "";
const t = s.trim().replace(/[\s_]+/g, " ");
return t.charAt(0).toUpperCase() + t.slice(1);
},
async getRedLinks($textbox) {
const txt = $textbox.textSelection('getContents') || "";
const wl = txt.match(/\[\[[^[\]\n]+\]\]/g);
if (!wl) return {
reds: [],
txt
};
const api = new mw.Api();
const textToParse = wl.join("").replace(/\|[^\]]+/g, "");
try {
const data = await api.post({
action: 'parse',
text: textToParse,
contentmodel: 'wikitext',
formatversion: 2
});
const tempDiv = document.createElement('div');
tempDiv.innerHTML = data.parse.text;
const reds = Array.from(tempDiv.querySelectorAll("a.new")).map(x => {
const urlParams = new URLSearchParams(x.href.split('?')[1]);
return urlParams.get('title')?.replace(/_/g, " ");
}).filter(Boolean);
return {
reds: [...new Set(reds)],
txt
};
} catch (e) {
return {
reds: [],
txt
};
}
},
async makePanel() {
const $editform = $('#editform');
const $textbox = $('#wpTextbox1');
$('#illwill').remove();
$editform.hide();
const $wrapper = $('<div id="illwill">Loading redlinks...</div>').insertBefore($editform);
const {
reds,
txt
} = await this.getRedLinks($textbox);
const wtLinks = [];
reds.forEach(title => {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s/g, "[\\s_]+");
const re = new RegExp("\\[\\[\\s*" + escaped + "\\s*(?:\\|[^\\[\\]]+)?\\]\\]", "ig");
const match = txt.match(re);
if (match && !wtLinks.some(z => z.title === title)) {
wtLinks.push({
link: match[0],
title
});
}
});
if (!wtLinks.length) {
const $back = $('<button>Quit</button>').on('click', () => {
$wrapper.remove();
$editform.show();
});
$wrapper.empty().append($('<strong>No valid redlinks found in text.</strong> '), $back);
return;
}
const $table = $(`
<table id="ill-table" class="wikitable">
<caption>IllWill.js (v${this.Version})</caption>
<thead>
<tr>
<th class="ill-x"><input type="checkbox" id="ill-select-all" disabled></th>
<th>Input link</th>
<th>Possible Wikidata matches</th>
<th>Sitelinks (top ${this.Config.MaxLanguages} by size)</th>
<th>Resulting wikitext</th>
</tr>
</thead>
<tbody></tbody>
<tfoot>
<tr>
<td colspan="3" id="ill-help">Carefully examine possible Wikidata matches.</td>
<td colspan="2" id="ill-buttons">
<button id="ill-cancel">Cancel</button>
<button id="ill-ok" disabled>Show changes</button>
</td>
</tr>
</tfoot>
</table>
`);
const $tbody = $table.find('tbody');
wtLinks.forEach((item, i) => {
$tbody.append(`
<tr id="ill-row-${i}" data-index="${i}">
<td class="ill-x"><input type="checkbox" class="ill-row-check" disabled></td>
<td class="ill-input">${this.escapeHTML(item.link)}</td>
<td class="ill-wd-item"><span class="ill-loading-inline">Searching...</span></td>
<td class="ill-sitelinks"></td>
<td class="ill-output"></td>
</tr>
`);
});
$wrapper.empty().append($table);
const chunkSize = 5;
for (let i = 0; i < wtLinks.length; i += chunkSize) {
const chunk = wtLinks.slice(i, i + chunkSize);
await Promise.all(chunk.map((item, idx) => this.fetchWikidataOptimized(item.title, i + idx)));
}
$table.on('click', '#ill-cancel', () => {
$editform.show();
$wrapper.remove();
});
$table.on('click', '#ill-ok', () => this.onOkButton($editform, $wrapper, $textbox));
$table.on('change', '#ill-select-all', (e) => {
const isChecked = $(e.target).is(':checked');
$table.find('.ill-row-check:not(:disabled)').each(function() {
$(this).prop('checked', isChecked).trigger('change');
});
});
$table.on('change', '.ill-row-check', (e) => {
this.onCheckbox(e);
this.updateOkButtonState($table);
});
$table.on('change', 'input[type="radio"]', (e) => {
this.showSiteLinks(e);
});
},
async fetchWikidataOptimized(title, index) {
const query = title.replace(this.Disambiguator, "");
const url = `${this.DataApi}?action=wbsearchentities&search=${encodeURIComponent(query)}&language=${this.LocalLang}&limit=5&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
const results = data.search;
const $cell = $(`#ill-row-${index} .ill-wd-item`);
if (!results || !results.length) {
$cell.html('<span class="ill-na">(no results)</span>');
return;
}
const entityIds = results.map(r => r.id);
await this.fetchEntities(entityIds, index, $cell);
} catch (e) {
$(`#ill-row-${index} .ill-wd-item`).text("Search failed.");
}
},
async fetchEntities(ids, rowIndex, $cell) {
const url = `${this.DataApi}?action=wbgetentities&ids=${ids.join('|')}&props=labels|descriptions|claims|sitelinks&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
$cell.empty();
ids.forEach(qNum => {
const entity = data.entities[qNum];
if (!entity) return;
const isScientific = entity.claims?.P31?.some(s => s.mainsnak?.datavalue?.value?.id === "Q13442814");
if (isScientific && this.Config.IgnoreScientific === HIDE) return;
const desc = entity.descriptions?.[this.LocalLang]?.value || entity.descriptions?.en?.value || "";
const label = entity.labels?.[this.LocalLang]?.value || entity.labels?.en?.value || qNum;
const isStrikeout = isScientific && this.Config.IgnoreScientific === STRIKEOUT;
const $div = $(`
<div class="${isStrikeout ? 'illwill-ignore' : ''}">
<input type="radio" name="selection-${rowIndex}" value="${qNum}" id="radio-${qNum}" ${isStrikeout ? 'disabled' : ''}>
<label for="radio-${qNum}">
<a href="https://wikidata.org/wiki/${qNum}" target="_blank">${this.escapeHTML(label)}</a>:
<span class="ill-desc">${desc ? this.escapeHTML(desc) : '<i>no description</i>'}</span>
</label>
</div>
`);
$cell.append($div);
this.SiteLinks[qNum] = entity.sitelinks || {};
});
} catch (e) {
$cell.text("Detail fetch failed.");
}
},
updateOkButtonState($table) {
const $rows = $table.find('.ill-row-check:not(:disabled)');
const $checkedRows = $rows.filter(':checked');
const anyEnabled = $rows.length > 0;
$table.find('#ill-ok').prop('disabled', $checkedRows.length === 0);
const $master = $table.find('#ill-select-all');
$master.prop('disabled', !anyEnabled);
if (anyEnabled && $rows.length === $checkedRows.length) {
$master.prop('checked', true);
} else {
$master.prop('checked', false);
}
},
async showSiteLinks(e) {
const isManualToggling = e.fromCheckbox || false;
const qNum = e.target.value;
const $row = $(e.target).closest('tr');
const sitelinks = this.SiteLinks[qNum];
const $checkbox = $row.find('.ill-row-check');
const $sitelinkCell = $row.find('.ill-sitelinks');
const $outputCell = $row.find('.ill-output');
const $table = $('#ill-table');
const effectiveMax = Math.min(Math.max(this.Config.MaxLanguages, 3), 6);
const validKeys = Object.keys(sitelinks).filter(k => k.endsWith('wiki') && !k.includes('commons'));
$sitelinkCell.html('<span class="ill-loading-inline">Measuring articles...</span>');
$outputCell.empty();
if (!isManualToggling) {
$checkbox.prop('disabled', true).prop('checked', false);
}
const candidates = validKeys.slice(0, 25);
const sizeMap = [];
await Promise.all(candidates.map(async (key) => {
const lang = key.replace('wiki', '').replace(/_/g, '-');
const title = sitelinks[key].title;
const endpoint = `https://${lang}.wikipedia.org/w/api.php?action=query&titles=${encodeURIComponent(title)}&prop=info&format=json&origin=*`;
try {
const res = await fetch(endpoint).then(r => r.json());
const page = Object.values(res.query.pages)[0];
sizeMap.push({
lang,
title,
length: page.length || 0
});
} catch (err) {
sizeMap.push({
lang,
title,
length: 0
});
}
}));
sizeMap.sort((a, b) => b.length - a.length);
const topLinks = sizeMap.slice(0, effectiveMax);
$sitelinkCell.html(topLinks.map(item => {
const kbValue = Math.round(item.length / 1024);
return `<div><a href="https://${item.lang}.wikipedia.org/wiki/${encodeURIComponent(item.title)}" target="_blank">${item.lang}:${this.escapeHTML(item.title)}</a> <span class="ill-size-tag">(${kbValue} kB)</span></div>`;
}).join(""));
const inputWikitext = $row.find('.ill-input').text();
const match = inputWikitext.match(/\[\[(.*?)\]\]/);
if (match) {
const parts = match[1].split('|');
const targetPage = parts[0].trim();
const surfaceName = (parts[1] || parts[0]).trim();
const illLangs = topLinks.map(item => {
const isSameTitle = this.norm(item.title) === this.norm(targetPage);
return `|${item.lang}|${isSameTitle ? '' : item.title}`;
}).join("");
const ltParam = (this.norm(surfaceName) === this.norm(targetPage)) ? "" : `|lt=${surfaceName}`;
$outputCell.text(`{{ill|${targetPage}${ltParam}${illLangs}}}`);
}
$checkbox.prop('disabled', !topLinks.length);
if (!isManualToggling) {
$checkbox.prop('checked', !!topLinks.length);
}
this.updateOkButtonState($table);
},
onCheckbox(e) {
const $row = $(e.target).closest('tr');
const $radio = $row.find('input[type="radio"]:checked');
const isChecked = $(e.target).is(':checked');
if (isChecked) {
if ($radio.length) {
this.showSiteLinks({
target: $radio[0],
fromCheckbox: true
});
}
} else {
$row.find('.ill-sitelinks, .ill-output').empty();
}
},
onOkButton($editform, $wrapper, $textbox) {
let text = $textbox.textSelection('getContents');
let changed = false;
$wrapper.find('.ill-row-check:checked').each(function() {
const $row = $(this).closest('tr');
const input = $row.find('.ill-input').text();
const output = $row.find('.ill-output').text();
if (output) {
const escapedInput = input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
text = text.replace(new RegExp(escapedInput, "gi"), output);
changed = true;
}
});
if (changed) {
$('#wpSummary').val((i, v) => (v ? v + "; " : "") + this.Summary);
$textbox.textSelection('setContents', text);
$wrapper.remove();
$editform.show();
if (this.Config.AutoDiff) $('#wpDiff').click();
}
},
escapeHTML(str) {
return str.replace(/[&<>"']/g, m => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
})[m]);
}
};
window.IllWill = IllWill;
mw.loader.using(['mediawiki.util', 'mediawiki.api', 'jquery.textSelection'], () => {
$(() => IllWill.setup());
});
})();
(function() {
'use strict';
const config = {
enableNS14: true,
rootWhitelists: ['Materials'],
userWhitelist: ""
};
const categoryDataCache = {};
let scanProgress = 0;
let totalToScan = 0;
async function verifyTreeMembership() {
if (!config.enableNS14) return false;
const api = new mw.Api();
const currentTitle = mw.config.get("wgPageName").replace(/_/g, ' ');
const fullRoots = config.rootWhitelists.concat(config.userWhitelist.split(',').map(s => s.trim()).filter(s => s)).map(cat => (cat.startsWith('Category:') ? cat : `Category:${cat}`));
if (fullRoots.includes(currentTitle)) return true;
let categoriesToTable = mw.config.get('wgCategories') || [];
let checked = new Set();
while (categoriesToTable.length > 0) {
if (categoriesToTable.some(cat => fullRoots.includes('Category:' + cat))) {
return true;
}
categoriesToTable.forEach(cat => checked.add(cat));
try {
const res = await api.get({
formatversion: 2,
prop: 'categories',
titles: categoriesToTable.map(c => 'Category:' + c),
cllimit: 'max'
});
let nextBatch = [];
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.categories) {
page.categories.forEach(parent => {
const parentName = parent.title.substring('Category:'.length);
if (!checked.has(parentName)) {
nextBatch.push(parentName);
}
});
}
});
}
categoriesToTable = [...new Set(nextBatch)];
} catch (e) {
return false;
}
}
return false;
}
function getChemboxBlock(wikitext) {
const chemboxRegex = /\{\{\s*Chembox[\s_]*(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const match = wikitext.match(chemboxRegex);
return match ? match[0] : null;
}
function getFormulaFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const formulaMatch = chembox.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
let rawFormula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
rawFormula = rawFormula.split(/\s+/)[0];
return rawFormula.split(',')[0].trim();
}
const propRegex = /\{\{\s*Chembox[\s_]+Properties(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const propSection = chembox.match(propRegex);
if (propSection) {
const content = propSection[0];
const allElements = ['Ac', 'Ag', 'Al', 'Am', 'Ar', 'As', 'At', 'Au', 'B', 'Ba', 'Be', 'Bh', 'Bi', 'Bk', 'Br', 'C', 'Ca', 'Cd', 'Ce', 'Cf', 'Cl', 'Cm', 'Cn', 'Co', 'Cr', 'Cs', 'Cu', 'D', 'Db', 'Ds', 'Dy', 'Er', 'Es', 'Eu', 'F', 'Fe', 'Fl', 'Fm', 'Fr', 'Ga', 'Gd', 'Ge', 'H', 'He', 'Hf', 'Hg', 'Ho', 'Hs', 'I', 'In', 'Ir', 'K', 'Kr', 'La', 'Li', 'Lr', 'Lu', 'Lv', 'Mc', 'Md', 'Mg', 'Mn', 'Mo', 'Mt', 'N', 'Na', 'Nb', 'Nd', 'Ne', 'Nh', 'Ni', 'No', 'Np', 'O', 'Og', 'Os', 'P', 'Pa', 'Pb', 'Pd', 'Pm', 'Po', 'Pr', 'Pt', 'Pu', 'Ra', 'Rb', 'Re', 'Rf', 'Rg', 'Rh', 'Rn', 'Ru', 'S', 'Sb', 'Sc', 'Se', 'Sg', 'Si', 'Sm', 'Sn', 'Sr', 'Ta', 'Tb', 'Tc', 'Te', 'Th', 'Ti', 'Tl', 'Tm', 'Ts', 'U', 'V', 'W', 'Xe', 'Y', 'Yb', 'Zn', 'Zr'];
let data = {};
allElements.forEach(el => {
const elRegex = new RegExp(`\\|\\s*${el}\\s*=\\s*(\\d*\\.?\\d+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch && elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
});
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
elementsPresent.filter(el => !(hasC && (el === "C" || el === "H"))).sort().forEach(el => {
formulaArray.push(el + data[el]);
});
return formulaArray.join('');
}
}
return null;
}
function getCASFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const idRegex = /\{\{\s*Chembox[\s_]+Identifiers(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const idSection = chembox.match(idRegex);
if (!idSection) return null;
const content = idSection[0];
const nbsp = String.fromCharCode(160);
const casMatch = content.match(/\|\s*CASNo\d*\b(?!\s*_)\s*=\s*([^|}\n]+)/i);
if (casMatch) {
let val = casMatch[1].replace(/\{\{[^}]+\}\}/g, "").replace(/\u00AD/g, "").split(" ").join("");
while (val.indexOf(nbsp) !== -1) val = val.replace(nbsp, "");
while (val.indexOf(" ") !== -1) val = val.replace(" ", "");
const cleanedCas = val.trim();
return /^\d{2,7}-\d{2}-\d$/.test(cleanedCas) ? cleanedCas : null;
}
return null;
}
async function getWikidataValue() {
const qid = mw.config.get('wgWikidataItemId');
if (!qid) return null;
const api = new mw.ForeignApi('https://www.wikidata.org/w/api.php');
const res = await api.get({
action: 'wbgetentities',
ids: qid,
props: 'claims',
format: 'json',
formatversion: 2
});
const claims = res.entities[qid].claims;
const data = {};
if (claims.P274) data.formula = claims.P274[0].mainsnak.datavalue.value;
if (claims.P231) data.cas = claims.P231[0].mainsnak.datavalue.value;
return data;
}
async function startBackgroundCategoryScan() {
const api = new mw.Api();
const titles = $('#mw-pages li a').map((i, el) => $(el).attr('title') || $(el).text()).get();
totalToScan = titles.length;
if (totalToScan === 0) return;
const chunks = arrayChunk(titles, 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: chunk,
rvprop: 'content',
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const f = getFormulaFromWikitext(wikitext);
const c = getCASFromWikitext(wikitext);
if (f || c) categoryDataCache[page.title] = {
formula: f,
cas: c
};
});
}
scanProgress += chunk.length;
$('#caschemassist-cat-btn').text(`Scanning... ${scanProgress}/${totalToScan}`);
}
$('#caschemassist-cat-btn').text('Show missing CAS/Chem').prop('disabled', false);
}
async function renderCategoryAudit() {
const api = new mw.Api();
const missingCheckTitles = [];
Object.values(categoryDataCache).forEach(data => {
if (data.formula) missingCheckTitles.push(data.formula);
if (data.cas) missingCheckTitles.push(data.cas);
});
if (missingCheckTitles.length === 0) {
mw.notify("No valid Chembox data found in this category.");
return;
}
const existenceMap = {};
const chunks = arrayChunk([...new Set(missingCheckTitles)], 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
titles: chunk,
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(p => {
existenceMap[p.title] = !p.missing;
});
}
}
$('#mw-pages li').each(function() {
const title = $(this).find('a').attr('title') || $(this).find('a').text().trim();
const data = categoryDataCache[title];
if (!data) return;
if (data.formula && !existenceMap[data.formula]) {
$(this).append($('<span>').css({
'color': 'red',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [${data.formula}]`));
}
if (data.cas && !existenceMap[data.cas]) {
$(this).append($('<span>').css({
'color': 'darkred',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [CAS: ${data.cas}]`));
}
});
$('#caschemassist-cat-btn').text('Audit complete').prop('disabled', true);
}
async function handleSingleArticle() {
const api = new mw.Api();
const currentTitle = mw.config.get("wgTitle");
const [wikiRes, wikidata] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
getWikidataValue()
]);
const page = wikiRes.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const formula = getFormulaFromWikitext(wikitext);
const cas = getCASFromWikitext(wikitext);
if (wikidata) {
let mismatches = [];
if (formula && wikidata.formula && formula !== wikidata.formula) {
mismatches.push(`Formula mismatch: Chembox "${formula}" vs Wikidata "${wikidata.formula}"`);
}
if (cas && wikidata.cas && cas !== wikidata.cas) {
mismatches.push(`CAS mismatch: Chembox "${cas}" vs Wikidata "${wikidata.cas}"`);
}
if (mismatches.length > 0) {
const $warning = $('<div>').css({
'background': '#fff2f2',
'border': '2px solid #d33',
'padding': '10px',
'margin-bottom': '1em',
'color': '#b32424'
}).append($('<strong>').text('⚠️ Data Mismatch Detected: '), mismatches.join(' | '));
$('#mw-content-text').prepend($warning);
}
}
async function checkAndRender(target, label, customWikitext) {
const check = await api.get({
action: 'query',
titles: target,
formatversion: 2
});
if (check.query.pages[0].missing) {
const $container = $('<div>').css({
'background': '#f8f9fa',
'border': '1px solid #a2a9b1',
'padding': '10px',
'margin-bottom': '1em'
});
const $btn = $('<button>').text(`Create redirect: ${target}`).click(async function() {
await api.postWithEditToken({
action: 'edit',
title: target,
text: customWikitext,
summary: `Creating redirect to [[${currentTitle}]] (${label})`,
createonly: true
});
mw.notify(`Created redirect: ${target}`);
$container.fadeOut(400, function() {
$(this).remove();
});
});
$container.append($('<strong>').text(`${label}: `), `Missing redirect. `, $btn);
$('#mw-content-text').prepend($container);
}
}
if (formula && formula !== currentTitle) {
const formulaWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from chemical formula}}\n}}`;
await checkAndRender(formula, "Chemical formula", formulaWikitext);
}
if (cas && cas !== currentTitle) {
const casWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from CAS Registry Number}}\n{{R unprintworthy}}\n}}`;
await checkAndRender(cas, "CAS number", casWikitext);
}
}
function arrayChunk(arr, size) {
const result = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
$(document).ready(async function() {
const ns = mw.config.get("wgNamespaceNumber");
if (ns === 14) {
if (await verifyTreeMembership()) {
const $catContainer = $('#mw-pages');
if ($catContainer.length) {
const $btn = $('<button>').text('Initializing scan...').attr('id', 'caschemassist-cat-btn').addClass('mw-ui-button').css({
'margin': '0 10px 10px 0',
'float': 'right',
'position': 'relative',
'z-index': '100'
}).prop('disabled', true).click(renderCategoryAudit);
$catContainer.prepend($btn);
startBackgroundCategoryScan();
}
}
} else if (ns === 0 && mw.config.get("wgAction") === "view") {
handleSingleArticle();
}
});
})();
//</nowiki>
hjxpx3thkhb17w8gr67ttn5qgdfwj62
739306
739305
2026-04-25T08:26:05Z
Sam Sailor
26820
Test
739306
javascript
text/javascript
//<nowiki>
(function() {
mw.util.addCSS(`
.ambox-Orphan { display: block !important; }
.comrade-container {
box-shadow: 0 1px 3px rgba(0,0,0,0.1); border-radius: 2px;
padding: 10px 15px; margin: 10px 0; display: flex;
flex-direction: column; align-items: flex-start; gap: 8px;
border: 1px solid #a2a9b1; background: #fcfcfc; line-height: 1.5;
}
.comrade-header { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.comrade-content { display: flex; flex-direction: column; gap: 5px; width: 100%; }
.comrade-success { background-color: #d5f5e3 !important; border-left: 5px solid #27ae60 !important; color: #1b5e20 !important; }
.comrade-nudge { background: #fff9ea; border-left: 5px solid #f0ad4e; }
.comrade-info { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-status { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-container button:not(.comrade-dismiss) {
background: #f8f9fa; border: 1px solid #a2a9b1; border-radius: 2px;
padding: 2px 10px; cursor: pointer; font-weight: bold; font-size: 0.95em; margin-left: 10px;
}
.comrade-container button:not(.comrade-dismiss):hover { border-color: #36c; color: #36c; background: #fff; }
.comrade-dismiss {
background: transparent; border: none; color: #d33; font-weight: bold;
cursor: pointer; font-size: 0.9em;
}
.comrade-dismiss:hover { text-decoration: underline; }
.comrade-code-block { background: #f1f1f1; padding: 4px 8px; border-radius: 3px; font-family: monospace; display: inline-block; margin-top: 4px; }
`);
function appendDismiss($el) {
$('<button>').addClass('comrade-dismiss').text('Dismiss').on('click', function() {
$(this).closest('.comrade-container').fadeOut();
}).appendTo($el.find('.comrade-header'));
$("#siteSub").after($el);
}
async function checkNamingPrecision(currentTitle, isHuman) {
const disambigMatch = currentTitle.match(/^(.+?)(?:, | \()/);
if (isHuman || !disambigMatch) return;
const baseTitle = disambigMatch[1].trim();
const api = new mw.Api();
try {
const baseRes = await api.get({
action: 'query',
titles: baseTitle,
redirects: 1,
formatversion: 2
});
const page = baseRes.query.pages[0];
const baseExists = !page.missing;
let baseRedirectsToSelf = false;
if (baseRes.query.redirects) {
baseRedirectsToSelf = baseRes.query.redirects.some(r => r.to === currentTitle);
}
const searchRes = await api.get({
action: 'query',
list: 'allpages',
apprefix: baseTitle,
aplimit: 50,
formatversion: 2
});
const competitors = searchRes.query.allpages.filter(p => {
const t = p.title;
return t !== currentTitle && t !== baseTitle && (t.startsWith(baseTitle + ', ') || t.startsWith(baseTitle + ' ('));
});
if (!baseExists || baseRedirectsToSelf) {
const $container = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (competitors.length === 0) {
$header.append($('<strong>').text('Precision:'));
const reason = baseRedirectsToSelf ? "already redirects here" : "is free";
$content.append($('<span>').append(`The base name `, $('<code>').text(baseTitle), ` ${reason}. Consider moving this article.`));
const moveUrl = mw.util.getUrl('Special:MovePage/' + currentTitle, {
wpNewTitle: baseTitle,
wpReason: 'Unnecessary disambiguation; base name is free/redirects here per [[WP:PRECISION]]'
});
$content.append($('<button>').text(`Move to ${baseTitle}`).on('click', () => window.open(moveUrl, '_blank')));
} else {
$header.append($('<strong>').text('Observation:'));
const state = baseRedirectsToSelf ? 'redirect to this page' : 'redlink';
$content.append($('<span>').append(`Base name `, $('<code>').text(baseTitle), ` has ${competitors.length} competitors, but it is currently a ${state}.`));
$content.append($('<span>').text('Consider if a disambiguation page is needed.'));
}
$container.append($header, $content);
appendDismiss($container);
}
} catch (e) {
console.warn("Comrade: Precision check failed", e);
}
}
async function checkAndRenderRedirect(redirTitle, targetTitle, rCategory, labelText, customWikitext) {
const api = new mw.Api();
const res = await api.get({
action: 'query',
titles: redirTitle,
formatversion: 2
});
if (res.query.pages[0].missing) {
const safeId = "comrade-redir-" + redirTitle.replace(/[^a-z0-9]/gi, '-');
const $container = $('<div>').addClass('comrade-container comrade-info').attr('id', safeId);
const $header = $('<div>').addClass('comrade-header');
const summary = `Created redirect from ${labelText.toLowerCase()} to [[${targetTitle}]]`;
const $btn = $('<button>').text('Create redirect').on('click', () => createModernRedirect(redirTitle, targetTitle, rCategory, summary, safeId, customWikitext));
$header.append($('<div>').append($('<strong>').text(`${labelText}: `), `Create redirect from `, $('<code>').text(redirTitle), $btn));
$container.append($header);
appendDismiss($container);
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
if (qid) {
try {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
});
const res = await fetch(url).then(r => r.json());
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
}
} catch (e) {
console.warn("Comrade: Wikidata API call failed.");
}
}
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
if (domain.includes(".") && url.pathname === "/") {
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
try {
const response = await fetch(citationURL);
if (response.ok) {
await checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
}
} catch (e) {
console.warn("Comrade: Citation API check failed.");
}
}
} catch (e) {
console.warn("Comrade: Error parsing URL object.");
}
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan, resolvedTitle) {
const api = new mw.Api();
const candidates = resolvedTitle ? [resolvedTitle] : [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
redirects: 0
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const foundTitle = page.title;
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) break;
const $container = $('<div>').addClass('comrade-container');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (isOrphan) {
$container.addClass('comrade-nudge');
$header.append($('<strong>').text('Comrade nudge:'));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
}
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(foundTitle),
target: '_blank'
}).text(foundTitle), "; should it be?"));
const snippet = '* {{anbl|' + currentTitle + '}}';
const $snippetWrapper = $('<div>').css({
'display': 'flex',
'align-items': 'center',
'gap': '10px',
'margin-top': '4px'
});
const $instructionSpan = $('<span>').append('If the Short description is good, consider adding ', $('<code>').text(snippet).css({
'background-color': '#f8f9fa',
'border': '1px solid #eaecf0',
'border-radius': '2px',
'padding': '1px 4px'
}));
const $copyBtn = $('<button>').text('Copy & insert').css('margin-left', '0').on('click', function() {
navigator.clipboard.writeText(snippet).then(() => {
const $this = $(this);
const originalText = $this.text();
$this.text('Copied!').prop('disabled', true);
const editUrl = mw.util.getUrl(foundTitle, {
action: 'edit',
summary: snippet
});
window.open(editUrl, '_blank');
setTimeout(() => {
$this.text(originalText).prop('disabled', false);
}, 1500);
});
});
$snippetWrapper.append($instructionSpan, $copyBtn);
$content.append($snippetWrapper);
if (!hasShortDesc) {
$content.append($('<span>').css({
'color': '#d33',
'font-weight': 'bold',
'margin-top': '5px'
}).text('⚠️ Missing short description: Please add a concise one before listing.'));
}
$container.append($header, $content);
appendDismiss($container);
break;
}
}
}
}
async function checkQualityConsistency() {
const oresClass = getOresPrediction();
if (!oresClass || oresClass === 'Stub') return;
const talkTitle = 'Talk:' + mw.config.get('wgPageName');
const api = new mw.Api();
try {
const [talkRes, pageRes] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: talkTitle,
rvprop: 'content',
formatversion: 2
}),
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
})
]);
const talkPage = talkRes.query.pages[0];
const wikitext = pageRes.query.pages[0].revisions[0].content;
const hasStubTag = /\{\{[^}]*stub\}\}/i.test(wikitext);
const $raterLink = $('#ca-rater, #t-rater');
const isRaterAvailable = (typeof window.rater !== 'undefined' || $raterLink.length > 0);
let talkClass = null;
if (!talkPage.missing) {
const classMatch = talkPage.revisions[0].content.match(/\|class\s*=\s*([^|}\s]+)/i);
talkClass = classMatch ? classMatch[1].charAt(0).toUpperCase() + classMatch[1].slice(1).toLowerCase() : null;
}
const isStubRated = talkClass === 'Stub';
const lingeringStubTag = (talkClass && talkClass !== 'Stub' && oresClass !== 'Stub' && hasStubTag);
if (talkPage.missing || isStubRated || lingeringStubTag) {
const $container = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').append($('<strong>').text('Quality:'), " ");
let message = `ORES suggests <b>${oresClass}</b>. `;
if (talkPage.missing) {
message = `Talk page missing; ORES suggests <b>${oresClass}</b>. `;
let $btn;
if (isRaterAvailable) {
$btn = $('<button>').text('Open Rater').on('click', () => $raterLink.find('a')[0].click());
} else {
$btn = $('<button>').text('Install Rater').on('click', () => window.open('https://en.wikipedia.org/wiki/User:Evad37/rater', '_blank'));
message += "Consider installing <i>Rater</i> for easy assessment.";
}
$content.append(message, $btn);
} else if (lingeringStubTag && !isStubRated) {
message = `Article is ${talkClass}-class, but stub tags remain. `;
const $btn = $('<button>').text('Remove stub tags').on('click', () => performStubRemoval());
$content.append(message, $btn);
} else if (isStubRated) {
message = `Talk page says Stub, but ORES predicts <b>${oresClass}</b>. `;
const $btnOres = $('<button>').text(`Promote to ${oresClass}`).on('click', () => performPromotion(oresClass));
$content.append(message, $btnOres);
if (oresClass === 'C') {
const $btnStart = $('<button>').text(`Promote to Start`).on('click', () => performPromotion('Start'));
$content.append("\u00A0\u00A0or\u00A0", $btnStart);
} else if (oresClass === 'B') {
const $btnC = $('<button>').text(`Promote to C`).on('click', () => performPromotion('C'));
$content.append("\u00A0\u00A0or\u00A0", $btnC);
}
}
$header.append($content);
$container.append($header);
appendDismiss($container);
}
} catch (e) {
console.error("Comrade quality check failed", e);
}
}
async function performStubRemoval() {
const api = new mw.Api();
try {
const pageData = await api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
});
let wikitext = pageData.query.pages[0].revisions[0].content;
const newWikitext = wikitext.replace(/\{\{[^}]*stub\}\}\n?/gi, '').trim();
if (wikitext === newWikitext) {
mw.notify("No stub tags found to remove.");
return;
}
await api.postWithToken('csrf', {
action: 'edit',
title: mw.config.get("wgPageName"),
text: newWikitext,
summary: `Removing lingering stub tags per ORES/Talk assessment`,
nocreate: true
});
location.reload();
} catch (err) {
console.error("Comrade failed to remove stub tags:", err);
mw.notify("Error removing stub tags.", {
type: 'error'
});
}
}
async function createModernRedirect(redirTitle, targetTitle, rCategory, customSummary, elementId, customWikitext) {
const api = new mw.Api();
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
try {
await api.postWithEditToken({
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
});
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
} catch (e) {
mw.notify("Failed to create redirect.", {
type: 'error'
});
}
}
function getOresPrediction() {
// Dependency: [[m:User:EpochFail/ArticleQuality-system.js]] renders the prediction inside .article_quality
const text = $('.article_quality').text();
if (!text) return null;
if (text.includes('Start')) return 'Start';
if (text.includes('C')) return 'C';
if (text.includes('B')) return 'B';
return null;
}
function getSmartSortKey(fullName, entity) {
const countryIds = entity.claims?.P27?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const locationIds = entity.claims?.P17?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const placeIds = [...(entity.claims?.P19 || []), ...(entity.claims?.P119 || [])].map(c => c.mainsnak?.datavalue?.value?.id).filter(Boolean);
const allRelevantIds = [...countryIds, ...locationIds, ...placeIds];
const birthYear = parseInt(entity.claims?.P569?.[0]?.mainsnak?.datavalue?.value?.time?.match(/[+-](\d{4})/)?.[1]) || null;
if (fullName.startsWith("Saint ")) return fullName.replace("Saint ", "");
const arabicParticles = /\b(Abu|Abd|Abdel|Abdul|ben|bin|bint)\b/i;
if (fullName.match(arabicParticles)) {
if (birthYear && birthYear > 1900) {
const parts = fullName.split(' ');
const binIndex = parts.findIndex(p => p.toLowerCase() === 'bin' || p.toLowerCase() === 'ben');
if (binIndex > 0) return parts.slice(binIndex).join(' ') + ', ' + parts.slice(0, binIndex).join(' ');
} else {
return fullName;
}
}
const eastAsianQids = ["Q148", "Q184", "Q884", "Q424", "Q881", "Q17"];
if (allRelevantIds.some(id => eastAsianQids.includes(id))) {
if (allRelevantIds.includes("Q17") && birthYear && birthYear > 1885) return westernSort(fullName);
const parts = fullName.split(' ');
if (parts.length > 1) return `${parts[0]}, ${parts.slice(1).join(' ')}`;
}
return westernSort(fullName);
}
function westernSort(name) {
let cleanName = name;
if (name.startsWith("O'")) cleanName = name.replace("O'", "O");
const parts = cleanName.split(' ');
if (parts.length < 2) return cleanName;
const surname = parts.pop();
if (["Jr.", "Jr", "III", "II", "Sr.", "Sr"].includes(surname)) {
const actualSurname = parts.pop();
return `${actualSurname}, ${parts.join(' ')} ${surname}`;
}
return `${surname}, ${parts.join(' ')}`;
}
async function performDeorphan() {
const api = new mw.Api();
try {
await api.edit(mw.config.get('wgPageName'), function(rev) {
let text = rev.content;
const summary = 'Article has backlinks; removed [[Template:Orphan|{{Orphan}}]]';
text = text.replace(/\{\{Orphan\s*(?:\|[^}]*)?\}\}\s*/gi, '');
text = text.replace(/(\{\{Multiple[ _]issues\s*)\|\s*\|/gi, '$1|');
text = replaceMI(text);
text = text.replace(/\n{3,}/g, '\n\n').trim();
return {
text,
summary,
minor: true
};
});
mw.notify('Orphan tag removed!', {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
setTimeout(() => location.reload(), 700);
} catch (e) {
mw.notify('Failed to remove orphan tag.', {
type: 'error'
});
}
}
async function performPromotion(newClass) {
const api = new mw.Api();
const pageName = mw.config.get('wgPageName');
const talkName = 'Talk:' + pageName;
try {
const talkData = await api.get({
action: 'query',
prop: 'revisions',
titles: talkName,
rvprop: 'content',
formatversion: 2
});
let talkWikitext = talkData.query.pages[0].revisions[0].content;
talkWikitext = talkWikitext.replace(/(\|class\s*=\s*)Stub/i, `$1${newClass}`);
await api.postWithEditToken({
action: 'edit',
title: talkName,
text: talkWikitext,
summary: `Promoting article to ${newClass} class per ORES prediction [[WP:ORES]]`
});
const artData = await api.get({
action: 'query',
prop: 'revisions',
titles: pageName,
rvprop: 'content',
formatversion: 2
});
let artWikitext = artData.query.pages[0].revisions[0].content;
const stubRegex = /\{\{[^}]*?-stub\}\}\s*\n?/gi;
const genericStubRegex = /\{\{stub\}\}\s*/gi;
artWikitext = artWikitext.replace(stubRegex, '').replace(genericStubRegex, '');
await api.postWithEditToken({
action: 'edit',
title: pageName,
text: artWikitext,
summary: `Removing stub tags; article promoted to ${newClass}`
});
location.reload();
} catch (e) {
alert("Promotion failed. Check console for details.");
console.error(e);
}
}
function replaceMI(text) {
const miRegex = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miRegex.exec(text)) !== null) {
const start = match.index;
result += text.slice(lastIndex, start);
let depth = 0;
let i = start;
while (i < text.length) {
if (text[i] === '{' && text[i + 1] === '{') {
depth++;
i += 2;
} else if (text[i] === '}' && text[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
const end = i;
const full = text.slice(start, end);
const pipeIdx = full.indexOf('|');
const inner = full.slice(pipeIdx + 1, full.length - 2).trim();
const topLevel = extractTopLevelTemplates(inner);
if (topLevel.length === 0) {
result += '';
} else if (topLevel.length === 1) {
result += topLevel[0].trim();
} else {
result += full;
}
lastIndex = end;
}
result += text.slice(lastIndex);
return result;
}
function extractTopLevelTemplates(inner) {
const templates = [];
let i = 0;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
let depth = 0;
const start = i;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
depth++;
i += 2;
} else if (inner[i] === '}' && inner[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
templates.push(inner.slice(start, i));
} else {
i++;
}
}
return templates;
}
async function performSortCorrection(newName, type) {
const api = new mw.Api();
const title = mw.config.get("wgPageName");
try {
const data = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2
});
let content = data.query.pages[0].revisions[0].content;
const dsRegex = /\{\{(?:DEFAULTSORT|Defaultsort):[^}]+\}\}/i;
const newTag = `{{DEFAULTSORT:${newName}}}`;
let actionDesc = type === "add" ? "added" : "corrected";
if (dsRegex.test(content)) {
content = content.replace(dsRegex, newTag);
} else {
const catRegex = /\[\[Category:/i;
if (catRegex.test(content)) {
content = content.replace(catRegex, `${newTag}\n[[Category:`);
} else {
content += `\n\n${newTag}`;
}
}
await api.postWithEditToken({
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName}`,
nocreate: true
});
mw.notify(`DEFAULTSORT ${actionDesc} to ${newName}!`, {
title: 'Comrade Success',
type: 'success'
});
setTimeout(() => location.reload(), 700);
} catch (err) {
mw.notify('Failed to save DEFAULTSORT.', {
type: 'error'
});
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
function renderSortNudge(target, reason) {
const $dsNudge = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Correct to: ${target}`).on('click', () => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
async function fetchWikidataNameLabels(entity) {
const familyIds = entity.claims?.P734?.map(c => c.mainsnak.datavalue.value.id) || [];
const givenIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
const allNameIds = [...new Set([...familyIds, ...givenIds])];
if (allNameIds.length === 0) return {
givens: [],
surnames: []
};
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: allNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
});
const nameData = await fetch(url).then(r => r.json());
return {
givens: givenIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean),
surnames: familyIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean)
};
}
function fixNameCasing(part, referenceName) {
return part.split(' ').map(word => {
const match = referenceName.match(new RegExp(`\\b${word}\\b`, 'i'));
if (match) return match[0];
return referenceName.split(' ').find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase())) || word;
}).join(' ');
}
function getLongNameFromWikitext(wikitext) {
const firstLine = wikitext.split('\n').find(l => l.includes("'''"));
if (!firstLine) return null;
const boldMatch = firstLine.match(/'''(.+?)'''/);
if (!boldMatch) return null;
return boldMatch[1].replace(/\s*([“"«《„‹「『'].+?[”"»》”›」』']|\([^)\n]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
}
async function init() {
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") return;
const api = new mw.Api();
const qid = mw.config.get("wgWikibaseItemId");
try {
const [pageData, backlinkData] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
api.get({
action: 'query',
list: 'backlinks',
blfilterredir: 'nonredirects',
bllimit: 50,
blnamespace: 0,
bltitle: mw.config.get("wgPageName"),
formatversion: 2
})
]);
const page = pageData.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const currentTitle = mw.config.get("wgTitle");
const cleanTitle = stripDiacritics(currentTitle);
const isOrphanTagged = /\{\{\s*(Orphan|Do-attempt|Lonely|Orp|Orphaned article)/i.test(wikitext) || /\| *orphan *=/i.test(wikitext);
const backLinkCount = backlinkData.query.backlinks.length;
if (isOrphanTagged && backLinkCount >= 1) {
const $container = $('<div>').addClass('comrade-container comrade-status');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text('Remove orphan tag').on('click', () => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) await checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
await checkDomainRedirect(qid, currentTitle);
let isHuman = false;
let entity = null;
if (qid) {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
});
const wikiData = await fetch(url).then(r => r.json());
entity = wikiData.entities[qid];
isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
}
await checkNamingPrecision(currentTitle, isHuman);
if (qid && entity) {
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const nameParts = tClean.split(' ');
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
const isOrphan = isOrphanTagged && backLinkCount < 1;
const primarySurname = hasFamilyNameHatnote ? nameParts[0] : (getSmartSortKey(tClean, entity).split(',')[0] || nameParts[nameParts.length - 1]);
let targetSortName = "";
if (isHuman) {
const {
givens,
surnames
} = await fetchWikidataNameLabels(entity);
const refName = entity.labels?.en?.value || currentTitle;
if (dsMatch) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixNameCasing(lastPart, refName)}, ${fixNameCasing(firstPart, refName)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field.");
} else if (givens.length > 0 && lastPart.split(' ').length > 1) {
const misplacedGiven = lastPart.split(' ').find(part => givens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastPart.split(' ').filter(p => p !== misplacedGiven).join(' ');
targetSortName = `${fixNameCasing(newSurname, refName)}, ${fixNameCasing(misplacedGiven + ' ' + firstPart, refName)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
}
}
} else {
targetSortName = hasFamilyNameHatnote ? `${nameParts[0]}, ${nameParts.slice(1).join(' ')}` : getSmartSortKey(tClean, entity);
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).on('click', () => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
const longName = getLongNameFromWikitext(wikitext);
if (longName && longName.length > tClean.length) {
let sortKey = hasFamilyNameHatnote ? `${longName.split(' ')[0]}, ${longName.split(' ').slice(1).join(' ')}` : getSmartSortKey(longName, entity);
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
await checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
if (targetSortName) {
let targetPage = currentTitle.includes(' (') ? currentTitle.split(' (')[0] : currentTitle;
let rCat = targetPage !== currentTitle ? "R from ambiguous sort name" : "R from sort name";
if (targetSortName.includes(',')) {
const p = targetSortName.split(',');
rCat += `|${p[0].trim().charAt(0).toUpperCase()}|${p[1].trim().charAt(0).toUpperCase()}`;
}
await checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name");
}
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
surnames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
givens.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) finalGivenNames.add(gn);
});
if (finalFamilyNames.size === 0 && nameParts.length >= 2) finalFamilyNames.add(nameParts[nameParts.length - 1]);
if (finalGivenNames.size === 0 && nameParts.length >= 2) finalGivenNames.add(nameParts[0]);
const getPriorityTarget = async (name, type) => {
const titles = type === 'surname' ? [`List of people with surname ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`] : [`List of people with given name ${name}`, `${name} (given name)`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
return titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
};
for (const name of finalGivenNames) {
if (name.toLowerCase() === primarySurname.toLowerCase()) continue;
const target = await getPriorityTarget(name, 'given');
if (target) await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
for (const name of finalFamilyNames) {
const titles = [name, `${name} (surname)`, `List of people with surname ${name}`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
const target = titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
if (target) await checkNameList(name, 'surname', currentTitle, hasShortDesc, isOrphan, target);
}
}
const oresWait = setInterval(() => {
if ($('.article_quality').length) {
clearInterval(oresWait);
checkQualityConsistency();
}
}, 500);
setTimeout(() => clearInterval(oresWait), 5000);
}
} catch (err) {
console.error("Comrade error:", err);
}
}
init();
})();
(function() {
if (window.IllWill) return;
const STRIKEOUT = true;
const HIDE = 'hide';
const IllWill = {
Version: "1.1.1",
Attribution: 'created by <a target="_blank" href="/wiki/User:Cobaltcigs">cobaltcigs</a>',
Summary: "Added interlanguage links using IllWill.js",
DataApi: "https://www.wikidata.org/w/api.php",
LocalLang: mw.config.get("wgPageContentLanguage"),
Disambiguator: / \(.*\)$/,
SiteLinks: {},
Config: {
MaxLanguages: 5,
IgnoreScientific: STRIKEOUT,
AutoDiff: true
},
css: `
#illwill { clear: both; margin-bottom: 1.5em; border: 1px solid #a2a9b1; padding: 15px; background: #f8f9fa; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.ill-sitelinks a { display: inline-block; margin-bottom: 3px; font-size: 0.9em; color: #36c; }
.ill-size-tag { font-size: 0.8em; color: #72777d; margin-left: 6px; white-space: nowrap; }
.illwill-ignore { text-decoration: line-through; color: #72777d; font-size: 0.85em; }
.ill-x { width: 35px; text-align: center; }
.ill-input, .ill-output { font-family: 'Courier New', monospace; white-space: pre-wrap; font-size: 0.9em; background: #fff; border: 1px solid #eaecf0; padding: 4px; border-radius: 2px; }
#ill-buttons { text-align: right; padding: 10px; border-top: 1px solid #a2a9b1; margin-top: 10px; }
#ill-buttons button { margin-left: 10px; cursor: pointer; padding: 6px 16px; border-radius: 2px; border: 1px solid #a2a9b1; transition: all 0.2s; }
#ill-ok { background: #36c; color: #fff; border-color: #36c !important; font-weight: bold; }
#ill-ok:hover { background: #447ff5; }
#ill-ok:disabled { background: #eaecf0; color: #72777d; border-color: #c8ccd1 !important; cursor: not-allowed; opacity: 0.7; }
#ill-table { width: 100%; border-collapse: collapse; margin-top: 10px; background: white; }
#ill-table th { background: #f2f2f2; text-align: left; padding: 8px; border: 1px solid #a2a9b1; }
#ill-table td { padding: 8px; border: 1px solid #a2a9b1; vertical-align: top; }
#ill-table caption { background: #eaffea; font-weight: bold; padding: 10px; border: 1px solid #a2a9b1; border-bottom: none; font-size: 1.1em; }
#ill-help { font-size: 0.85em; color: #54595d; padding: 10px; line-height: 1.4; }
.ill-loading-inline { font-style: italic; color: #72777d; font-size: 0.9em; display: block; margin: 5px 0; }
`,
setup() {
if (!["edit", "submit"].includes(mw.config.get('wgAction'))) return;
mw.loader.addStyleTag(this.css);
const link = mw.util.addPortletLink('p-cactions', '#', 'IllWill', 'ca-illwill', "Add interlanguage links via Wikidata");
if (link) {
link.addEventListener('click', (e) => {
e.preventDefault();
this.makePanel();
});
}
},
norm(s) {
if (!s) return "";
const t = s.trim().replace(/[\s_]+/g, " ");
return t.charAt(0).toUpperCase() + t.slice(1);
},
async getRedLinks($textbox) {
const txt = $textbox.textSelection('getContents') || "";
const wl = txt.match(/\[\[[^[\]\n]+\]\]/g);
if (!wl) return {
reds: [],
txt
};
const api = new mw.Api();
const textToParse = wl.join("").replace(/\|[^\]]+/g, "");
try {
const data = await api.post({
action: 'parse',
text: textToParse,
contentmodel: 'wikitext',
formatversion: 2
});
const tempDiv = document.createElement('div');
tempDiv.innerHTML = data.parse.text;
const reds = Array.from(tempDiv.querySelectorAll("a.new")).map(x => {
const urlParams = new URLSearchParams(x.href.split('?')[1]);
return urlParams.get('title')?.replace(/_/g, " ");
}).filter(Boolean);
return {
reds: [...new Set(reds)],
txt
};
} catch (e) {
return {
reds: [],
txt
};
}
},
async makePanel() {
const $editform = $('#editform');
const $textbox = $('#wpTextbox1');
$('#illwill').remove();
$editform.hide();
const $wrapper = $('<div id="illwill">Loading redlinks...</div>').insertBefore($editform);
const {
reds,
txt
} = await this.getRedLinks($textbox);
const wtLinks = [];
reds.forEach(title => {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s/g, "[\\s_]+");
const re = new RegExp("\\[\\[\\s*" + escaped + "\\s*(?:\\|[^\\[\\]]+)?\\]\\]", "ig");
const match = txt.match(re);
if (match && !wtLinks.some(z => z.title === title)) {
wtLinks.push({
link: match[0],
title
});
}
});
if (!wtLinks.length) {
const $back = $('<button>Quit</button>').on('click', () => {
$wrapper.remove();
$editform.show();
});
$wrapper.empty().append($('<strong>No valid redlinks found in text.</strong> '), $back);
return;
}
const $table = $(`
<table id="ill-table" class="wikitable">
<caption>IllWill.js (v${this.Version})</caption>
<thead>
<tr>
<th class="ill-x"><input type="checkbox" id="ill-select-all" disabled></th>
<th>Input link</th>
<th>Possible Wikidata matches</th>
<th>Sitelinks (top ${this.Config.MaxLanguages} by size)</th>
<th>Resulting wikitext</th>
</tr>
</thead>
<tbody></tbody>
<tfoot>
<tr>
<td colspan="3" id="ill-help">Carefully examine possible Wikidata matches.</td>
<td colspan="2" id="ill-buttons">
<button id="ill-cancel">Cancel</button>
<button id="ill-ok" disabled>Show changes</button>
</td>
</tr>
</tfoot>
</table>
`);
const $tbody = $table.find('tbody');
wtLinks.forEach((item, i) => {
$tbody.append(`
<tr id="ill-row-${i}" data-index="${i}">
<td class="ill-x"><input type="checkbox" class="ill-row-check" disabled></td>
<td class="ill-input">${this.escapeHTML(item.link)}</td>
<td class="ill-wd-item"><span class="ill-loading-inline">Searching...</span></td>
<td class="ill-sitelinks"></td>
<td class="ill-output"></td>
</tr>
`);
});
$wrapper.empty().append($table);
const chunkSize = 5;
for (let i = 0; i < wtLinks.length; i += chunkSize) {
const chunk = wtLinks.slice(i, i + chunkSize);
await Promise.all(chunk.map((item, idx) => this.fetchWikidataOptimized(item.title, i + idx)));
}
$table.on('click', '#ill-cancel', () => {
$editform.show();
$wrapper.remove();
});
$table.on('click', '#ill-ok', () => this.onOkButton($editform, $wrapper, $textbox));
$table.on('change', '#ill-select-all', (e) => {
const isChecked = $(e.target).is(':checked');
$table.find('.ill-row-check:not(:disabled)').each(function() {
$(this).prop('checked', isChecked).trigger('change');
});
});
$table.on('change', '.ill-row-check', (e) => {
this.onCheckbox(e);
this.updateOkButtonState($table);
});
$table.on('change', 'input[type="radio"]', (e) => {
this.showSiteLinks(e);
});
},
async fetchWikidataOptimized(title, index) {
const query = title.replace(this.Disambiguator, "");
const url = `${this.DataApi}?action=wbsearchentities&search=${encodeURIComponent(query)}&language=${this.LocalLang}&limit=5&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
const results = data.search;
const $cell = $(`#ill-row-${index} .ill-wd-item`);
if (!results || !results.length) {
$cell.html('<span class="ill-na">(no results)</span>');
return;
}
const entityIds = results.map(r => r.id);
await this.fetchEntities(entityIds, index, $cell);
} catch (e) {
$(`#ill-row-${index} .ill-wd-item`).text("Search failed.");
}
},
async fetchEntities(ids, rowIndex, $cell) {
const url = `${this.DataApi}?action=wbgetentities&ids=${ids.join('|')}&props=labels|descriptions|claims|sitelinks&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
$cell.empty();
ids.forEach(qNum => {
const entity = data.entities[qNum];
if (!entity) return;
const isScientific = entity.claims?.P31?.some(s => s.mainsnak?.datavalue?.value?.id === "Q13442814");
if (isScientific && this.Config.IgnoreScientific === HIDE) return;
const desc = entity.descriptions?.[this.LocalLang]?.value || entity.descriptions?.en?.value || "";
const label = entity.labels?.[this.LocalLang]?.value || entity.labels?.en?.value || qNum;
const isStrikeout = isScientific && this.Config.IgnoreScientific === STRIKEOUT;
const $div = $(`
<div class="${isStrikeout ? 'illwill-ignore' : ''}">
<input type="radio" name="selection-${rowIndex}" value="${qNum}" id="radio-${qNum}" ${isStrikeout ? 'disabled' : ''}>
<label for="radio-${qNum}">
<a href="https://wikidata.org/wiki/${qNum}" target="_blank">${this.escapeHTML(label)}</a>:
<span class="ill-desc">${desc ? this.escapeHTML(desc) : '<i>no description</i>'}</span>
</label>
</div>
`);
$cell.append($div);
this.SiteLinks[qNum] = entity.sitelinks || {};
});
} catch (e) {
$cell.text("Detail fetch failed.");
}
},
updateOkButtonState($table) {
const $rows = $table.find('.ill-row-check:not(:disabled)');
const $checkedRows = $rows.filter(':checked');
const anyEnabled = $rows.length > 0;
$table.find('#ill-ok').prop('disabled', $checkedRows.length === 0);
const $master = $table.find('#ill-select-all');
$master.prop('disabled', !anyEnabled);
if (anyEnabled && $rows.length === $checkedRows.length) {
$master.prop('checked', true);
} else {
$master.prop('checked', false);
}
},
async showSiteLinks(e) {
const isManualToggling = e.fromCheckbox || false;
const qNum = e.target.value;
const $row = $(e.target).closest('tr');
const sitelinks = this.SiteLinks[qNum];
const $checkbox = $row.find('.ill-row-check');
const $sitelinkCell = $row.find('.ill-sitelinks');
const $outputCell = $row.find('.ill-output');
const $table = $('#ill-table');
const effectiveMax = Math.min(Math.max(this.Config.MaxLanguages, 3), 6);
const validKeys = Object.keys(sitelinks).filter(k => k.endsWith('wiki') && !k.includes('commons'));
$sitelinkCell.html('<span class="ill-loading-inline">Measuring articles...</span>');
$outputCell.empty();
if (!isManualToggling) {
$checkbox.prop('disabled', true).prop('checked', false);
}
const candidates = validKeys.slice(0, 25);
const sizeMap = [];
await Promise.all(candidates.map(async (key) => {
const lang = key.replace('wiki', '').replace(/_/g, '-');
const title = sitelinks[key].title;
const endpoint = `https://${lang}.wikipedia.org/w/api.php?action=query&titles=${encodeURIComponent(title)}&prop=info&format=json&origin=*`;
try {
const res = await fetch(endpoint).then(r => r.json());
const page = Object.values(res.query.pages)[0];
sizeMap.push({
lang,
title,
length: page.length || 0
});
} catch (err) {
sizeMap.push({
lang,
title,
length: 0
});
}
}));
sizeMap.sort((a, b) => b.length - a.length);
const topLinks = sizeMap.slice(0, effectiveMax);
$sitelinkCell.html(topLinks.map(item => {
const kbValue = Math.round(item.length / 1024);
return `<div><a href="https://${item.lang}.wikipedia.org/wiki/${encodeURIComponent(item.title)}" target="_blank">${item.lang}:${this.escapeHTML(item.title)}</a> <span class="ill-size-tag">(${kbValue} kB)</span></div>`;
}).join(""));
const inputWikitext = $row.find('.ill-input').text();
const match = inputWikitext.match(/\[\[(.*?)\]\]/);
if (match) {
const parts = match[1].split('|');
const targetPage = parts[0].trim();
const surfaceName = (parts[1] || parts[0]).trim();
const illLangs = topLinks.map(item => {
const isSameTitle = this.norm(item.title) === this.norm(targetPage);
return `|${item.lang}|${isSameTitle ? '' : item.title}`;
}).join("");
const ltParam = (this.norm(surfaceName) === this.norm(targetPage)) ? "" : `|lt=${surfaceName}`;
$outputCell.text(`{{ill|${targetPage}${ltParam}${illLangs}}}`);
}
$checkbox.prop('disabled', !topLinks.length);
if (!isManualToggling) {
$checkbox.prop('checked', !!topLinks.length);
}
this.updateOkButtonState($table);
},
onCheckbox(e) {
const $row = $(e.target).closest('tr');
const $radio = $row.find('input[type="radio"]:checked');
const isChecked = $(e.target).is(':checked');
if (isChecked) {
if ($radio.length) {
this.showSiteLinks({
target: $radio[0],
fromCheckbox: true
});
}
} else {
$row.find('.ill-sitelinks, .ill-output').empty();
}
},
onOkButton($editform, $wrapper, $textbox) {
let text = $textbox.textSelection('getContents');
let changed = false;
$wrapper.find('.ill-row-check:checked').each(function() {
const $row = $(this).closest('tr');
const input = $row.find('.ill-input').text();
const output = $row.find('.ill-output').text();
if (output) {
const escapedInput = input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
text = text.replace(new RegExp(escapedInput, "gi"), output);
changed = true;
}
});
if (changed) {
$('#wpSummary').val((i, v) => (v ? v + "; " : "") + this.Summary);
$textbox.textSelection('setContents', text);
$wrapper.remove();
$editform.show();
if (this.Config.AutoDiff) $('#wpDiff').click();
}
},
escapeHTML(str) {
return str.replace(/[&<>"']/g, m => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
})[m]);
}
};
window.IllWill = IllWill;
mw.loader.using(['mediawiki.util', 'mediawiki.api', 'jquery.textSelection'], () => {
$(() => IllWill.setup());
});
})();
(function() {
'use strict';
const config = {
enableNS14: true,
rootWhitelists: ['Materials'],
userWhitelist: ""
};
const categoryDataCache = {};
let scanProgress = 0;
let totalToScan = 0;
async function verifyTreeMembership() {
if (!config.enableNS14) return false;
const api = new mw.Api();
const currentTitle = mw.config.get("wgPageName").replace(/_/g, ' ');
const fullRoots = config.rootWhitelists.concat(config.userWhitelist.split(',').map(s => s.trim()).filter(s => s)).map(cat => (cat.startsWith('Category:') ? cat : `Category:${cat}`));
if (fullRoots.includes(currentTitle)) return true;
let categoriesToTable = mw.config.get('wgCategories') || [];
let checked = new Set();
while (categoriesToTable.length > 0) {
if (categoriesToTable.some(cat => fullRoots.includes('Category:' + cat))) {
return true;
}
categoriesToTable.forEach(cat => checked.add(cat));
try {
const res = await api.get({
formatversion: 2,
prop: 'categories',
titles: categoriesToTable.map(c => 'Category:' + c),
cllimit: 'max'
});
let nextBatch = [];
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.categories) {
page.categories.forEach(parent => {
const parentName = parent.title.substring('Category:'.length);
if (!checked.has(parentName)) {
nextBatch.push(parentName);
}
});
}
});
}
categoriesToTable = [...new Set(nextBatch)];
} catch (e) {
return false;
}
}
return false;
}
function getChemboxBlock(wikitext) {
const chemboxRegex = /\{\{\s*Chembox[\s_]*(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const match = wikitext.match(chemboxRegex);
return match ? match[0] : null;
}
function getFormulaFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const formulaMatch = chembox.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
let rawFormula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
rawFormula = rawFormula.split(/\s+/)[0];
return rawFormula.split(',')[0].trim();
}
const propRegex = /\{\{\s*Chembox[\s_]+Properties(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const propSection = chembox.match(propRegex);
if (propSection) {
const content = propSection[0];
const allElements = ['Ac', 'Ag', 'Al', 'Am', 'Ar', 'As', 'At', 'Au', 'B', 'Ba', 'Be', 'Bh', 'Bi', 'Bk', 'Br', 'C', 'Ca', 'Cd', 'Ce', 'Cf', 'Cl', 'Cm', 'Cn', 'Co', 'Cr', 'Cs', 'Cu', 'D', 'Db', 'Ds', 'Dy', 'Er', 'Es', 'Eu', 'F', 'Fe', 'Fl', 'Fm', 'Fr', 'Ga', 'Gd', 'Ge', 'H', 'He', 'Hf', 'Hg', 'Ho', 'Hs', 'I', 'In', 'Ir', 'K', 'Kr', 'La', 'Li', 'Lr', 'Lu', 'Lv', 'Mc', 'Md', 'Mg', 'Mn', 'Mo', 'Mt', 'N', 'Na', 'Nb', 'Nd', 'Ne', 'Nh', 'Ni', 'No', 'Np', 'O', 'Og', 'Os', 'P', 'Pa', 'Pb', 'Pd', 'Pm', 'Po', 'Pr', 'Pt', 'Pu', 'Ra', 'Rb', 'Re', 'Rf', 'Rg', 'Rh', 'Rn', 'Ru', 'S', 'Sb', 'Sc', 'Se', 'Sg', 'Si', 'Sm', 'Sn', 'Sr', 'Ta', 'Tb', 'Tc', 'Te', 'Th', 'Ti', 'Tl', 'Tm', 'Ts', 'U', 'V', 'W', 'Xe', 'Y', 'Yb', 'Zn', 'Zr'];
let data = {};
allElements.forEach(el => {
const elRegex = new RegExp(`\\|\\s*${el}\\s*=\\s*(\\d*\\.?\\d+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch && elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
});
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
elementsPresent.filter(el => !(hasC && (el === "C" || el === "H"))).sort().forEach(el => {
formulaArray.push(el + data[el]);
});
return formulaArray.join('');
}
}
return null;
}
function getCASFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const idRegex = /\{\{\s*Chembox[\s_]+Identifiers(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const idSection = chembox.match(idRegex);
if (!idSection) return null;
const content = idSection[0];
const nbsp = String.fromCharCode(160);
const casMatch = content.match(/\|\s*CASNo\d*\b(?!\s*_)\s*=\s*([^|}\n]+)/i);
if (casMatch) {
let val = casMatch[1].replace(/\{\{[^}]+\}\}/g, "").replace(/\u00AD/g, "").split(" ").join("");
while (val.indexOf(nbsp) !== -1) val = val.replace(nbsp, "");
while (val.indexOf(" ") !== -1) val = val.replace(" ", "");
const cleanedCas = val.trim();
return /^\d{2,7}-\d{2}-\d$/.test(cleanedCas) ? cleanedCas : null;
}
return null;
}
async function getWikidataValue() {
const qid = mw.config.get('wgWikidataItemId');
if (!qid) return null;
const api = new mw.ForeignApi('https://www.wikidata.org/w/api.php');
const res = await api.get({
action: 'wbgetentities',
ids: qid,
props: 'claims',
format: 'json',
formatversion: 2
});
const claims = res.entities[qid].claims;
const data = {};
if (claims.P274) data.formula = claims.P274[0].mainsnak.datavalue.value;
if (claims.P231) data.cas = claims.P231[0].mainsnak.datavalue.value;
return data;
}
async function startBackgroundCategoryScan() {
const api = new mw.Api();
const titles = $('#mw-pages li a').map((i, el) => $(el).attr('title') || $(el).text()).get();
totalToScan = titles.length;
if (totalToScan === 0) return;
const chunks = arrayChunk(titles, 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: chunk,
rvprop: 'content',
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const f = getFormulaFromWikitext(wikitext);
const c = getCASFromWikitext(wikitext);
if (f || c) categoryDataCache[page.title] = {
formula: f,
cas: c
};
});
}
scanProgress += chunk.length;
$('#caschemassist-cat-btn').text(`Scanning... ${scanProgress}/${totalToScan}`);
}
$('#caschemassist-cat-btn').text('Show missing CAS/Chem').prop('disabled', false);
}
async function renderCategoryAudit() {
const api = new mw.Api();
const missingCheckTitles = [];
Object.values(categoryDataCache).forEach(data => {
if (data.formula) missingCheckTitles.push(data.formula);
if (data.cas) missingCheckTitles.push(data.cas);
});
if (missingCheckTitles.length === 0) {
mw.notify("No valid Chembox data found in this category.");
return;
}
const existenceMap = {};
const chunks = arrayChunk([...new Set(missingCheckTitles)], 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
titles: chunk,
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(p => {
existenceMap[p.title] = !p.missing;
});
}
}
$('#mw-pages li').each(function() {
const title = $(this).find('a').attr('title') || $(this).find('a').text().trim();
const data = categoryDataCache[title];
if (!data) return;
if (data.formula && !existenceMap[data.formula]) {
$(this).append($('<span>').css({
'color': 'red',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [${data.formula}]`));
}
if (data.cas && !existenceMap[data.cas]) {
$(this).append($('<span>').css({
'color': 'darkred',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [CAS: ${data.cas}]`));
}
});
$('#caschemassist-cat-btn').text('Audit complete').prop('disabled', true);
}
async function handleSingleArticle() {
const api = new mw.Api();
const currentTitle = mw.config.get("wgTitle");
const [wikiRes, wikidata] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
getWikidataValue()
]);
const page = wikiRes.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const formula = getFormulaFromWikitext(wikitext);
const cas = getCASFromWikitext(wikitext);
if (wikidata) {
let mismatches = [];
if (formula && wikidata.formula && formula !== wikidata.formula) {
mismatches.push(`Formula mismatch: Chembox "${formula}" vs Wikidata "${wikidata.formula}"`);
}
if (cas && wikidata.cas && cas !== wikidata.cas) {
mismatches.push(`CAS mismatch: Chembox "${cas}" vs Wikidata "${wikidata.cas}"`);
}
if (mismatches.length > 0) {
const $warning = $('<div>').css({
'background': '#fff2f2',
'border': '2px solid #d33',
'padding': '10px',
'margin-bottom': '1em',
'color': '#b32424'
}).append($('<strong>').text('⚠️ Data Mismatch Detected: '), mismatches.join(' | '));
$('#mw-content-text').prepend($warning);
}
}
async function checkAndRender(target, label, customWikitext) {
const check = await api.get({
action: 'query',
titles: target,
formatversion: 2
});
if (check.query.pages[0].missing) {
const $container = $('<div>').css({
'background': '#f8f9fa',
'border': '1px solid #a2a9b1',
'padding': '10px',
'margin-bottom': '1em'
});
const $btn = $('<button>').text(`Create redirect: ${target}`).click(async function() {
await api.postWithEditToken({
action: 'edit',
title: target,
text: customWikitext,
summary: `Creating redirect to [[${currentTitle}]] (${label})`,
createonly: true
});
mw.notify(`Created redirect: ${target}`);
$container.fadeOut(400, function() {
$(this).remove();
});
});
$container.append($('<strong>').text(`${label}: `), `Missing redirect. `, $btn);
$('#mw-content-text').prepend($container);
}
}
if (formula && formula !== currentTitle) {
const formulaWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from chemical formula}}\n}}`;
await checkAndRender(formula, "Chemical formula", formulaWikitext);
}
if (cas && cas !== currentTitle) {
const casWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from CAS Registry Number}}\n{{R unprintworthy}}\n}}`;
await checkAndRender(cas, "CAS number", casWikitext);
}
}
function arrayChunk(arr, size) {
const result = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
$(document).ready(async function() {
const ns = mw.config.get("wgNamespaceNumber");
if (ns === 14) {
if (await verifyTreeMembership()) {
const $catContainer = $('#mw-pages');
if ($catContainer.length) {
const $btn = $('<button>').text('Initializing scan...').attr('id', 'caschemassist-cat-btn').addClass('mw-ui-button').css({
'margin': '0 10px 10px 0',
'float': 'right',
'position': 'relative',
'z-index': '100'
}).prop('disabled', true).click(renderCategoryAudit);
$catContainer.prepend($btn);
startBackgroundCategoryScan();
}
}
} else if (ns === 0 && mw.config.get("wgAction") === "view") {
handleSingleArticle();
}
});
})();
//</nowiki>
7w8ducjwl07fpckfotgqcet62pyyhkt
739307
739306
2026-04-25T08:26:52Z
Sam Sailor
26820
Test
739307
javascript
text/javascript
//<nowiki>
(function() {
mw.util.addCSS(`
.ambox-Orphan { display: block !important; }
.comrade-container {
box-shadow: 0 1px 3px rgba(0,0,0,0.1); border-radius: 2px;
padding: 10px 15px; margin: 10px 0; display: flex;
flex-direction: column; align-items: flex-start; gap: 8px;
border: 1px solid #a2a9b1; background: #fcfcfc; line-height: 1.5;
}
.comrade-header { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.comrade-content { display: flex; flex-direction: column; gap: 5px; width: 100%; }
.comrade-success { background-color: #d5f5e3 !important; border-left: 5px solid #27ae60 !important; color: #1b5e20 !important; }
.comrade-nudge { background: #fff9ea; border-left: 5px solid #f0ad4e; }
.comrade-info { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-status { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-container button:not(.comrade-dismiss) {
background: #f8f9fa; border: 1px solid #a2a9b1; border-radius: 2px;
padding: 2px 10px; cursor: pointer; font-weight: bold; font-size: 0.95em; margin-left: 10px;
}
.comrade-container button:not(.comrade-dismiss):hover { border-color: #36c; color: #36c; background: #fff; }
.comrade-dismiss {
background: transparent; border: none; color: #d33; font-weight: bold;
cursor: pointer; font-size: 0.9em;
}
.comrade-dismiss:hover { text-decoration: underline; }
.comrade-code-block { background: #f1f1f1; padding: 4px 8px; border-radius: 3px; font-family: monospace; display: inline-block; margin-top: 4px; }
`);
function appendDismiss($el) {
$('<button>').addClass('comrade-dismiss').text('Dismiss').on('click', function() {
$(this).closest('.comrade-container').fadeOut();
}).appendTo($el.find('.comrade-header'));
$("#siteSub").after($el);
}
async function checkNamingPrecision(currentTitle, isHuman) {
const disambigMatch = currentTitle.match(/^(.+?)(?:, | \()/);
if (isHuman || !disambigMatch) return;
const baseTitle = disambigMatch[1].trim();
const api = new mw.Api();
try {
const baseRes = await api.get({
action: 'query',
titles: baseTitle,
redirects: 1,
formatversion: 2
});
const page = baseRes.query.pages[0];
const baseExists = !page.missing;
let baseRedirectsToSelf = false;
if (baseRes.query.redirects) {
baseRedirectsToSelf = baseRes.query.redirects.some(r => r.to === currentTitle);
}
const searchRes = await api.get({
action: 'query',
list: 'allpages',
apprefix: baseTitle,
aplimit: 50,
formatversion: 2
});
const competitors = searchRes.query.allpages.filter(p => {
const t = p.title;
return t !== currentTitle && t !== baseTitle && (t.startsWith(baseTitle + ', ') || t.startsWith(baseTitle + ' ('));
});
if (!baseExists || baseRedirectsToSelf) {
const $container = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (competitors.length === 0) {
$header.append($('<strong>').text('Precision:'));
const reason = baseRedirectsToSelf ? "already redirects here" : "is free";
$content.append($('<span>').append(`The base name `, $('<code>').text(baseTitle), ` ${reason}. Consider moving this article.`));
const moveUrl = mw.util.getUrl('Special:MovePage/' + currentTitle, {
wpNewTitle: baseTitle,
wpReason: 'Unnecessary disambiguation; base name is free/redirects here per [[WP:PRECISION]]'
});
$content.append($('<button>').text(`Move to ${baseTitle}`).on('click', () => window.open(moveUrl, '_blank')));
} else {
$header.append($('<strong>').text('Observation:'));
const state = baseRedirectsToSelf ? 'redirect to this page' : 'redlink';
$content.append($('<span>').append(`Base name `, $('<code>').text(baseTitle), ` has ${competitors.length} competitors, but it is currently a ${state}.`));
$content.append($('<span>').text('Consider if a disambiguation page is needed.'));
}
$container.append($header, $content);
appendDismiss($container);
}
} catch (e) {
console.warn("Comrade: Precision check failed", e);
}
}
async function checkAndRenderRedirect(redirTitle, targetTitle, rCategory, labelText, customWikitext) {
const api = new mw.Api();
const res = await api.get({
action: 'query',
titles: redirTitle,
formatversion: 2
});
if (res.query.pages[0].missing) {
const safeId = "comrade-redir-" + redirTitle.replace(/[^a-z0-9]/gi, '-');
const $container = $('<div>').addClass('comrade-container comrade-info').attr('id', safeId);
const $header = $('<div>').addClass('comrade-header');
const summary = `Created redirect from ${labelText.toLowerCase()} to [[${targetTitle}]]`;
const $btn = $('<button>').text('Create redirect').on('click', () => createModernRedirect(redirTitle, targetTitle, rCategory, summary, safeId, customWikitext));
$header.append($('<div>').append($('<strong>').text(`${labelText}: `), `Create redirect from `, $('<code>').text(redirTitle), $btn));
$container.append($header);
appendDismiss($container);
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
if (qid) {
try {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
});
const res = await fetch(url).then(r => r.json());
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
}
} catch (e) {
console.warn("Comrade: Wikidata API call failed.");
}
}
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
if (domain.includes(".") && url.pathname === "/") {
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
try {
const response = await fetch(citationURL);
if (response.ok) {
await checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
}
} catch (e) {
console.warn("Comrade: Citation API check failed.");
}
}
} catch (e) {
console.warn("Comrade: Error parsing URL object.");
}
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan, resolvedTitle) {
const api = new mw.Api();
const candidates = resolvedTitle ? [resolvedTitle] : [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
redirects: 0
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const foundTitle = page.title;
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) break;
const $container = $('<div>').addClass('comrade-container');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (isOrphan) {
$container.addClass('comrade-nudge');
$header.append($('<strong>').text('Comrade nudge:'));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
}
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(foundTitle),
target: '_blank'
}).text(foundTitle), "; should it be?"));
const snippet = '* {{anbl|' + currentTitle + '}}';
const $snippetWrapper = $('<div>').css({
'display': 'flex',
'align-items': 'center',
'gap': '10px',
'margin-top': '4px'
});
const $instructionSpan = $('<span>').append('If the Short description is good, consider adding ', $('<code>').text(snippet).css({
'background-color': '#f8f9fa',
'border': '1px solid #eaecf0',
'border-radius': '2px',
'padding': '1px 4px'
}));
const $copyBtn = $('<button>').text('Copy & insert').css('margin-left', '0').on('click', function() {
navigator.clipboard.writeText(snippet).then(() => {
const $this = $(this);
const originalText = $this.text();
$this.text('Copied!').prop('disabled', true);
const editUrl = mw.util.getUrl(foundTitle, {
action: 'edit',
summary: snippet
});
window.open(editUrl, '_blank');
setTimeout(() => {
$this.text(originalText).prop('disabled', false);
}, 1500);
});
});
$snippetWrapper.append($instructionSpan, $copyBtn);
$content.append($snippetWrapper);
if (!hasShortDesc) {
$content.append($('<span>').css({
'color': '#d33',
'font-weight': 'bold',
'margin-top': '5px'
}).text('⚠️ Missing short description: Please add a concise one before listing.'));
}
$container.append($header, $content);
appendDismiss($container);
break;
}
}
}
}
async function checkQualityConsistency() {
const oresClass = getOresPrediction();
if (!oresClass || oresClass === 'Stub') return;
const talkTitle = 'Talk:' + mw.config.get('wgPageName');
const api = new mw.Api();
try {
const [talkRes, pageRes] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: talkTitle,
rvprop: 'content',
formatversion: 2
}),
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
})
]);
const talkPage = talkRes.query.pages[0];
const wikitext = pageRes.query.pages[0].revisions[0].content;
const hasStubTag = /\{\{[^}]*stub\}\}/i.test(wikitext);
const $raterLink = $('#ca-rater, #t-rater');
const isRaterAvailable = (typeof window.rater !== 'undefined' || $raterLink.length > 0);
let talkClass = null;
if (!talkPage.missing) {
const classMatch = talkPage.revisions[0].content.match(/\|class\s*=\s*([^|}\s]+)/i);
talkClass = classMatch ? classMatch[1].charAt(0).toUpperCase() + classMatch[1].slice(1).toLowerCase() : null;
}
const isStubRated = talkClass === 'Stub';
const lingeringStubTag = (talkClass && talkClass !== 'Stub' && oresClass !== 'Stub' && hasStubTag);
if (talkPage.missing || isStubRated || lingeringStubTag) {
const $container = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').append($('<strong>').text('Quality:'), " ");
let message = `ORES suggests <b>${oresClass}</b>. `;
if (talkPage.missing) {
message = `Talk page missing; ORES suggests <b>${oresClass}</b>. `;
let $btn;
if (isRaterAvailable) {
$btn = $('<button>').text('Open Rater').on('click', () => $raterLink.find('a')[0].click());
} else {
$btn = $('<button>').text('Install Rater').on('click', () => window.open('https://en.wikipedia.org/wiki/User:Evad37/rater', '_blank'));
message += "Consider installing <i>Rater</i> for easy assessment.";
}
$content.append(message, $btn);
} else if (lingeringStubTag && !isStubRated) {
message = `Article is ${talkClass}-class, but stub tags remain. `;
const $btn = $('<button>').text('Remove stub tags').on('click', () => performStubRemoval());
$content.append(message, $btn);
} else if (isStubRated) {
message = `Talk page says Stub, but ORES predicts <b>${oresClass}</b>. `;
const $btnOres = $('<button>').text(`Promote to ${oresClass}`).on('click', () => performPromotion(oresClass));
$content.append(message, $btnOres);
if (oresClass === 'C') {
const $btnStart = $('<button>').text(`Promote to Start`).on('click', () => performPromotion('Start'));
$content.append("\u00A0\u00A0\u00A0or\u00A0", $btnStart);
} else if (oresClass === 'B') {
const $btnC = $('<button>').text(`Promote to C`).on('click', () => performPromotion('C'));
$content.append("\u00A0\u00A0\u00A0or\u00A0", $btnC);
}
}
$header.append($content);
$container.append($header);
appendDismiss($container);
}
} catch (e) {
console.error("Comrade quality check failed", e);
}
}
async function performStubRemoval() {
const api = new mw.Api();
try {
const pageData = await api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
});
let wikitext = pageData.query.pages[0].revisions[0].content;
const newWikitext = wikitext.replace(/\{\{[^}]*stub\}\}\n?/gi, '').trim();
if (wikitext === newWikitext) {
mw.notify("No stub tags found to remove.");
return;
}
await api.postWithToken('csrf', {
action: 'edit',
title: mw.config.get("wgPageName"),
text: newWikitext,
summary: `Removing lingering stub tags per ORES/Talk assessment`,
nocreate: true
});
location.reload();
} catch (err) {
console.error("Comrade failed to remove stub tags:", err);
mw.notify("Error removing stub tags.", {
type: 'error'
});
}
}
async function createModernRedirect(redirTitle, targetTitle, rCategory, customSummary, elementId, customWikitext) {
const api = new mw.Api();
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
try {
await api.postWithEditToken({
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
});
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
} catch (e) {
mw.notify("Failed to create redirect.", {
type: 'error'
});
}
}
function getOresPrediction() {
// Dependency: [[m:User:EpochFail/ArticleQuality-system.js]] renders the prediction inside .article_quality
const text = $('.article_quality').text();
if (!text) return null;
if (text.includes('Start')) return 'Start';
if (text.includes('C')) return 'C';
if (text.includes('B')) return 'B';
return null;
}
function getSmartSortKey(fullName, entity) {
const countryIds = entity.claims?.P27?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const locationIds = entity.claims?.P17?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const placeIds = [...(entity.claims?.P19 || []), ...(entity.claims?.P119 || [])].map(c => c.mainsnak?.datavalue?.value?.id).filter(Boolean);
const allRelevantIds = [...countryIds, ...locationIds, ...placeIds];
const birthYear = parseInt(entity.claims?.P569?.[0]?.mainsnak?.datavalue?.value?.time?.match(/[+-](\d{4})/)?.[1]) || null;
if (fullName.startsWith("Saint ")) return fullName.replace("Saint ", "");
const arabicParticles = /\b(Abu|Abd|Abdel|Abdul|ben|bin|bint)\b/i;
if (fullName.match(arabicParticles)) {
if (birthYear && birthYear > 1900) {
const parts = fullName.split(' ');
const binIndex = parts.findIndex(p => p.toLowerCase() === 'bin' || p.toLowerCase() === 'ben');
if (binIndex > 0) return parts.slice(binIndex).join(' ') + ', ' + parts.slice(0, binIndex).join(' ');
} else {
return fullName;
}
}
const eastAsianQids = ["Q148", "Q184", "Q884", "Q424", "Q881", "Q17"];
if (allRelevantIds.some(id => eastAsianQids.includes(id))) {
if (allRelevantIds.includes("Q17") && birthYear && birthYear > 1885) return westernSort(fullName);
const parts = fullName.split(' ');
if (parts.length > 1) return `${parts[0]}, ${parts.slice(1).join(' ')}`;
}
return westernSort(fullName);
}
function westernSort(name) {
let cleanName = name;
if (name.startsWith("O'")) cleanName = name.replace("O'", "O");
const parts = cleanName.split(' ');
if (parts.length < 2) return cleanName;
const surname = parts.pop();
if (["Jr.", "Jr", "III", "II", "Sr.", "Sr"].includes(surname)) {
const actualSurname = parts.pop();
return `${actualSurname}, ${parts.join(' ')} ${surname}`;
}
return `${surname}, ${parts.join(' ')}`;
}
async function performDeorphan() {
const api = new mw.Api();
try {
await api.edit(mw.config.get('wgPageName'), function(rev) {
let text = rev.content;
const summary = 'Article has backlinks; removed [[Template:Orphan|{{Orphan}}]]';
text = text.replace(/\{\{Orphan\s*(?:\|[^}]*)?\}\}\s*/gi, '');
text = text.replace(/(\{\{Multiple[ _]issues\s*)\|\s*\|/gi, '$1|');
text = replaceMI(text);
text = text.replace(/\n{3,}/g, '\n\n').trim();
return {
text,
summary,
minor: true
};
});
mw.notify('Orphan tag removed!', {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
setTimeout(() => location.reload(), 700);
} catch (e) {
mw.notify('Failed to remove orphan tag.', {
type: 'error'
});
}
}
async function performPromotion(newClass) {
const api = new mw.Api();
const pageName = mw.config.get('wgPageName');
const talkName = 'Talk:' + pageName;
try {
const talkData = await api.get({
action: 'query',
prop: 'revisions',
titles: talkName,
rvprop: 'content',
formatversion: 2
});
let talkWikitext = talkData.query.pages[0].revisions[0].content;
talkWikitext = talkWikitext.replace(/(\|class\s*=\s*)Stub/i, `$1${newClass}`);
await api.postWithEditToken({
action: 'edit',
title: talkName,
text: talkWikitext,
summary: `Promoting article to ${newClass} class per ORES prediction [[WP:ORES]]`
});
const artData = await api.get({
action: 'query',
prop: 'revisions',
titles: pageName,
rvprop: 'content',
formatversion: 2
});
let artWikitext = artData.query.pages[0].revisions[0].content;
const stubRegex = /\{\{[^}]*?-stub\}\}\s*\n?/gi;
const genericStubRegex = /\{\{stub\}\}\s*/gi;
artWikitext = artWikitext.replace(stubRegex, '').replace(genericStubRegex, '');
await api.postWithEditToken({
action: 'edit',
title: pageName,
text: artWikitext,
summary: `Removing stub tags; article promoted to ${newClass}`
});
location.reload();
} catch (e) {
alert("Promotion failed. Check console for details.");
console.error(e);
}
}
function replaceMI(text) {
const miRegex = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miRegex.exec(text)) !== null) {
const start = match.index;
result += text.slice(lastIndex, start);
let depth = 0;
let i = start;
while (i < text.length) {
if (text[i] === '{' && text[i + 1] === '{') {
depth++;
i += 2;
} else if (text[i] === '}' && text[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
const end = i;
const full = text.slice(start, end);
const pipeIdx = full.indexOf('|');
const inner = full.slice(pipeIdx + 1, full.length - 2).trim();
const topLevel = extractTopLevelTemplates(inner);
if (topLevel.length === 0) {
result += '';
} else if (topLevel.length === 1) {
result += topLevel[0].trim();
} else {
result += full;
}
lastIndex = end;
}
result += text.slice(lastIndex);
return result;
}
function extractTopLevelTemplates(inner) {
const templates = [];
let i = 0;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
let depth = 0;
const start = i;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
depth++;
i += 2;
} else if (inner[i] === '}' && inner[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
templates.push(inner.slice(start, i));
} else {
i++;
}
}
return templates;
}
async function performSortCorrection(newName, type) {
const api = new mw.Api();
const title = mw.config.get("wgPageName");
try {
const data = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2
});
let content = data.query.pages[0].revisions[0].content;
const dsRegex = /\{\{(?:DEFAULTSORT|Defaultsort):[^}]+\}\}/i;
const newTag = `{{DEFAULTSORT:${newName}}}`;
let actionDesc = type === "add" ? "added" : "corrected";
if (dsRegex.test(content)) {
content = content.replace(dsRegex, newTag);
} else {
const catRegex = /\[\[Category:/i;
if (catRegex.test(content)) {
content = content.replace(catRegex, `${newTag}\n[[Category:`);
} else {
content += `\n\n${newTag}`;
}
}
await api.postWithEditToken({
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName}`,
nocreate: true
});
mw.notify(`DEFAULTSORT ${actionDesc} to ${newName}!`, {
title: 'Comrade Success',
type: 'success'
});
setTimeout(() => location.reload(), 700);
} catch (err) {
mw.notify('Failed to save DEFAULTSORT.', {
type: 'error'
});
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
function renderSortNudge(target, reason) {
const $dsNudge = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Correct to: ${target}`).on('click', () => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
async function fetchWikidataNameLabels(entity) {
const familyIds = entity.claims?.P734?.map(c => c.mainsnak.datavalue.value.id) || [];
const givenIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
const allNameIds = [...new Set([...familyIds, ...givenIds])];
if (allNameIds.length === 0) return {
givens: [],
surnames: []
};
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: allNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
});
const nameData = await fetch(url).then(r => r.json());
return {
givens: givenIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean),
surnames: familyIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean)
};
}
function fixNameCasing(part, referenceName) {
return part.split(' ').map(word => {
const match = referenceName.match(new RegExp(`\\b${word}\\b`, 'i'));
if (match) return match[0];
return referenceName.split(' ').find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase())) || word;
}).join(' ');
}
function getLongNameFromWikitext(wikitext) {
const firstLine = wikitext.split('\n').find(l => l.includes("'''"));
if (!firstLine) return null;
const boldMatch = firstLine.match(/'''(.+?)'''/);
if (!boldMatch) return null;
return boldMatch[1].replace(/\s*([“"«《„‹「『'].+?[”"»》”›」』']|\([^)\n]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
}
async function init() {
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") return;
const api = new mw.Api();
const qid = mw.config.get("wgWikibaseItemId");
try {
const [pageData, backlinkData] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
api.get({
action: 'query',
list: 'backlinks',
blfilterredir: 'nonredirects',
bllimit: 50,
blnamespace: 0,
bltitle: mw.config.get("wgPageName"),
formatversion: 2
})
]);
const page = pageData.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const currentTitle = mw.config.get("wgTitle");
const cleanTitle = stripDiacritics(currentTitle);
const isOrphanTagged = /\{\{\s*(Orphan|Do-attempt|Lonely|Orp|Orphaned article)/i.test(wikitext) || /\| *orphan *=/i.test(wikitext);
const backLinkCount = backlinkData.query.backlinks.length;
if (isOrphanTagged && backLinkCount >= 1) {
const $container = $('<div>').addClass('comrade-container comrade-status');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text('Remove orphan tag').on('click', () => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) await checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
await checkDomainRedirect(qid, currentTitle);
let isHuman = false;
let entity = null;
if (qid) {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
});
const wikiData = await fetch(url).then(r => r.json());
entity = wikiData.entities[qid];
isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
}
await checkNamingPrecision(currentTitle, isHuman);
if (qid && entity) {
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const nameParts = tClean.split(' ');
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
const isOrphan = isOrphanTagged && backLinkCount < 1;
const primarySurname = hasFamilyNameHatnote ? nameParts[0] : (getSmartSortKey(tClean, entity).split(',')[0] || nameParts[nameParts.length - 1]);
let targetSortName = "";
if (isHuman) {
const {
givens,
surnames
} = await fetchWikidataNameLabels(entity);
const refName = entity.labels?.en?.value || currentTitle;
if (dsMatch) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixNameCasing(lastPart, refName)}, ${fixNameCasing(firstPart, refName)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field.");
} else if (givens.length > 0 && lastPart.split(' ').length > 1) {
const misplacedGiven = lastPart.split(' ').find(part => givens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastPart.split(' ').filter(p => p !== misplacedGiven).join(' ');
targetSortName = `${fixNameCasing(newSurname, refName)}, ${fixNameCasing(misplacedGiven + ' ' + firstPart, refName)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
}
}
} else {
targetSortName = hasFamilyNameHatnote ? `${nameParts[0]}, ${nameParts.slice(1).join(' ')}` : getSmartSortKey(tClean, entity);
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).on('click', () => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
const longName = getLongNameFromWikitext(wikitext);
if (longName && longName.length > tClean.length) {
let sortKey = hasFamilyNameHatnote ? `${longName.split(' ')[0]}, ${longName.split(' ').slice(1).join(' ')}` : getSmartSortKey(longName, entity);
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
await checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
if (targetSortName) {
let targetPage = currentTitle.includes(' (') ? currentTitle.split(' (')[0] : currentTitle;
let rCat = targetPage !== currentTitle ? "R from ambiguous sort name" : "R from sort name";
if (targetSortName.includes(',')) {
const p = targetSortName.split(',');
rCat += `|${p[0].trim().charAt(0).toUpperCase()}|${p[1].trim().charAt(0).toUpperCase()}`;
}
await checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name");
}
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
surnames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
givens.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) finalGivenNames.add(gn);
});
if (finalFamilyNames.size === 0 && nameParts.length >= 2) finalFamilyNames.add(nameParts[nameParts.length - 1]);
if (finalGivenNames.size === 0 && nameParts.length >= 2) finalGivenNames.add(nameParts[0]);
const getPriorityTarget = async (name, type) => {
const titles = type === 'surname' ? [`List of people with surname ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`] : [`List of people with given name ${name}`, `${name} (given name)`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
return titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
};
for (const name of finalGivenNames) {
if (name.toLowerCase() === primarySurname.toLowerCase()) continue;
const target = await getPriorityTarget(name, 'given');
if (target) await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
for (const name of finalFamilyNames) {
const titles = [name, `${name} (surname)`, `List of people with surname ${name}`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
const target = titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
if (target) await checkNameList(name, 'surname', currentTitle, hasShortDesc, isOrphan, target);
}
}
const oresWait = setInterval(() => {
if ($('.article_quality').length) {
clearInterval(oresWait);
checkQualityConsistency();
}
}, 500);
setTimeout(() => clearInterval(oresWait), 5000);
}
} catch (err) {
console.error("Comrade error:", err);
}
}
init();
})();
(function() {
if (window.IllWill) return;
const STRIKEOUT = true;
const HIDE = 'hide';
const IllWill = {
Version: "1.1.1",
Attribution: 'created by <a target="_blank" href="/wiki/User:Cobaltcigs">cobaltcigs</a>',
Summary: "Added interlanguage links using IllWill.js",
DataApi: "https://www.wikidata.org/w/api.php",
LocalLang: mw.config.get("wgPageContentLanguage"),
Disambiguator: / \(.*\)$/,
SiteLinks: {},
Config: {
MaxLanguages: 5,
IgnoreScientific: STRIKEOUT,
AutoDiff: true
},
css: `
#illwill { clear: both; margin-bottom: 1.5em; border: 1px solid #a2a9b1; padding: 15px; background: #f8f9fa; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.ill-sitelinks a { display: inline-block; margin-bottom: 3px; font-size: 0.9em; color: #36c; }
.ill-size-tag { font-size: 0.8em; color: #72777d; margin-left: 6px; white-space: nowrap; }
.illwill-ignore { text-decoration: line-through; color: #72777d; font-size: 0.85em; }
.ill-x { width: 35px; text-align: center; }
.ill-input, .ill-output { font-family: 'Courier New', monospace; white-space: pre-wrap; font-size: 0.9em; background: #fff; border: 1px solid #eaecf0; padding: 4px; border-radius: 2px; }
#ill-buttons { text-align: right; padding: 10px; border-top: 1px solid #a2a9b1; margin-top: 10px; }
#ill-buttons button { margin-left: 10px; cursor: pointer; padding: 6px 16px; border-radius: 2px; border: 1px solid #a2a9b1; transition: all 0.2s; }
#ill-ok { background: #36c; color: #fff; border-color: #36c !important; font-weight: bold; }
#ill-ok:hover { background: #447ff5; }
#ill-ok:disabled { background: #eaecf0; color: #72777d; border-color: #c8ccd1 !important; cursor: not-allowed; opacity: 0.7; }
#ill-table { width: 100%; border-collapse: collapse; margin-top: 10px; background: white; }
#ill-table th { background: #f2f2f2; text-align: left; padding: 8px; border: 1px solid #a2a9b1; }
#ill-table td { padding: 8px; border: 1px solid #a2a9b1; vertical-align: top; }
#ill-table caption { background: #eaffea; font-weight: bold; padding: 10px; border: 1px solid #a2a9b1; border-bottom: none; font-size: 1.1em; }
#ill-help { font-size: 0.85em; color: #54595d; padding: 10px; line-height: 1.4; }
.ill-loading-inline { font-style: italic; color: #72777d; font-size: 0.9em; display: block; margin: 5px 0; }
`,
setup() {
if (!["edit", "submit"].includes(mw.config.get('wgAction'))) return;
mw.loader.addStyleTag(this.css);
const link = mw.util.addPortletLink('p-cactions', '#', 'IllWill', 'ca-illwill', "Add interlanguage links via Wikidata");
if (link) {
link.addEventListener('click', (e) => {
e.preventDefault();
this.makePanel();
});
}
},
norm(s) {
if (!s) return "";
const t = s.trim().replace(/[\s_]+/g, " ");
return t.charAt(0).toUpperCase() + t.slice(1);
},
async getRedLinks($textbox) {
const txt = $textbox.textSelection('getContents') || "";
const wl = txt.match(/\[\[[^[\]\n]+\]\]/g);
if (!wl) return {
reds: [],
txt
};
const api = new mw.Api();
const textToParse = wl.join("").replace(/\|[^\]]+/g, "");
try {
const data = await api.post({
action: 'parse',
text: textToParse,
contentmodel: 'wikitext',
formatversion: 2
});
const tempDiv = document.createElement('div');
tempDiv.innerHTML = data.parse.text;
const reds = Array.from(tempDiv.querySelectorAll("a.new")).map(x => {
const urlParams = new URLSearchParams(x.href.split('?')[1]);
return urlParams.get('title')?.replace(/_/g, " ");
}).filter(Boolean);
return {
reds: [...new Set(reds)],
txt
};
} catch (e) {
return {
reds: [],
txt
};
}
},
async makePanel() {
const $editform = $('#editform');
const $textbox = $('#wpTextbox1');
$('#illwill').remove();
$editform.hide();
const $wrapper = $('<div id="illwill">Loading redlinks...</div>').insertBefore($editform);
const {
reds,
txt
} = await this.getRedLinks($textbox);
const wtLinks = [];
reds.forEach(title => {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s/g, "[\\s_]+");
const re = new RegExp("\\[\\[\\s*" + escaped + "\\s*(?:\\|[^\\[\\]]+)?\\]\\]", "ig");
const match = txt.match(re);
if (match && !wtLinks.some(z => z.title === title)) {
wtLinks.push({
link: match[0],
title
});
}
});
if (!wtLinks.length) {
const $back = $('<button>Quit</button>').on('click', () => {
$wrapper.remove();
$editform.show();
});
$wrapper.empty().append($('<strong>No valid redlinks found in text.</strong> '), $back);
return;
}
const $table = $(`
<table id="ill-table" class="wikitable">
<caption>IllWill.js (v${this.Version})</caption>
<thead>
<tr>
<th class="ill-x"><input type="checkbox" id="ill-select-all" disabled></th>
<th>Input link</th>
<th>Possible Wikidata matches</th>
<th>Sitelinks (top ${this.Config.MaxLanguages} by size)</th>
<th>Resulting wikitext</th>
</tr>
</thead>
<tbody></tbody>
<tfoot>
<tr>
<td colspan="3" id="ill-help">Carefully examine possible Wikidata matches.</td>
<td colspan="2" id="ill-buttons">
<button id="ill-cancel">Cancel</button>
<button id="ill-ok" disabled>Show changes</button>
</td>
</tr>
</tfoot>
</table>
`);
const $tbody = $table.find('tbody');
wtLinks.forEach((item, i) => {
$tbody.append(`
<tr id="ill-row-${i}" data-index="${i}">
<td class="ill-x"><input type="checkbox" class="ill-row-check" disabled></td>
<td class="ill-input">${this.escapeHTML(item.link)}</td>
<td class="ill-wd-item"><span class="ill-loading-inline">Searching...</span></td>
<td class="ill-sitelinks"></td>
<td class="ill-output"></td>
</tr>
`);
});
$wrapper.empty().append($table);
const chunkSize = 5;
for (let i = 0; i < wtLinks.length; i += chunkSize) {
const chunk = wtLinks.slice(i, i + chunkSize);
await Promise.all(chunk.map((item, idx) => this.fetchWikidataOptimized(item.title, i + idx)));
}
$table.on('click', '#ill-cancel', () => {
$editform.show();
$wrapper.remove();
});
$table.on('click', '#ill-ok', () => this.onOkButton($editform, $wrapper, $textbox));
$table.on('change', '#ill-select-all', (e) => {
const isChecked = $(e.target).is(':checked');
$table.find('.ill-row-check:not(:disabled)').each(function() {
$(this).prop('checked', isChecked).trigger('change');
});
});
$table.on('change', '.ill-row-check', (e) => {
this.onCheckbox(e);
this.updateOkButtonState($table);
});
$table.on('change', 'input[type="radio"]', (e) => {
this.showSiteLinks(e);
});
},
async fetchWikidataOptimized(title, index) {
const query = title.replace(this.Disambiguator, "");
const url = `${this.DataApi}?action=wbsearchentities&search=${encodeURIComponent(query)}&language=${this.LocalLang}&limit=5&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
const results = data.search;
const $cell = $(`#ill-row-${index} .ill-wd-item`);
if (!results || !results.length) {
$cell.html('<span class="ill-na">(no results)</span>');
return;
}
const entityIds = results.map(r => r.id);
await this.fetchEntities(entityIds, index, $cell);
} catch (e) {
$(`#ill-row-${index} .ill-wd-item`).text("Search failed.");
}
},
async fetchEntities(ids, rowIndex, $cell) {
const url = `${this.DataApi}?action=wbgetentities&ids=${ids.join('|')}&props=labels|descriptions|claims|sitelinks&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
$cell.empty();
ids.forEach(qNum => {
const entity = data.entities[qNum];
if (!entity) return;
const isScientific = entity.claims?.P31?.some(s => s.mainsnak?.datavalue?.value?.id === "Q13442814");
if (isScientific && this.Config.IgnoreScientific === HIDE) return;
const desc = entity.descriptions?.[this.LocalLang]?.value || entity.descriptions?.en?.value || "";
const label = entity.labels?.[this.LocalLang]?.value || entity.labels?.en?.value || qNum;
const isStrikeout = isScientific && this.Config.IgnoreScientific === STRIKEOUT;
const $div = $(`
<div class="${isStrikeout ? 'illwill-ignore' : ''}">
<input type="radio" name="selection-${rowIndex}" value="${qNum}" id="radio-${qNum}" ${isStrikeout ? 'disabled' : ''}>
<label for="radio-${qNum}">
<a href="https://wikidata.org/wiki/${qNum}" target="_blank">${this.escapeHTML(label)}</a>:
<span class="ill-desc">${desc ? this.escapeHTML(desc) : '<i>no description</i>'}</span>
</label>
</div>
`);
$cell.append($div);
this.SiteLinks[qNum] = entity.sitelinks || {};
});
} catch (e) {
$cell.text("Detail fetch failed.");
}
},
updateOkButtonState($table) {
const $rows = $table.find('.ill-row-check:not(:disabled)');
const $checkedRows = $rows.filter(':checked');
const anyEnabled = $rows.length > 0;
$table.find('#ill-ok').prop('disabled', $checkedRows.length === 0);
const $master = $table.find('#ill-select-all');
$master.prop('disabled', !anyEnabled);
if (anyEnabled && $rows.length === $checkedRows.length) {
$master.prop('checked', true);
} else {
$master.prop('checked', false);
}
},
async showSiteLinks(e) {
const isManualToggling = e.fromCheckbox || false;
const qNum = e.target.value;
const $row = $(e.target).closest('tr');
const sitelinks = this.SiteLinks[qNum];
const $checkbox = $row.find('.ill-row-check');
const $sitelinkCell = $row.find('.ill-sitelinks');
const $outputCell = $row.find('.ill-output');
const $table = $('#ill-table');
const effectiveMax = Math.min(Math.max(this.Config.MaxLanguages, 3), 6);
const validKeys = Object.keys(sitelinks).filter(k => k.endsWith('wiki') && !k.includes('commons'));
$sitelinkCell.html('<span class="ill-loading-inline">Measuring articles...</span>');
$outputCell.empty();
if (!isManualToggling) {
$checkbox.prop('disabled', true).prop('checked', false);
}
const candidates = validKeys.slice(0, 25);
const sizeMap = [];
await Promise.all(candidates.map(async (key) => {
const lang = key.replace('wiki', '').replace(/_/g, '-');
const title = sitelinks[key].title;
const endpoint = `https://${lang}.wikipedia.org/w/api.php?action=query&titles=${encodeURIComponent(title)}&prop=info&format=json&origin=*`;
try {
const res = await fetch(endpoint).then(r => r.json());
const page = Object.values(res.query.pages)[0];
sizeMap.push({
lang,
title,
length: page.length || 0
});
} catch (err) {
sizeMap.push({
lang,
title,
length: 0
});
}
}));
sizeMap.sort((a, b) => b.length - a.length);
const topLinks = sizeMap.slice(0, effectiveMax);
$sitelinkCell.html(topLinks.map(item => {
const kbValue = Math.round(item.length / 1024);
return `<div><a href="https://${item.lang}.wikipedia.org/wiki/${encodeURIComponent(item.title)}" target="_blank">${item.lang}:${this.escapeHTML(item.title)}</a> <span class="ill-size-tag">(${kbValue} kB)</span></div>`;
}).join(""));
const inputWikitext = $row.find('.ill-input').text();
const match = inputWikitext.match(/\[\[(.*?)\]\]/);
if (match) {
const parts = match[1].split('|');
const targetPage = parts[0].trim();
const surfaceName = (parts[1] || parts[0]).trim();
const illLangs = topLinks.map(item => {
const isSameTitle = this.norm(item.title) === this.norm(targetPage);
return `|${item.lang}|${isSameTitle ? '' : item.title}`;
}).join("");
const ltParam = (this.norm(surfaceName) === this.norm(targetPage)) ? "" : `|lt=${surfaceName}`;
$outputCell.text(`{{ill|${targetPage}${ltParam}${illLangs}}}`);
}
$checkbox.prop('disabled', !topLinks.length);
if (!isManualToggling) {
$checkbox.prop('checked', !!topLinks.length);
}
this.updateOkButtonState($table);
},
onCheckbox(e) {
const $row = $(e.target).closest('tr');
const $radio = $row.find('input[type="radio"]:checked');
const isChecked = $(e.target).is(':checked');
if (isChecked) {
if ($radio.length) {
this.showSiteLinks({
target: $radio[0],
fromCheckbox: true
});
}
} else {
$row.find('.ill-sitelinks, .ill-output').empty();
}
},
onOkButton($editform, $wrapper, $textbox) {
let text = $textbox.textSelection('getContents');
let changed = false;
$wrapper.find('.ill-row-check:checked').each(function() {
const $row = $(this).closest('tr');
const input = $row.find('.ill-input').text();
const output = $row.find('.ill-output').text();
if (output) {
const escapedInput = input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
text = text.replace(new RegExp(escapedInput, "gi"), output);
changed = true;
}
});
if (changed) {
$('#wpSummary').val((i, v) => (v ? v + "; " : "") + this.Summary);
$textbox.textSelection('setContents', text);
$wrapper.remove();
$editform.show();
if (this.Config.AutoDiff) $('#wpDiff').click();
}
},
escapeHTML(str) {
return str.replace(/[&<>"']/g, m => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
})[m]);
}
};
window.IllWill = IllWill;
mw.loader.using(['mediawiki.util', 'mediawiki.api', 'jquery.textSelection'], () => {
$(() => IllWill.setup());
});
})();
(function() {
'use strict';
const config = {
enableNS14: true,
rootWhitelists: ['Materials'],
userWhitelist: ""
};
const categoryDataCache = {};
let scanProgress = 0;
let totalToScan = 0;
async function verifyTreeMembership() {
if (!config.enableNS14) return false;
const api = new mw.Api();
const currentTitle = mw.config.get("wgPageName").replace(/_/g, ' ');
const fullRoots = config.rootWhitelists.concat(config.userWhitelist.split(',').map(s => s.trim()).filter(s => s)).map(cat => (cat.startsWith('Category:') ? cat : `Category:${cat}`));
if (fullRoots.includes(currentTitle)) return true;
let categoriesToTable = mw.config.get('wgCategories') || [];
let checked = new Set();
while (categoriesToTable.length > 0) {
if (categoriesToTable.some(cat => fullRoots.includes('Category:' + cat))) {
return true;
}
categoriesToTable.forEach(cat => checked.add(cat));
try {
const res = await api.get({
formatversion: 2,
prop: 'categories',
titles: categoriesToTable.map(c => 'Category:' + c),
cllimit: 'max'
});
let nextBatch = [];
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.categories) {
page.categories.forEach(parent => {
const parentName = parent.title.substring('Category:'.length);
if (!checked.has(parentName)) {
nextBatch.push(parentName);
}
});
}
});
}
categoriesToTable = [...new Set(nextBatch)];
} catch (e) {
return false;
}
}
return false;
}
function getChemboxBlock(wikitext) {
const chemboxRegex = /\{\{\s*Chembox[\s_]*(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const match = wikitext.match(chemboxRegex);
return match ? match[0] : null;
}
function getFormulaFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const formulaMatch = chembox.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
let rawFormula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
rawFormula = rawFormula.split(/\s+/)[0];
return rawFormula.split(',')[0].trim();
}
const propRegex = /\{\{\s*Chembox[\s_]+Properties(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const propSection = chembox.match(propRegex);
if (propSection) {
const content = propSection[0];
const allElements = ['Ac', 'Ag', 'Al', 'Am', 'Ar', 'As', 'At', 'Au', 'B', 'Ba', 'Be', 'Bh', 'Bi', 'Bk', 'Br', 'C', 'Ca', 'Cd', 'Ce', 'Cf', 'Cl', 'Cm', 'Cn', 'Co', 'Cr', 'Cs', 'Cu', 'D', 'Db', 'Ds', 'Dy', 'Er', 'Es', 'Eu', 'F', 'Fe', 'Fl', 'Fm', 'Fr', 'Ga', 'Gd', 'Ge', 'H', 'He', 'Hf', 'Hg', 'Ho', 'Hs', 'I', 'In', 'Ir', 'K', 'Kr', 'La', 'Li', 'Lr', 'Lu', 'Lv', 'Mc', 'Md', 'Mg', 'Mn', 'Mo', 'Mt', 'N', 'Na', 'Nb', 'Nd', 'Ne', 'Nh', 'Ni', 'No', 'Np', 'O', 'Og', 'Os', 'P', 'Pa', 'Pb', 'Pd', 'Pm', 'Po', 'Pr', 'Pt', 'Pu', 'Ra', 'Rb', 'Re', 'Rf', 'Rg', 'Rh', 'Rn', 'Ru', 'S', 'Sb', 'Sc', 'Se', 'Sg', 'Si', 'Sm', 'Sn', 'Sr', 'Ta', 'Tb', 'Tc', 'Te', 'Th', 'Ti', 'Tl', 'Tm', 'Ts', 'U', 'V', 'W', 'Xe', 'Y', 'Yb', 'Zn', 'Zr'];
let data = {};
allElements.forEach(el => {
const elRegex = new RegExp(`\\|\\s*${el}\\s*=\\s*(\\d*\\.?\\d+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch && elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
});
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
elementsPresent.filter(el => !(hasC && (el === "C" || el === "H"))).sort().forEach(el => {
formulaArray.push(el + data[el]);
});
return formulaArray.join('');
}
}
return null;
}
function getCASFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const idRegex = /\{\{\s*Chembox[\s_]+Identifiers(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const idSection = chembox.match(idRegex);
if (!idSection) return null;
const content = idSection[0];
const nbsp = String.fromCharCode(160);
const casMatch = content.match(/\|\s*CASNo\d*\b(?!\s*_)\s*=\s*([^|}\n]+)/i);
if (casMatch) {
let val = casMatch[1].replace(/\{\{[^}]+\}\}/g, "").replace(/\u00AD/g, "").split(" ").join("");
while (val.indexOf(nbsp) !== -1) val = val.replace(nbsp, "");
while (val.indexOf(" ") !== -1) val = val.replace(" ", "");
const cleanedCas = val.trim();
return /^\d{2,7}-\d{2}-\d$/.test(cleanedCas) ? cleanedCas : null;
}
return null;
}
async function getWikidataValue() {
const qid = mw.config.get('wgWikidataItemId');
if (!qid) return null;
const api = new mw.ForeignApi('https://www.wikidata.org/w/api.php');
const res = await api.get({
action: 'wbgetentities',
ids: qid,
props: 'claims',
format: 'json',
formatversion: 2
});
const claims = res.entities[qid].claims;
const data = {};
if (claims.P274) data.formula = claims.P274[0].mainsnak.datavalue.value;
if (claims.P231) data.cas = claims.P231[0].mainsnak.datavalue.value;
return data;
}
async function startBackgroundCategoryScan() {
const api = new mw.Api();
const titles = $('#mw-pages li a').map((i, el) => $(el).attr('title') || $(el).text()).get();
totalToScan = titles.length;
if (totalToScan === 0) return;
const chunks = arrayChunk(titles, 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: chunk,
rvprop: 'content',
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const f = getFormulaFromWikitext(wikitext);
const c = getCASFromWikitext(wikitext);
if (f || c) categoryDataCache[page.title] = {
formula: f,
cas: c
};
});
}
scanProgress += chunk.length;
$('#caschemassist-cat-btn').text(`Scanning... ${scanProgress}/${totalToScan}`);
}
$('#caschemassist-cat-btn').text('Show missing CAS/Chem').prop('disabled', false);
}
async function renderCategoryAudit() {
const api = new mw.Api();
const missingCheckTitles = [];
Object.values(categoryDataCache).forEach(data => {
if (data.formula) missingCheckTitles.push(data.formula);
if (data.cas) missingCheckTitles.push(data.cas);
});
if (missingCheckTitles.length === 0) {
mw.notify("No valid Chembox data found in this category.");
return;
}
const existenceMap = {};
const chunks = arrayChunk([...new Set(missingCheckTitles)], 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
titles: chunk,
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(p => {
existenceMap[p.title] = !p.missing;
});
}
}
$('#mw-pages li').each(function() {
const title = $(this).find('a').attr('title') || $(this).find('a').text().trim();
const data = categoryDataCache[title];
if (!data) return;
if (data.formula && !existenceMap[data.formula]) {
$(this).append($('<span>').css({
'color': 'red',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [${data.formula}]`));
}
if (data.cas && !existenceMap[data.cas]) {
$(this).append($('<span>').css({
'color': 'darkred',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [CAS: ${data.cas}]`));
}
});
$('#caschemassist-cat-btn').text('Audit complete').prop('disabled', true);
}
async function handleSingleArticle() {
const api = new mw.Api();
const currentTitle = mw.config.get("wgTitle");
const [wikiRes, wikidata] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
getWikidataValue()
]);
const page = wikiRes.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const formula = getFormulaFromWikitext(wikitext);
const cas = getCASFromWikitext(wikitext);
if (wikidata) {
let mismatches = [];
if (formula && wikidata.formula && formula !== wikidata.formula) {
mismatches.push(`Formula mismatch: Chembox "${formula}" vs Wikidata "${wikidata.formula}"`);
}
if (cas && wikidata.cas && cas !== wikidata.cas) {
mismatches.push(`CAS mismatch: Chembox "${cas}" vs Wikidata "${wikidata.cas}"`);
}
if (mismatches.length > 0) {
const $warning = $('<div>').css({
'background': '#fff2f2',
'border': '2px solid #d33',
'padding': '10px',
'margin-bottom': '1em',
'color': '#b32424'
}).append($('<strong>').text('⚠️ Data Mismatch Detected: '), mismatches.join(' | '));
$('#mw-content-text').prepend($warning);
}
}
async function checkAndRender(target, label, customWikitext) {
const check = await api.get({
action: 'query',
titles: target,
formatversion: 2
});
if (check.query.pages[0].missing) {
const $container = $('<div>').css({
'background': '#f8f9fa',
'border': '1px solid #a2a9b1',
'padding': '10px',
'margin-bottom': '1em'
});
const $btn = $('<button>').text(`Create redirect: ${target}`).click(async function() {
await api.postWithEditToken({
action: 'edit',
title: target,
text: customWikitext,
summary: `Creating redirect to [[${currentTitle}]] (${label})`,
createonly: true
});
mw.notify(`Created redirect: ${target}`);
$container.fadeOut(400, function() {
$(this).remove();
});
});
$container.append($('<strong>').text(`${label}: `), `Missing redirect. `, $btn);
$('#mw-content-text').prepend($container);
}
}
if (formula && formula !== currentTitle) {
const formulaWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from chemical formula}}\n}}`;
await checkAndRender(formula, "Chemical formula", formulaWikitext);
}
if (cas && cas !== currentTitle) {
const casWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from CAS Registry Number}}\n{{R unprintworthy}}\n}}`;
await checkAndRender(cas, "CAS number", casWikitext);
}
}
function arrayChunk(arr, size) {
const result = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
$(document).ready(async function() {
const ns = mw.config.get("wgNamespaceNumber");
if (ns === 14) {
if (await verifyTreeMembership()) {
const $catContainer = $('#mw-pages');
if ($catContainer.length) {
const $btn = $('<button>').text('Initializing scan...').attr('id', 'caschemassist-cat-btn').addClass('mw-ui-button').css({
'margin': '0 10px 10px 0',
'float': 'right',
'position': 'relative',
'z-index': '100'
}).prop('disabled', true).click(renderCategoryAudit);
$catContainer.prepend($btn);
startBackgroundCategoryScan();
}
}
} else if (ns === 0 && mw.config.get("wgAction") === "view") {
handleSingleArticle();
}
});
})();
//</nowiki>
q9abcyy9kp5p0uxbhqs5mpxmvb0psf7
739309
739307
2026-04-25T11:55:40Z
Sam Sailor
26820
Test
739309
javascript
text/javascript
//<nowiki>
(function() {
mw.util.addCSS(`
.ambox-Orphan { display: block !important; }
.comrade-container {
box-shadow: 0 1px 3px rgba(0,0,0,0.1); border-radius: 2px;
padding: 10px 15px; margin: 10px 0; display: flex;
flex-direction: column; align-items: flex-start; gap: 8px;
border: 1px solid #a2a9b1; background: #fcfcfc; line-height: 1.5;
}
.comrade-header { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.comrade-content { display: flex; flex-direction: column; gap: 5px; width: 100%; }
.comrade-success { background-color: #d5f5e3 !important; border-left: 5px solid #27ae60 !important; color: #1b5e20 !important; }
.comrade-nudge { background: #fff9ea; border-left: 5px solid #f0ad4e; }
.comrade-info { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-status { background: #eaf3ff; border-left: 5px solid #36c; }
.comrade-container button:not(.comrade-dismiss) {
background: #f8f9fa; border: 1px solid #a2a9b1; border-radius: 2px;
padding: 2px 10px; cursor: pointer; font-weight: bold; font-size: 0.95em; margin-left: 10px;
}
.comrade-container button:not(.comrade-dismiss):hover { border-color: #36c; color: #36c; background: #fff; }
.comrade-dismiss {
background: transparent; border: none; color: #d33; font-weight: bold;
cursor: pointer; font-size: 0.9em;
}
.comrade-dismiss:hover { text-decoration: underline; }
.comrade-code-block { background: #f1f1f1; padding: 4px 8px; border-radius: 3px; font-family: monospace; display: inline-block; margin-top: 4px; }
`);
function appendDismiss($el) {
$('<button>').addClass('comrade-dismiss').text('Dismiss').on('click', function() {
$(this).closest('.comrade-container').fadeOut();
}).appendTo($el.find('.comrade-header'));
$("#siteSub").after($el);
}
async function checkNamingPrecision(currentTitle, isHuman) {
const disambigMatch = currentTitle.match(/^(.+?)(?:, | \()/);
if (isHuman || !disambigMatch) return;
const baseTitle = disambigMatch[1].trim();
const api = new mw.Api();
try {
const baseRes = await api.get({
action: 'query',
titles: baseTitle,
redirects: 1,
formatversion: 2
});
const page = baseRes.query.pages[0];
const baseExists = !page.missing;
let baseRedirectsToSelf = false;
if (baseRes.query.redirects) {
baseRedirectsToSelf = baseRes.query.redirects.some(r => r.to === currentTitle);
}
const searchRes = await api.get({
action: 'query',
list: 'allpages',
apprefix: baseTitle,
aplimit: 50,
formatversion: 2
});
const competitors = searchRes.query.allpages.filter(p => {
const t = p.title;
return t !== currentTitle && t !== baseTitle && (t.startsWith(baseTitle + ', ') || t.startsWith(baseTitle + ' ('));
});
if (!baseExists || baseRedirectsToSelf) {
const $container = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (competitors.length === 0) {
$header.append($('<strong>').text('Precision:'));
const reason = baseRedirectsToSelf ? "already redirects here" : "is free";
$content.append($('<span>').append(`The base name `, $('<code>').text(baseTitle), ` ${reason}. Consider moving this article.`));
const moveUrl = mw.util.getUrl('Special:MovePage/' + currentTitle, {
wpNewTitle: baseTitle,
wpReason: 'Unnecessary disambiguation; base name is free/redirects here per [[WP:PRECISION]]'
});
$content.append($('<button>').text(`Move to ${baseTitle}`).on('click', () => window.open(moveUrl, '_blank')));
} else {
$header.append($('<strong>').text('Observation:'));
const state = baseRedirectsToSelf ? 'redirect to this page' : 'redlink';
$content.append($('<span>').append(`Base name `, $('<code>').text(baseTitle), ` has ${competitors.length} competitors, but it is currently a ${state}.`));
$content.append($('<span>').text('Consider if a disambiguation page is needed.'));
}
$container.append($header, $content);
appendDismiss($container);
}
} catch (e) {
console.warn("Comrade: Precision check failed", e);
}
}
async function checkAndRenderRedirect(redirTitle, targetTitle, rCategory, labelText, customWikitext) {
const api = new mw.Api();
const res = await api.get({
action: 'query',
titles: redirTitle,
formatversion: 2
});
if (res.query.pages[0].missing) {
const safeId = "comrade-redir-" + redirTitle.replace(/[^a-z0-9]/gi, '-');
const $container = $('<div>').addClass('comrade-container comrade-info').attr('id', safeId);
const $header = $('<div>').addClass('comrade-header');
const summary = `Created redirect from ${labelText.toLowerCase()} to [[${targetTitle}]]`;
const $btn = $('<button>').text('Create redirect').on('click', () => createModernRedirect(redirTitle, targetTitle, rCategory, summary, safeId, customWikitext));
$header.append($('<div>').append($('<strong>').text(`${labelText}: `), `Create redirect from `, $('<code>').text(redirTitle), $btn));
$container.append($header);
appendDismiss($container);
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
if (qid) {
try {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
});
const res = await fetch(url).then(r => r.json());
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
}
} catch (e) {
console.warn("Comrade: Wikidata API call failed.");
}
}
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
if (domain.includes(".") && url.pathname === "/") {
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
try {
const response = await fetch(citationURL);
if (response.ok) {
await checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
}
} catch (e) {
console.warn("Comrade: Citation API check failed.");
}
}
} catch (e) {
console.warn("Comrade: Error parsing URL object.");
}
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan, resolvedTitle) {
const api = new mw.Api();
const candidates = resolvedTitle ? [resolvedTitle] : [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
redirects: 0
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const foundTitle = page.title;
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) break;
const $container = $('<div>').addClass('comrade-container');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').addClass('comrade-content');
if (isOrphan) {
$container.addClass('comrade-nudge');
$header.append($('<strong>').text('Comrade nudge:'));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
}
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(foundTitle),
target: '_blank'
}).text(foundTitle), "; should it be?"));
const snippet = '* {{anbl|' + currentTitle + '}}';
const $snippetWrapper = $('<div>').css({
'display': 'flex',
'align-items': 'center',
'gap': '10px',
'margin-top': '4px'
});
const $instructionSpan = $('<span>').append('If the Short description is good, consider adding ', $('<code>').text(snippet).css({
'background-color': '#f8f9fa',
'border': '1px solid #eaecf0',
'border-radius': '2px',
'padding': '1px 4px'
}));
const $copyBtn = $('<button>').text('Copy & insert').css('margin-left', '0').on('click', function() {
navigator.clipboard.writeText(snippet + '\n').then(() => {
const $this = $(this);
const originalText = $this.text();
$this.text('Copied!').prop('disabled', true);
const editUrl = mw.util.getUrl(foundTitle, {
action: 'edit',
summary: 'Adding ' + snippet
});
window.open(editUrl, '_blank');
setTimeout(() => {
$this.text(originalText).prop('disabled', false);
}, 1500);
});
});
$snippetWrapper.append($instructionSpan, $copyBtn);
$content.append($snippetWrapper);
if (!hasShortDesc) {
$content.append($('<span>').css({
'color': '#d33',
'font-weight': 'bold',
'margin-top': '5px'
}).text('⚠️ Missing short description: Please add a concise one before listing.'));
}
$container.append($header, $content);
appendDismiss($container);
break;
}
}
}
}
async function checkQualityConsistency() {
const oresClass = getOresPrediction();
if (!oresClass || oresClass === 'Stub') return;
const talkTitle = 'Talk:' + mw.config.get('wgPageName');
const api = new mw.Api();
try {
const [talkRes, pageRes] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: talkTitle,
rvprop: 'content',
formatversion: 2
}),
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
})
]);
const talkPage = talkRes.query.pages[0];
const wikitext = pageRes.query.pages[0].revisions[0].content;
const hasStubTag = /\{\{[^}]*stub\}\}/i.test(wikitext);
const $raterLink = $('#ca-rater, #t-rater');
const isRaterAvailable = (typeof window.rater !== 'undefined' || $raterLink.length > 0);
let talkClass = null;
if (!talkPage.missing) {
const classMatch = talkPage.revisions[0].content.match(/\|class\s*=\s*([^|}\s]+)/i);
talkClass = classMatch ? classMatch[1].charAt(0).toUpperCase() + classMatch[1].slice(1).toLowerCase() : null;
}
const isStubRated = talkClass === 'Stub';
const lingeringStubTag = (talkClass && talkClass !== 'Stub' && oresClass !== 'Stub' && hasStubTag);
if (talkPage.missing || isStubRated || lingeringStubTag) {
const $container = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $content = $('<div>').append($('<strong>').text('Quality:'), " ");
let message = `ORES suggests <b>${oresClass}</b>. `;
if (talkPage.missing) {
message = `Talk page missing; ORES suggests <b>${oresClass}</b>. `;
let $btn;
if (isRaterAvailable) {
$btn = $('<button>').text('Open Rater').on('click', () => $raterLink.find('a')[0].click());
} else {
$btn = $('<button>').text('Install Rater').on('click', () => window.open('https://en.wikipedia.org/wiki/User:Evad37/rater', '_blank'));
message += "Consider installing <i>Rater</i> for easy assessment.";
}
$content.append(message, $btn);
} else if (lingeringStubTag && !isStubRated) {
message = `Article is ${talkClass}-class, but stub tags remain. `;
const $btn = $('<button>').text('Remove stub tags').on('click', () => performStubRemoval());
$content.append(message, $btn);
} else if (isStubRated) {
message = `Talk page says Stub, but ORES predicts <b>${oresClass}</b>. `;
const $btnOres = $('<button>').text(`Promote to ${oresClass}`).on('click', () => performPromotion(oresClass));
$content.append(message, $btnOres);
if (oresClass === 'C') {
const $btnStart = $('<button>').text(`Promote to Start`).on('click', () => performPromotion('Start'));
$content.append("\u00A0\u00A0\u00A0or\u00A0", $btnStart);
} else if (oresClass === 'B') {
const $btnC = $('<button>').text(`Promote to C`).on('click', () => performPromotion('C'));
$content.append("\u00A0\u00A0\u00A0or\u00A0", $btnC);
}
}
$header.append($content);
$container.append($header);
appendDismiss($container);
}
} catch (e) {
console.error("Comrade quality check failed", e);
}
}
async function performStubRemoval() {
const api = new mw.Api();
try {
const pageData = await api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
});
let wikitext = pageData.query.pages[0].revisions[0].content;
const newWikitext = wikitext.replace(/\{\{[^}]*stub\}\}\n?/gi, '').trim();
if (wikitext === newWikitext) {
mw.notify("No stub tags found to remove.");
return;
}
await api.postWithToken('csrf', {
action: 'edit',
title: mw.config.get("wgPageName"),
text: newWikitext,
summary: `Removing lingering stub tags per ORES/Talk assessment`,
nocreate: true
});
location.reload();
} catch (err) {
console.error("Comrade failed to remove stub tags:", err);
mw.notify("Error removing stub tags.", {
type: 'error'
});
}
}
async function createModernRedirect(redirTitle, targetTitle, rCategory, customSummary, elementId, customWikitext) {
const api = new mw.Api();
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
try {
await api.postWithEditToken({
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
});
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
} catch (e) {
mw.notify("Failed to create redirect.", {
type: 'error'
});
}
}
function getOresPrediction() {
// Dependency: [[m:User:EpochFail/ArticleQuality-system.js]] renders the prediction inside .article_quality
const text = $('.article_quality').text();
if (!text) return null;
if (text.includes('Start')) return 'Start';
if (text.includes('C')) return 'C';
if (text.includes('B')) return 'B';
return null;
}
function getSmartSortKey(fullName, entity) {
const countryIds = entity.claims?.P27?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const locationIds = entity.claims?.P17?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const placeIds = [...(entity.claims?.P19 || []), ...(entity.claims?.P119 || [])].map(c => c.mainsnak?.datavalue?.value?.id).filter(Boolean);
const allRelevantIds = [...countryIds, ...locationIds, ...placeIds];
const birthYear = parseInt(entity.claims?.P569?.[0]?.mainsnak?.datavalue?.value?.time?.match(/[+-](\d{4})/)?.[1]) || null;
if (fullName.startsWith("Saint ")) return fullName.replace("Saint ", "");
const arabicParticles = /\b(Abu|Abd|Abdel|Abdul|ben|bin|bint)\b/i;
if (fullName.match(arabicParticles)) {
if (birthYear && birthYear > 1900) {
const parts = fullName.split(' ');
const binIndex = parts.findIndex(p => p.toLowerCase() === 'bin' || p.toLowerCase() === 'ben');
if (binIndex > 0) return parts.slice(binIndex).join(' ') + ', ' + parts.slice(0, binIndex).join(' ');
} else {
return fullName;
}
}
const eastAsianQids = ["Q148", "Q184", "Q884", "Q424", "Q881", "Q17"];
if (allRelevantIds.some(id => eastAsianQids.includes(id))) {
if (allRelevantIds.includes("Q17") && birthYear && birthYear > 1885) return westernSort(fullName);
const parts = fullName.split(' ');
if (parts.length > 1) return `${parts[0]}, ${parts.slice(1).join(' ')}`;
}
return westernSort(fullName);
}
function westernSort(name) {
let cleanName = name;
if (name.startsWith("O'")) cleanName = name.replace("O'", "O");
const parts = cleanName.split(' ');
if (parts.length < 2) return cleanName;
const surname = parts.pop();
if (["Jr.", "Jr", "III", "II", "Sr.", "Sr"].includes(surname)) {
const actualSurname = parts.pop();
return `${actualSurname}, ${parts.join(' ')} ${surname}`;
}
return `${surname}, ${parts.join(' ')}`;
}
async function performDeorphan() {
const api = new mw.Api();
try {
await api.edit(mw.config.get('wgPageName'), function(rev) {
let text = rev.content;
const summary = 'Article has backlinks; removed [[Template:Orphan|{{Orphan}}]]';
text = text.replace(/\{\{Orphan\s*(?:\|[^}]*)?\}\}\s*/gi, '');
text = text.replace(/(\{\{Multiple[ _]issues\s*)\|\s*\|/gi, '$1|');
text = replaceMI(text);
text = text.replace(/\n{3,}/g, '\n\n').trim();
return {
text,
summary,
minor: true
};
});
mw.notify('Orphan tag removed!', {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
setTimeout(() => location.reload(), 700);
} catch (e) {
mw.notify('Failed to remove orphan tag.', {
type: 'error'
});
}
}
async function performPromotion(newClass) {
const api = new mw.Api();
const pageName = mw.config.get('wgPageName');
const talkName = 'Talk:' + pageName;
try {
const talkData = await api.get({
action: 'query',
prop: 'revisions',
titles: talkName,
rvprop: 'content',
formatversion: 2
});
let talkWikitext = talkData.query.pages[0].revisions[0].content;
talkWikitext = talkWikitext.replace(/(\|class\s*=\s*)Stub/i, `$1${newClass}`);
await api.postWithEditToken({
action: 'edit',
title: talkName,
text: talkWikitext,
summary: `Promoting article to ${newClass} class per ORES prediction [[WP:ORES]]`
});
const artData = await api.get({
action: 'query',
prop: 'revisions',
titles: pageName,
rvprop: 'content',
formatversion: 2
});
let artWikitext = artData.query.pages[0].revisions[0].content;
const stubRegex = /\{\{[^}]*?-stub\}\}\s*\n?/gi;
const genericStubRegex = /\{\{stub\}\}\s*/gi;
artWikitext = artWikitext.replace(stubRegex, '').replace(genericStubRegex, '');
await api.postWithEditToken({
action: 'edit',
title: pageName,
text: artWikitext,
summary: `Removing stub tags; article promoted to ${newClass}`
});
location.reload();
} catch (e) {
alert("Promotion failed. Check console for details.");
console.error(e);
}
}
function replaceMI(text) {
const miRegex = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miRegex.exec(text)) !== null) {
const start = match.index;
result += text.slice(lastIndex, start);
let depth = 0;
let i = start;
while (i < text.length) {
if (text[i] === '{' && text[i + 1] === '{') {
depth++;
i += 2;
} else if (text[i] === '}' && text[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
const end = i;
const full = text.slice(start, end);
const pipeIdx = full.indexOf('|');
const inner = full.slice(pipeIdx + 1, full.length - 2).trim();
const topLevel = extractTopLevelTemplates(inner);
if (topLevel.length === 0) {
result += '';
} else if (topLevel.length === 1) {
result += topLevel[0].trim();
} else {
result += full;
}
lastIndex = end;
}
result += text.slice(lastIndex);
return result;
}
function extractTopLevelTemplates(inner) {
const templates = [];
let i = 0;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
let depth = 0;
const start = i;
while (i < inner.length) {
if (inner[i] === '{' && inner[i + 1] === '{') {
depth++;
i += 2;
} else if (inner[i] === '}' && inner[i + 1] === '}') {
depth--;
i += 2;
if (depth === 0) break;
} else {
i++;
}
}
templates.push(inner.slice(start, i));
} else {
i++;
}
}
return templates;
}
async function performSortCorrection(newName, type) {
const api = new mw.Api();
const title = mw.config.get("wgPageName");
try {
const data = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2
});
let content = data.query.pages[0].revisions[0].content;
const dsRegex = /\{\{(?:DEFAULTSORT|Defaultsort):[^}]+\}\}/i;
const newTag = `{{DEFAULTSORT:${newName}}}`;
let actionDesc = type === "add" ? "added" : "corrected";
if (dsRegex.test(content)) {
content = content.replace(dsRegex, newTag);
} else {
const catRegex = /\[\[Category:/i;
if (catRegex.test(content)) {
content = content.replace(catRegex, `${newTag}\n[[Category:`);
} else {
content += `\n\n${newTag}`;
}
}
await api.postWithEditToken({
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName}`,
nocreate: true
});
mw.notify(`DEFAULTSORT ${actionDesc} to ${newName}!`, {
title: 'Comrade Success',
type: 'success'
});
setTimeout(() => location.reload(), 700);
} catch (err) {
mw.notify('Failed to save DEFAULTSORT.', {
type: 'error'
});
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
function renderSortNudge(target, reason) {
const $dsNudge = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Correct to: ${target}`).on('click', () => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
async function fetchWikidataNameLabels(entity) {
const familyIds = entity.claims?.P734?.map(c => c.mainsnak.datavalue.value.id) || [];
const givenIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
const allNameIds = [...new Set([...familyIds, ...givenIds])];
if (allNameIds.length === 0) return {
givens: [],
surnames: []
};
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: allNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
});
const nameData = await fetch(url).then(r => r.json());
return {
givens: givenIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean),
surnames: familyIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean)
};
}
function fixNameCasing(part, referenceName) {
return part.split(' ').map(word => {
const match = referenceName.match(new RegExp(`\\b${word}\\b`, 'i'));
if (match) return match[0];
return referenceName.split(' ').find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase())) || word;
}).join(' ');
}
function getLongNameFromWikitext(wikitext) {
const firstLine = wikitext.split('\n').find(l => l.includes("'''"));
if (!firstLine) return null;
const boldMatch = firstLine.match(/'''(.+?)'''/);
if (!boldMatch) return null;
return boldMatch[1].replace(/\s*([“"«《„‹「『'].+?[”"»》”›」』']|\([^)\n]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
}
async function init() {
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") return;
const api = new mw.Api();
const qid = mw.config.get("wgWikibaseItemId");
try {
const [pageData, backlinkData] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
api.get({
action: 'query',
list: 'backlinks',
blfilterredir: 'nonredirects',
bllimit: 50,
blnamespace: 0,
bltitle: mw.config.get("wgPageName"),
formatversion: 2
})
]);
const page = pageData.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const currentTitle = mw.config.get("wgTitle");
const cleanTitle = stripDiacritics(currentTitle);
const isOrphanTagged = /\{\{\s*(Orphan|Do-attempt|Lonely|Orp|Orphaned article)/i.test(wikitext) || /\| *orphan *=/i.test(wikitext);
const backLinkCount = backlinkData.query.backlinks.length;
if (isOrphanTagged && backLinkCount >= 1) {
const $container = $('<div>').addClass('comrade-container comrade-status');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text('Remove orphan tag').on('click', () => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) await checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
await checkDomainRedirect(qid, currentTitle);
let isHuman = false;
let entity = null;
if (qid) {
const url = new URL("https://www.wikidata.org/w/api.php");
url.search = new URLSearchParams({
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
});
const wikiData = await fetch(url).then(r => r.json());
entity = wikiData.entities[qid];
isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
}
await checkNamingPrecision(currentTitle, isHuman);
if (qid && entity) {
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const nameParts = tClean.split(' ');
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
const isOrphan = isOrphanTagged && backLinkCount < 1;
const primarySurname = hasFamilyNameHatnote ? nameParts[0] : (getSmartSortKey(tClean, entity).split(',')[0] || nameParts[nameParts.length - 1]);
let targetSortName = "";
if (isHuman) {
const {
givens,
surnames
} = await fetchWikidataNameLabels(entity);
const refName = entity.labels?.en?.value || currentTitle;
if (dsMatch) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixNameCasing(lastPart, refName)}, ${fixNameCasing(firstPart, refName)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field.");
} else if (givens.length > 0 && lastPart.split(' ').length > 1) {
const misplacedGiven = lastPart.split(' ').find(part => givens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastPart.split(' ').filter(p => p !== misplacedGiven).join(' ');
targetSortName = `${fixNameCasing(newSurname, refName)}, ${fixNameCasing(misplacedGiven + ' ' + firstPart, refName)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
}
}
} else {
targetSortName = hasFamilyNameHatnote ? `${nameParts[0]}, ${nameParts.slice(1).join(' ')}` : getSmartSortKey(tClean, entity);
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).on('click', () => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
const longName = getLongNameFromWikitext(wikitext);
if (longName && longName.length > tClean.length) {
let sortKey = hasFamilyNameHatnote ? `${longName.split(' ')[0]}, ${longName.split(' ').slice(1).join(' ')}` : getSmartSortKey(longName, entity);
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
await checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
if (targetSortName) {
let targetPage = currentTitle.includes(' (') ? currentTitle.split(' (')[0] : currentTitle;
let rCat = targetPage !== currentTitle ? "R from ambiguous sort name" : "R from sort name";
if (targetSortName.includes(',')) {
const p = targetSortName.split(',');
rCat += `|${p[0].trim().charAt(0).toUpperCase()}|${p[1].trim().charAt(0).toUpperCase()}`;
}
await checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name");
}
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
surnames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
givens.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) finalGivenNames.add(gn);
});
if (finalFamilyNames.size === 0 && nameParts.length >= 2) finalFamilyNames.add(nameParts[nameParts.length - 1]);
if (finalGivenNames.size === 0 && nameParts.length >= 2) finalGivenNames.add(nameParts[0]);
const getPriorityTarget = async (name, type) => {
const titles = type === 'surname' ? [`List of people with surname ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`] : [`List of people with given name ${name}`, `${name} (given name)`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
return titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
};
for (const name of finalGivenNames) {
if (name.toLowerCase() === primarySurname.toLowerCase()) continue;
const target = await getPriorityTarget(name, 'given');
if (target) await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
for (const name of finalFamilyNames) {
const titles = [name, `${name} (surname)`, `List of people with surname ${name}`];
const res = await api.get({
action: 'query',
titles: titles.join('|'),
formatversion: 2
});
const target = titles.find(t => res.query.pages.find(pg => pg.title === t && !pg.missing));
if (target) await checkNameList(name, 'surname', currentTitle, hasShortDesc, isOrphan, target);
}
}
const oresWait = setInterval(() => {
if ($('.article_quality').length) {
clearInterval(oresWait);
checkQualityConsistency();
}
}, 500);
setTimeout(() => clearInterval(oresWait), 5000);
}
} catch (err) {
console.error("Comrade error:", err);
}
}
init();
})();
(function() {
if (window.IllWill) return;
const STRIKEOUT = true;
const HIDE = 'hide';
const IllWill = {
Version: "1.1.1",
Attribution: 'created by <a target="_blank" href="/wiki/User:Cobaltcigs">cobaltcigs</a>',
Summary: "Added interlanguage links using IllWill.js",
DataApi: "https://www.wikidata.org/w/api.php",
LocalLang: mw.config.get("wgPageContentLanguage"),
Disambiguator: / \(.*\)$/,
SiteLinks: {},
Config: {
MaxLanguages: 5,
IgnoreScientific: STRIKEOUT,
AutoDiff: true
},
css: `
#illwill { clear: both; margin-bottom: 1.5em; border: 1px solid #a2a9b1; padding: 15px; background: #f8f9fa; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.ill-sitelinks a { display: inline-block; margin-bottom: 3px; font-size: 0.9em; color: #36c; }
.ill-size-tag { font-size: 0.8em; color: #72777d; margin-left: 6px; white-space: nowrap; }
.illwill-ignore { text-decoration: line-through; color: #72777d; font-size: 0.85em; }
.ill-x { width: 35px; text-align: center; }
.ill-input, .ill-output { font-family: 'Courier New', monospace; white-space: pre-wrap; font-size: 0.9em; background: #fff; border: 1px solid #eaecf0; padding: 4px; border-radius: 2px; }
#ill-buttons { text-align: right; padding: 10px; border-top: 1px solid #a2a9b1; margin-top: 10px; }
#ill-buttons button { margin-left: 10px; cursor: pointer; padding: 6px 16px; border-radius: 2px; border: 1px solid #a2a9b1; transition: all 0.2s; }
#ill-ok { background: #36c; color: #fff; border-color: #36c !important; font-weight: bold; }
#ill-ok:hover { background: #447ff5; }
#ill-ok:disabled { background: #eaecf0; color: #72777d; border-color: #c8ccd1 !important; cursor: not-allowed; opacity: 0.7; }
#ill-table { width: 100%; border-collapse: collapse; margin-top: 10px; background: white; }
#ill-table th { background: #f2f2f2; text-align: left; padding: 8px; border: 1px solid #a2a9b1; }
#ill-table td { padding: 8px; border: 1px solid #a2a9b1; vertical-align: top; }
#ill-table caption { background: #eaffea; font-weight: bold; padding: 10px; border: 1px solid #a2a9b1; border-bottom: none; font-size: 1.1em; }
#ill-help { font-size: 0.85em; color: #54595d; padding: 10px; line-height: 1.4; }
.ill-loading-inline { font-style: italic; color: #72777d; font-size: 0.9em; display: block; margin: 5px 0; }
`,
setup() {
if (!["edit", "submit"].includes(mw.config.get('wgAction'))) return;
mw.loader.addStyleTag(this.css);
const link = mw.util.addPortletLink('p-cactions', '#', 'IllWill', 'ca-illwill', "Add interlanguage links via Wikidata");
if (link) {
link.addEventListener('click', (e) => {
e.preventDefault();
this.makePanel();
});
}
},
norm(s) {
if (!s) return "";
const t = s.trim().replace(/[\s_]+/g, " ");
return t.charAt(0).toUpperCase() + t.slice(1);
},
async getRedLinks($textbox) {
const txt = $textbox.textSelection('getContents') || "";
const wl = txt.match(/\[\[[^[\]\n]+\]\]/g);
if (!wl) return {
reds: [],
txt
};
const api = new mw.Api();
const textToParse = wl.join("").replace(/\|[^\]]+/g, "");
try {
const data = await api.post({
action: 'parse',
text: textToParse,
contentmodel: 'wikitext',
formatversion: 2
});
const tempDiv = document.createElement('div');
tempDiv.innerHTML = data.parse.text;
const reds = Array.from(tempDiv.querySelectorAll("a.new")).map(x => {
const urlParams = new URLSearchParams(x.href.split('?')[1]);
return urlParams.get('title')?.replace(/_/g, " ");
}).filter(Boolean);
return {
reds: [...new Set(reds)],
txt
};
} catch (e) {
return {
reds: [],
txt
};
}
},
async makePanel() {
const $editform = $('#editform');
const $textbox = $('#wpTextbox1');
$('#illwill').remove();
$editform.hide();
const $wrapper = $('<div id="illwill">Loading redlinks...</div>').insertBefore($editform);
const {
reds,
txt
} = await this.getRedLinks($textbox);
const wtLinks = [];
reds.forEach(title => {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s/g, "[\\s_]+");
const re = new RegExp("\\[\\[\\s*" + escaped + "\\s*(?:\\|[^\\[\\]]+)?\\]\\]", "ig");
const match = txt.match(re);
if (match && !wtLinks.some(z => z.title === title)) {
wtLinks.push({
link: match[0],
title
});
}
});
if (!wtLinks.length) {
const $back = $('<button>Quit</button>').on('click', () => {
$wrapper.remove();
$editform.show();
});
$wrapper.empty().append($('<strong>No valid redlinks found in text.</strong> '), $back);
return;
}
const $table = $(`
<table id="ill-table" class="wikitable">
<caption>IllWill.js (v${this.Version})</caption>
<thead>
<tr>
<th class="ill-x"><input type="checkbox" id="ill-select-all" disabled></th>
<th>Input link</th>
<th>Possible Wikidata matches</th>
<th>Sitelinks (top ${this.Config.MaxLanguages} by size)</th>
<th>Resulting wikitext</th>
</tr>
</thead>
<tbody></tbody>
<tfoot>
<tr>
<td colspan="3" id="ill-help">Carefully examine possible Wikidata matches.</td>
<td colspan="2" id="ill-buttons">
<button id="ill-cancel">Cancel</button>
<button id="ill-ok" disabled>Show changes</button>
</td>
</tr>
</tfoot>
</table>
`);
const $tbody = $table.find('tbody');
wtLinks.forEach((item, i) => {
$tbody.append(`
<tr id="ill-row-${i}" data-index="${i}">
<td class="ill-x"><input type="checkbox" class="ill-row-check" disabled></td>
<td class="ill-input">${this.escapeHTML(item.link)}</td>
<td class="ill-wd-item"><span class="ill-loading-inline">Searching...</span></td>
<td class="ill-sitelinks"></td>
<td class="ill-output"></td>
</tr>
`);
});
$wrapper.empty().append($table);
const chunkSize = 5;
for (let i = 0; i < wtLinks.length; i += chunkSize) {
const chunk = wtLinks.slice(i, i + chunkSize);
await Promise.all(chunk.map((item, idx) => this.fetchWikidataOptimized(item.title, i + idx)));
}
$table.on('click', '#ill-cancel', () => {
$editform.show();
$wrapper.remove();
});
$table.on('click', '#ill-ok', () => this.onOkButton($editform, $wrapper, $textbox));
$table.on('change', '#ill-select-all', (e) => {
const isChecked = $(e.target).is(':checked');
$table.find('.ill-row-check:not(:disabled)').each(function() {
$(this).prop('checked', isChecked).trigger('change');
});
});
$table.on('change', '.ill-row-check', (e) => {
this.onCheckbox(e);
this.updateOkButtonState($table);
});
$table.on('change', 'input[type="radio"]', (e) => {
this.showSiteLinks(e);
});
},
async fetchWikidataOptimized(title, index) {
const query = title.replace(this.Disambiguator, "");
const url = `${this.DataApi}?action=wbsearchentities&search=${encodeURIComponent(query)}&language=${this.LocalLang}&limit=5&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
const results = data.search;
const $cell = $(`#ill-row-${index} .ill-wd-item`);
if (!results || !results.length) {
$cell.html('<span class="ill-na">(no results)</span>');
return;
}
const entityIds = results.map(r => r.id);
await this.fetchEntities(entityIds, index, $cell);
} catch (e) {
$(`#ill-row-${index} .ill-wd-item`).text("Search failed.");
}
},
async fetchEntities(ids, rowIndex, $cell) {
const url = `${this.DataApi}?action=wbgetentities&ids=${ids.join('|')}&props=labels|descriptions|claims|sitelinks&format=json&origin=*`;
try {
const resp = await fetch(url);
const data = await resp.json();
$cell.empty();
ids.forEach(qNum => {
const entity = data.entities[qNum];
if (!entity) return;
const isScientific = entity.claims?.P31?.some(s => s.mainsnak?.datavalue?.value?.id === "Q13442814");
if (isScientific && this.Config.IgnoreScientific === HIDE) return;
const desc = entity.descriptions?.[this.LocalLang]?.value || entity.descriptions?.en?.value || "";
const label = entity.labels?.[this.LocalLang]?.value || entity.labels?.en?.value || qNum;
const isStrikeout = isScientific && this.Config.IgnoreScientific === STRIKEOUT;
const $div = $(`
<div class="${isStrikeout ? 'illwill-ignore' : ''}">
<input type="radio" name="selection-${rowIndex}" value="${qNum}" id="radio-${qNum}" ${isStrikeout ? 'disabled' : ''}>
<label for="radio-${qNum}">
<a href="https://wikidata.org/wiki/${qNum}" target="_blank">${this.escapeHTML(label)}</a>:
<span class="ill-desc">${desc ? this.escapeHTML(desc) : '<i>no description</i>'}</span>
</label>
</div>
`);
$cell.append($div);
this.SiteLinks[qNum] = entity.sitelinks || {};
});
} catch (e) {
$cell.text("Detail fetch failed.");
}
},
updateOkButtonState($table) {
const $rows = $table.find('.ill-row-check:not(:disabled)');
const $checkedRows = $rows.filter(':checked');
const anyEnabled = $rows.length > 0;
$table.find('#ill-ok').prop('disabled', $checkedRows.length === 0);
const $master = $table.find('#ill-select-all');
$master.prop('disabled', !anyEnabled);
if (anyEnabled && $rows.length === $checkedRows.length) {
$master.prop('checked', true);
} else {
$master.prop('checked', false);
}
},
async showSiteLinks(e) {
const isManualToggling = e.fromCheckbox || false;
const qNum = e.target.value;
const $row = $(e.target).closest('tr');
const sitelinks = this.SiteLinks[qNum];
const $checkbox = $row.find('.ill-row-check');
const $sitelinkCell = $row.find('.ill-sitelinks');
const $outputCell = $row.find('.ill-output');
const $table = $('#ill-table');
const effectiveMax = Math.min(Math.max(this.Config.MaxLanguages, 3), 6);
const validKeys = Object.keys(sitelinks).filter(k => k.endsWith('wiki') && !k.includes('commons'));
$sitelinkCell.html('<span class="ill-loading-inline">Measuring articles...</span>');
$outputCell.empty();
if (!isManualToggling) {
$checkbox.prop('disabled', true).prop('checked', false);
}
const candidates = validKeys.slice(0, 25);
const sizeMap = [];
await Promise.all(candidates.map(async (key) => {
const lang = key.replace('wiki', '').replace(/_/g, '-');
const title = sitelinks[key].title;
const endpoint = `https://${lang}.wikipedia.org/w/api.php?action=query&titles=${encodeURIComponent(title)}&prop=info&format=json&origin=*`;
try {
const res = await fetch(endpoint).then(r => r.json());
const page = Object.values(res.query.pages)[0];
sizeMap.push({
lang,
title,
length: page.length || 0
});
} catch (err) {
sizeMap.push({
lang,
title,
length: 0
});
}
}));
sizeMap.sort((a, b) => b.length - a.length);
const topLinks = sizeMap.slice(0, effectiveMax);
$sitelinkCell.html(topLinks.map(item => {
const kbValue = Math.round(item.length / 1024);
return `<div><a href="https://${item.lang}.wikipedia.org/wiki/${encodeURIComponent(item.title)}" target="_blank">${item.lang}:${this.escapeHTML(item.title)}</a> <span class="ill-size-tag">(${kbValue} kB)</span></div>`;
}).join(""));
const inputWikitext = $row.find('.ill-input').text();
const match = inputWikitext.match(/\[\[(.*?)\]\]/);
if (match) {
const parts = match[1].split('|');
const targetPage = parts[0].trim();
const surfaceName = (parts[1] || parts[0]).trim();
const illLangs = topLinks.map(item => {
const isSameTitle = this.norm(item.title) === this.norm(targetPage);
return `|${item.lang}|${isSameTitle ? '' : item.title}`;
}).join("");
const ltParam = (this.norm(surfaceName) === this.norm(targetPage)) ? "" : `|lt=${surfaceName}`;
$outputCell.text(`{{ill|${targetPage}${ltParam}${illLangs}}}`);
}
$checkbox.prop('disabled', !topLinks.length);
if (!isManualToggling) {
$checkbox.prop('checked', !!topLinks.length);
}
this.updateOkButtonState($table);
},
onCheckbox(e) {
const $row = $(e.target).closest('tr');
const $radio = $row.find('input[type="radio"]:checked');
const isChecked = $(e.target).is(':checked');
if (isChecked) {
if ($radio.length) {
this.showSiteLinks({
target: $radio[0],
fromCheckbox: true
});
}
} else {
$row.find('.ill-sitelinks, .ill-output').empty();
}
},
onOkButton($editform, $wrapper, $textbox) {
let text = $textbox.textSelection('getContents');
let changed = false;
$wrapper.find('.ill-row-check:checked').each(function() {
const $row = $(this).closest('tr');
const input = $row.find('.ill-input').text();
const output = $row.find('.ill-output').text();
if (output) {
const escapedInput = input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
text = text.replace(new RegExp(escapedInput, "gi"), output);
changed = true;
}
});
if (changed) {
$('#wpSummary').val((i, v) => (v ? v + "; " : "") + this.Summary);
$textbox.textSelection('setContents', text);
$wrapper.remove();
$editform.show();
if (this.Config.AutoDiff) $('#wpDiff').click();
}
},
escapeHTML(str) {
return str.replace(/[&<>"']/g, m => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
})[m]);
}
};
window.IllWill = IllWill;
mw.loader.using(['mediawiki.util', 'mediawiki.api', 'jquery.textSelection'], () => {
$(() => IllWill.setup());
});
})();
(function() {
'use strict';
const config = {
enableNS14: true,
rootWhitelists: ['Materials'],
userWhitelist: ""
};
const categoryDataCache = {};
let scanProgress = 0;
let totalToScan = 0;
async function verifyTreeMembership() {
if (!config.enableNS14) return false;
const api = new mw.Api();
const currentTitle = mw.config.get("wgPageName").replace(/_/g, ' ');
const fullRoots = config.rootWhitelists.concat(config.userWhitelist.split(',').map(s => s.trim()).filter(s => s)).map(cat => (cat.startsWith('Category:') ? cat : `Category:${cat}`));
if (fullRoots.includes(currentTitle)) return true;
let categoriesToTable = mw.config.get('wgCategories') || [];
let checked = new Set();
while (categoriesToTable.length > 0) {
if (categoriesToTable.some(cat => fullRoots.includes('Category:' + cat))) {
return true;
}
categoriesToTable.forEach(cat => checked.add(cat));
try {
const res = await api.get({
formatversion: 2,
prop: 'categories',
titles: categoriesToTable.map(c => 'Category:' + c),
cllimit: 'max'
});
let nextBatch = [];
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.categories) {
page.categories.forEach(parent => {
const parentName = parent.title.substring('Category:'.length);
if (!checked.has(parentName)) {
nextBatch.push(parentName);
}
});
}
});
}
categoriesToTable = [...new Set(nextBatch)];
} catch (e) {
return false;
}
}
return false;
}
function getChemboxBlock(wikitext) {
const chemboxRegex = /\{\{\s*Chembox[\s_]*(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const match = wikitext.match(chemboxRegex);
return match ? match[0] : null;
}
function getFormulaFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const formulaMatch = chembox.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
let rawFormula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
rawFormula = rawFormula.split(/\s+/)[0];
return rawFormula.split(',')[0].trim();
}
const propRegex = /\{\{\s*Chembox[\s_]+Properties(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const propSection = chembox.match(propRegex);
if (propSection) {
const content = propSection[0];
const allElements = ['Ac', 'Ag', 'Al', 'Am', 'Ar', 'As', 'At', 'Au', 'B', 'Ba', 'Be', 'Bh', 'Bi', 'Bk', 'Br', 'C', 'Ca', 'Cd', 'Ce', 'Cf', 'Cl', 'Cm', 'Cn', 'Co', 'Cr', 'Cs', 'Cu', 'D', 'Db', 'Ds', 'Dy', 'Er', 'Es', 'Eu', 'F', 'Fe', 'Fl', 'Fm', 'Fr', 'Ga', 'Gd', 'Ge', 'H', 'He', 'Hf', 'Hg', 'Ho', 'Hs', 'I', 'In', 'Ir', 'K', 'Kr', 'La', 'Li', 'Lr', 'Lu', 'Lv', 'Mc', 'Md', 'Mg', 'Mn', 'Mo', 'Mt', 'N', 'Na', 'Nb', 'Nd', 'Ne', 'Nh', 'Ni', 'No', 'Np', 'O', 'Og', 'Os', 'P', 'Pa', 'Pb', 'Pd', 'Pm', 'Po', 'Pr', 'Pt', 'Pu', 'Ra', 'Rb', 'Re', 'Rf', 'Rg', 'Rh', 'Rn', 'Ru', 'S', 'Sb', 'Sc', 'Se', 'Sg', 'Si', 'Sm', 'Sn', 'Sr', 'Ta', 'Tb', 'Tc', 'Te', 'Th', 'Ti', 'Tl', 'Tm', 'Ts', 'U', 'V', 'W', 'Xe', 'Y', 'Yb', 'Zn', 'Zr'];
let data = {};
allElements.forEach(el => {
const elRegex = new RegExp(`\\|\\s*${el}\\s*=\\s*(\\d*\\.?\\d+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch && elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
});
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
elementsPresent.filter(el => !(hasC && (el === "C" || el === "H"))).sort().forEach(el => {
formulaArray.push(el + data[el]);
});
return formulaArray.join('');
}
}
return null;
}
function getCASFromWikitext(wikitext) {
const chembox = getChemboxBlock(wikitext);
if (!chembox) return null;
const idRegex = /\{\{\s*Chembox[\s_]+Identifiers(?:[^{}]|\{\{(?:[^{}]|\{\{[^{}]*\}\})*\}\})*\}\}/i;
const idSection = chembox.match(idRegex);
if (!idSection) return null;
const content = idSection[0];
const nbsp = String.fromCharCode(160);
const casMatch = content.match(/\|\s*CASNo\d*\b(?!\s*_)\s*=\s*([^|}\n]+)/i);
if (casMatch) {
let val = casMatch[1].replace(/\{\{[^}]+\}\}/g, "").replace(/\u00AD/g, "").split(" ").join("");
while (val.indexOf(nbsp) !== -1) val = val.replace(nbsp, "");
while (val.indexOf(" ") !== -1) val = val.replace(" ", "");
const cleanedCas = val.trim();
return /^\d{2,7}-\d{2}-\d$/.test(cleanedCas) ? cleanedCas : null;
}
return null;
}
async function getWikidataValue() {
const qid = mw.config.get('wgWikidataItemId');
if (!qid) return null;
const api = new mw.ForeignApi('https://www.wikidata.org/w/api.php');
const res = await api.get({
action: 'wbgetentities',
ids: qid,
props: 'claims',
format: 'json',
formatversion: 2
});
const claims = res.entities[qid].claims;
const data = {};
if (claims.P274) data.formula = claims.P274[0].mainsnak.datavalue.value;
if (claims.P231) data.cas = claims.P231[0].mainsnak.datavalue.value;
return data;
}
async function startBackgroundCategoryScan() {
const api = new mw.Api();
const titles = $('#mw-pages li a').map((i, el) => $(el).attr('title') || $(el).text()).get();
totalToScan = titles.length;
if (totalToScan === 0) return;
const chunks = arrayChunk(titles, 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: chunk,
rvprop: 'content',
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(page => {
if (page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const f = getFormulaFromWikitext(wikitext);
const c = getCASFromWikitext(wikitext);
if (f || c) categoryDataCache[page.title] = {
formula: f,
cas: c
};
});
}
scanProgress += chunk.length;
$('#caschemassist-cat-btn').text(`Scanning... ${scanProgress}/${totalToScan}`);
}
$('#caschemassist-cat-btn').text('Show missing CAS/Chem').prop('disabled', false);
}
async function renderCategoryAudit() {
const api = new mw.Api();
const missingCheckTitles = [];
Object.values(categoryDataCache).forEach(data => {
if (data.formula) missingCheckTitles.push(data.formula);
if (data.cas) missingCheckTitles.push(data.cas);
});
if (missingCheckTitles.length === 0) {
mw.notify("No valid Chembox data found in this category.");
return;
}
const existenceMap = {};
const chunks = arrayChunk([...new Set(missingCheckTitles)], 50);
for (const chunk of chunks) {
const res = await api.get({
action: 'query',
titles: chunk,
formatversion: 2
});
if (res.query && res.query.pages) {
res.query.pages.forEach(p => {
existenceMap[p.title] = !p.missing;
});
}
}
$('#mw-pages li').each(function() {
const title = $(this).find('a').attr('title') || $(this).find('a').text().trim();
const data = categoryDataCache[title];
if (!data) return;
if (data.formula && !existenceMap[data.formula]) {
$(this).append($('<span>').css({
'color': 'red',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [${data.formula}]`));
}
if (data.cas && !existenceMap[data.cas]) {
$(this).append($('<span>').css({
'color': 'darkred',
'font-size': '0.85em',
'margin-left': '8px',
'font-weight': 'bold'
}).text(` [CAS: ${data.cas}]`));
}
});
$('#caschemassist-cat-btn').text('Audit complete').prop('disabled', true);
}
async function handleSingleArticle() {
const api = new mw.Api();
const currentTitle = mw.config.get("wgTitle");
const [wikiRes, wikidata] = await Promise.all([
api.get({
action: 'query',
prop: 'revisions',
titles: mw.config.get("wgPageName"),
rvprop: 'content',
formatversion: 2
}),
getWikidataValue()
]);
const page = wikiRes.query.pages[0];
if (!page || page.missing || !page.revisions) return;
const wikitext = page.revisions[0].content;
const formula = getFormulaFromWikitext(wikitext);
const cas = getCASFromWikitext(wikitext);
if (wikidata) {
let mismatches = [];
if (formula && wikidata.formula && formula !== wikidata.formula) {
mismatches.push(`Formula mismatch: Chembox "${formula}" vs Wikidata "${wikidata.formula}"`);
}
if (cas && wikidata.cas && cas !== wikidata.cas) {
mismatches.push(`CAS mismatch: Chembox "${cas}" vs Wikidata "${wikidata.cas}"`);
}
if (mismatches.length > 0) {
const $warning = $('<div>').css({
'background': '#fff2f2',
'border': '2px solid #d33',
'padding': '10px',
'margin-bottom': '1em',
'color': '#b32424'
}).append($('<strong>').text('⚠️ Data Mismatch Detected: '), mismatches.join(' | '));
$('#mw-content-text').prepend($warning);
}
}
async function checkAndRender(target, label, customWikitext) {
const check = await api.get({
action: 'query',
titles: target,
formatversion: 2
});
if (check.query.pages[0].missing) {
const $container = $('<div>').css({
'background': '#f8f9fa',
'border': '1px solid #a2a9b1',
'padding': '10px',
'margin-bottom': '1em'
});
const $btn = $('<button>').text(`Create redirect: ${target}`).click(async function() {
await api.postWithEditToken({
action: 'edit',
title: target,
text: customWikitext,
summary: `Creating redirect to [[${currentTitle}]] (${label})`,
createonly: true
});
mw.notify(`Created redirect: ${target}`);
$container.fadeOut(400, function() {
$(this).remove();
});
});
$container.append($('<strong>').text(`${label}: `), `Missing redirect. `, $btn);
$('#mw-content-text').prepend($container);
}
}
if (formula && formula !== currentTitle) {
const formulaWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from chemical formula}}\n}}`;
await checkAndRender(formula, "Chemical formula", formulaWikitext);
}
if (cas && cas !== currentTitle) {
const casWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from CAS Registry Number}}\n{{R unprintworthy}}\n}}`;
await checkAndRender(cas, "CAS number", casWikitext);
}
}
function arrayChunk(arr, size) {
const result = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
$(document).ready(async function() {
const ns = mw.config.get("wgNamespaceNumber");
if (ns === 14) {
if (await verifyTreeMembership()) {
const $catContainer = $('#mw-pages');
if ($catContainer.length) {
const $btn = $('<button>').text('Initializing scan...').attr('id', 'caschemassist-cat-btn').addClass('mw-ui-button').css({
'margin': '0 10px 10px 0',
'float': 'right',
'position': 'relative',
'z-index': '100'
}).prop('disabled', true).click(renderCategoryAudit);
$catContainer.prepend($btn);
startBackgroundCategoryScan();
}
}
} else if (ns === 0 && mw.config.get("wgAction") === "view") {
handleSingleArticle();
}
});
})();
//</nowiki>
awkfesbpp7d7f5lvkv2qu3bta6rjvni
Jupiter
0
102293
739260
620517
2026-04-24T13:23:42Z
~2026-23062-17
73557
showcaptcha
739260
wikitext
text/x-wiki
{| class="wikitable"
|+
!Nom
!Liens
!Nombre d'occurrences
!Changements depuis la semaine dernière
|-
| Baidu Baike || baike.baidu.com, baike.baidu.hk || 0 || +0
|}
this is an edit
cp8ytu7opdizu1269ozytcqczoe6bi7
Missing hl
0
118450
739236
463075
2026-04-24T12:10:22Z
~2026-23062-17
73557
showcaptcha
739236
wikitext
text/x-wiki
Test page
----
Is there an hl up there?
this is an edit
klfk1p3rbeqm7c36n7ldpgdg234g50k
Mwbot-rs/Save
0
122864
739237
738365
2026-04-24T12:10:59Z
Mwbot-rs test
52001
Test suite edit
739237
wikitext
text/x-wiki
It has been 1777032658 seconds since the epoch.
mctzluz6d26n25lr0rryn4y57o8wc4l
739239
739237
2026-04-24T12:11:15Z
Mwbot-rs test
52001
Test suite edit
739239
wikitext
text/x-wiki
It has been 1777032674 seconds since the epoch.
l0nn6iop5joffi3gz1qlpk2g55eegcz
User:SongVĩ.Bot II
2
124239
739271
739207
2026-04-24T17:00:14Z
SongVĩ.Bot II
52414
[[User:SongVĩ.Bot II|Task 0]]: Đã 1579 ngày...
739271
wikitext
text/x-wiki
Cập nhật lần cuối: 25-04-2026
Đã 1579 ngày...
09jkj7kcc3yq2e157rjyt8hwsnc0c2a
Wikipedia:Village pump/topic list
4
146208
739267
739215
2026-04-24T15:23:04Z
Cewbot
33876
[[User:Cewbot/log/20170915/configuration|Generate topic list: 6 topics]]
739267
wikitext
text/x-wiki
<!-- This page is auto-generated by bot. Please contact the bot operator to improve the tool. -->
{| class="wikitable sortable mw-collapsible" style="float:left;"
|-
! data-sort-type="number" style="font-weight: normal;" | <small>#</small> !! 💭 Title !! <span title="Count of comments">💬</span> !! <span title="Count of peoples in discussion">👥</span> !! 🙋 Last editor !! data-sort-type="isoDate" | <span title="Date/Time">🕒 <small>(UTC)</small></span>
|-
| style="text-align: right;" | 1
| [[Wikipedia:Village pump#Script|Script]]
| style="text-align: right;" | 8
| style="text-align: right;" | 5
| style="background-color: #bbb;" | [[User:LuniZunie|LuniZunie]]
| style="background-color: #bbb;" data-sort-type="isoDate" data-sort-value="2025-11-09T16:47:00.000Z" | 2025-11-09 <span style="color: blue;">16:47</span>
|-
| style="text-align: right;" | 2
| [[Wikipedia:Village pump#Report_concerning_Tanbiruzzammn|Report concerning Tanbiruzzammn]]
| style="text-align: right;" | 2
| style="text-align: right;" | 2
| style="background-color: #bbb;" | [[User:Barras|Barras]]
| style="background-color: #bbb;" data-sort-type="isoDate" data-sort-value="2025-12-09T21:45:00.000Z" | 2025-12-09 <span style="color: blue;">21:45</span>
|-
| style="text-align: right;" | 3
| [[Wikipedia:Village pump#Report_concerning_Bucheon|Report concerning Bucheon]]
| style="text-align: right;background-color: #fcc;" | 1
| style="text-align: right;background-color: #fcc;" | 1
| style="background-color: #bbb;" | [[User:PieWriter|PieWriter]]
| style="background-color: #bbb;" data-sort-type="isoDate" data-sort-value="2026-02-19T10:25:00.000Z" | 2026-02-19 <span style="color: blue;">10:25</span>
|-
| style="text-align: right;" | 4
| [[Wikipedia:Village pump#Versions_and_dates|Versions and dates]]
| style="text-align: right;" | 2
| style="text-align: right;background-color: #fcc;" | 1
| style="background-color: #bbb;" | [[Special:Contributions/~2026-13668-13|<span style="color: #c20;">~2026-13668-13</span>]]
| style="background-color: #bbb;" data-sort-type="isoDate" data-sort-value="2026-03-03T06:17:00.000Z" | 2026-03-03 <span style="color: blue;">06:17</span>
|-
| style="text-align: right;" | 5
| style="max-width: 24em" | <small>[[Wikipedia:Village pump#Upcoming_Wikimedia_Café_meetup_regarding_the_the_2026-2027_Wikimedia_Foundation_Annual_Plan|Upcoming Wikimedia Café meetup regarding the the 2026-2027 Wikimedia Foundation Annual Plan]]</small>
| style="text-align: right;background-color: #fcc;" | 1
| style="text-align: right;background-color: #fcc;" | 1
| style="background-color: #ddd;" | [[User:Pine|Pine]]
| style="background-color: #ddd;" data-sort-type="isoDate" data-sort-value="2026-03-30T03:46:00.000Z" | 2026-03-30 <span style="color: blue;">03:46</span>
|-
| style="text-align: right;" | 6
| [[Wikipedia:Village pump#Changes_to_electionadmin_userright|Changes to electionadmin userright]]
| style="text-align: right;" | 4
| style="text-align: right;" | 4
| [[User:Chaotic Enby|Chaotic Enby]]
| data-sort-type="isoDate" data-sort-value="2026-04-23T15:22:00.000Z" | 2026-04-23 <span style="color: blue;">15:22</span>
|}
{| class="wikitable mw-collapsible mw-collapsed" style="float: left; margin-left: .5em;;{{#if:{{{no_time_legend|}}}|display:none;|}}"
! title="From the latest bot edit" | Legend
|-
| style="background-color: #efe;" |
* In the last hour
|-
| style="background-color: #eef;" |
* In the last day
|-
| |
* In the last week
|-
| style="background-color: #ddd;" |
* In the last month
|-
| style="background-color: #bbb;" |
* More than one month
|-
! Manual settings
|-
| style="max-width: 12em;" | <small>When exceptions occur,<br />please check [[User:Cewbot/log/20170915/configuration|the setting]] first.</small>
|-
|}
{{Clear}}
tlxg3wcsu66pp2w0yqmp59tgres118k
Vorlage:Coordinate/to DMS
0
148892
739294
564218
2026-04-25T00:03:43Z
InternetArchiveBot
34092
Rescuing 1 sources and tagging 0 as dead.) #IABot (v2.0.9.5
739294
wikitext
text/x-wiki
{{Infobox Gemeinde in Deutschland
|Art = Stadt
|Wappen = Reinbek Wappen.svg
|Breitengrad = 53.510211
|Längengrad = 10.250318
|Lageplan = Reinbek in OD.svg
|Bundesland = Schleswig-Holstein
|Kreis = Stormarn
|Höhe = 27
|PLZ = 21465
|Vorwahl = 040, 04104
|Gemeindeschlüssel = 01062060
|LOCODE = DE REI
|Gliederung = [[Liste der Bezirke und Stadtteile Reinbeks|6 statistische Bezirke und 22 Stadtteile]]
|Straße = Hamburger Straße 5–7
|Website = [https://www.reinbek.de/ www.reinbek.de]
|Bürgermeister = [[Björn Warmer]]
|Partei = SPD
}}
'''Reinbek''' ([[Niederdeutsche Sprache|niederdeutsch]] ''Reinbeek''), in der südlichen [[Geest]] [[Schleswig-Holstein]]s gelegen, ist mit etwa 28.000 Einwohnern die zweitgrößte Stadt im [[Kreis Stormarn]]. Die [[Mittelstadt]] liegt im östlichen Ballungsraum [[Hamburg]]s und gehört zur [[Metropolregion Hamburg]].
== Geografie ==
Die Ost- und Südgrenze Reinbeks bildet die zum ''Mühlenteich'' aufgestaute, naturgeschützte [[Bille]]. Die zwischen den Ortsteilen liegenden Flächen werden zum Teil noch landwirtschaftlich genutzt. Geprägt vom angrenzenden [[Sachsenwald]], bietet Reinbek ein grünes, erholsames Stadtbild. Ein Großteil der Stadt ist mit Einzelhäusern bebaut, das Gebiet rings um den Täby-Platz und das Paul Luckow-Stadion besteht zum großen Teil aus mehrstöckigen Mietshäusern, die im Stil der 1960er Jahre erbaut wurden. Das höchste von ihnen, das Sachsenwald-Hochhaus mit 20 Stockwerken, befindet sich in der Hamburger Straße. Zu Reinbek gehören die Stadtteile ''Alt-Reinbek'', [[Hinschendorf]], [[Schönningstedt]], [[Neuschönningstedt]], [[Ohe (Reinbek)|Ohe]] mit [[Büchsenschinken]] und das jüngere Neubaugebiet [[Krabbenkamp]] (→[[Liste der Bezirke und Stadtteile Reinbeks]]). Direkt angrenzend liegen die Hamburger Stadtteile [[Hamburg-Bergedorf|Bergedorf]] und [[Hamburg-Lohbrügge|Lohbrügge]].
== Geschichte ==
{{Hauptartikel|Geschichte der Stadt Reinbek}}
Von der Besiedlung des heutigen Reinbeker Gebietes in bereits vorgeschichtlicher Zeit zeugen zahlreiche [[Hügelgrab|Hügelgräber]]. Die erste urkundlich überlieferte Erwähnung Reinbeks datiert allerdings erst auf das Jahr 1238 und geht auf die Gründung des gleichnamigen [[Zisterzienserinnen]]klosters (siehe [[Kloster Reinbek]]) zurück. Die ältesten bekannten Schreibformen des Ortsnamens sind ''(ville) Reinebec'' (1238), ''(in) Reynebeke'' (1309 und 1350), ''(to deme) Reynenbeke'' (1400) und ''(tome) Rynenbeke'' (1466); der Name wird als Kompositum aus dem Grundwort ''bek'' für „Bach“ und dem Adjektiv „rein“ als Bestimmungswort gedeutet.<ref>[[Wolfgang Laur]]: ''Historisches Ortsnamenlexikon von Schleswig-Holstein'', 2. Aufl., S. 538.</ref> Nach der Zerstörung des Klosters (1534) gewann der Ort erst mit dem Bau der Schlossanlage (1572) wieder an Bedeutung.
[[Datei:Mühlenteich und Reinbeker Schloss im Winter.jpg|mini|Der Mühlenteich und das Reinbeker Schloss im Winter]]
Die Ansiedlung von Handwerkern im späten 18. Jahrhundert brachte endlich wirtschaftliches Wachstum. Einen entscheidenden Impuls für die Entwicklung des Ortes gab jedoch der Bau der Eisenbahnstrecke zwischen Hamburg und [[Berlin]] (1846): Reinbek wurde vorübergehend zum Kurort und beliebten Ausflugsziel. Die alte Schreibweise „Reinbeck“ wurde am 1. September 1877 durch eine Anordnung über die einheitliche Regelung der Schreibweise für Ortsnamen von der Provinzialregierung in Schleswig in „Reinbek“ geändert.
Zum Ende des [[Zweiter Weltkrieg|Zweiten Weltkrieges]] wurde Deutschland schrittweise besetzt. Am 3. Mai 1945 besetzten britischen Truppen auch Reinbek, das benachbarte [[Glinde]] sowie den letzten Teil des noch unbesetzten [[Kreis Stormarn|Stormarns]].<ref>[[Hamburger Abendblatt]]: [http://www.abendblatt.de/region/stormarn/article205288543/Vor-siebzig-Jahren-kapitulierte-die-Stadt-Ahrensburg.html Kriegsende. Vor siebzig Jahren kapitulierte die Stadt Ahrensburg], vom: 2. Mai 2015; abgerufen am: 31. Mai 2017</ref> Des Weiteren begann am Nachmittag des Tages auch die Besetzung [[Hamburg]]s, die zuvor in der [[Villa Möllering]] bei [[Lüneburg]] vereinbart worden war. Einen Tag später unterschrieb zudem [[Hans-Georg von Friedeburg]] im Auftrag des letzten [[Reichspräsident]]en [[Karl Dönitz]], der sich zuvor mit der [[Regierung Dönitz|letzten Reichsregierung]] in den [[Sonderbereich Mürwik]] abgesetzt hatte, die [[Teilkapitulation der Wehrmacht für Nordwestdeutschland, Dänemark und die Niederlande]].<ref>[https://web.archive.org/web/20131104080252/http://www.volksbund.de/fileadmin/redaktion/BereichInfo/Textsammlungen/Ausstellungen/0400_ausstellung_timeloberg/Timeloberg.pdf Die Kapitulation auf dem Timeloberg] (PDF, 16. S.; 455 kB)</ref> Die [[Bedingungslose Kapitulation der Wehrmacht]] folgte am 8. Mai 1945. Zum Kriegsende erlebte Reinbek einen verstärkten Zuzug von Flüchtlingen und durch Kriegseinwirkung obdachlos gewordenen Hamburgern.
Seit den 1960er Jahren wurden mehrere Gewerbegebiete erschlossen und erweitert. Am 28. Juni 1952 erhielt Reinbek das [[Stadtrecht]]. Am 1. Januar 1974 wurden die Gemeinde [[Schönningstedt]] (mit [[Neuschönningstedt]] und [[Ohe (Reinbek)|Ohe]]) sowie ein Teil der Gemeinde Glinde mit damals etwa 100 Einwohnern und ein Teil der aufgelösten Gemeinde Stemwarde eingegliedert.<ref>{{Literatur | Herausgeber = Statistisches Bundesamt | Titel = Historisches Gemeindeverzeichnis für die Bundesrepublik Deutschland. Namens-, Grenz- und Schlüsselnummernänderungen bei Gemeinden, Kreisen und Regierungsbezirken vom 27. Mai 1970 bis 31. Dezember 1982 | Jahr = 1983 | Verlag = W. Kohlhammer GmbH | Ort = Stuttgart/Mainz | ISBN = 3-17-003263-1 | Seiten = 186}}</ref> Im Jahre 1978 kam das bisher landwirtschaftlich genutzte Gebiet Krabbenkamp, das vormals zu Schönningstedt gehörte, als weiterer Stadtteil hinzu.
== Religion ==
Reinbek gehörte ursprünglich zum [[Kirchspiel]] [[Kirchsteinbek|Steinbek]], bis es 1894 zu einer eigenständigen [[Evangelisch-lutherische Kirchen|evangelisch-lutherischen]] [[Kirchengemeinde]] wurde. Die [[Neogotik|neogotische]] Kirche (heute Maria-Magdalenen-Kirche) wurde 1901 errichtet. 1908 gründete sich die [[Römisch-katholische Kirche|römisch-katholische]] Kirchengemeinde, die 1953 die Herz-Jesu-Kirche erbauen ließ. In Reinbek sind 44 % der Bevölkerung evangelisch und 9 % katholisch, 26 % gehören anderen Konfessionen an, 22 % sind ohne Religionszugehörigkeit. Die bedeutendsten Gemeinden der Stadt sind:
* [[Ansgar von Bremen|Ansgar]]-Kirchengemeinde Schönningstedt-Ohe (evangelisch-lutherisch)
* Kirchengemeinde [[Gethsemane]] Neuschönningstedt (evangelisch-lutherisch)
* [[Maria-Magdalenen-Kirche (Reinbek)|Maria-Magdalenen-Kirche]] (evangelisch-lutherisch)
* [[Nathan-Söderblom-Kirche (Reinbek)|Nathan-Söderblom-Kirche]] (evangelisch-lutherisch)
* [[Herz-Jesu-Kirche (Reinbek)|Herz-Jesu]]-Gemeinde (römisch-katholisch)
* [[Evangelisch-Freikirchliche Gemeinde]] ([[Baptisten]])
== Politik ==
=== Stadtvertretung ===
Die letzten drei Kommunalwahlen [[Kommunalwahlen in Schleswig-Holstein 2018|am 6. Mai 2018]], [[Kommunalwahlen in Schleswig-Holstein 2013|am 26. Mai 2013]]<ref>{{Webarchiv|url=http://www.reinbek.de/wahlen/KW2013.html |wayback=20160304064515 |text=Archivierte Kopie}}</ref> und [[Kommunalwahlen in Schleswig-Holstein 2008|am 25. Mai 2008]]<ref>{{Cite web |title=Archived copy |url=http://www.reinbek.de/files/Wahlen/GKW_25052008.pdf#page=10 |access-date=2023-02-21 |archive-date=2016-03-04 |archive-url=https://web.archive.org/web/20160304074748/http://www.reinbek.de/files/Wahlen/GKW_25052008.pdf#page=10 }}</ref> führten zu folgenden Ergebnissen:
{| class="wikitable"
| colspan="2" | '''Parteien und Wählergemeinschaften'''
| align="center" | '''%<br />2018'''
| align="center" | '''Sitze<br />2018'''
| align="center" | '''%<br />2013'''
| align="center" | '''Sitze<br />2013'''
| align="center" | '''%<br />2008'''
| align="center" | '''Sitze<br />2008'''
| rowspan="12" |{{Wahldiagramm
| LAND = DE
| TITEL = Kommunalwahl 2018
| JAHRALT = 2013
| JAHRNEU = 2018
| GUV = ja
| PARTEI1 = CDU
| ERGEBNIS1 = 27.5
| ERGEBNISALT1 = 30.7
| PARTEI3 = SPD
| ERGEBNIS3 = 20.7
| ERGEBNISALT3 = 26.6
| PARTEI2 = GRÜNE
| ERGEBNIS2 = 22.1
| ERGEBNISALT2 = 17.2
| PARTEI5 = Forum21
| ERGEBNIS5 = 11.0
| ERGEBNISALT5 = 13.2
| FARBE5 = 0000FF
| PARTEI4 = FDP
| ERGEBNIS4 = 17.0
| ERGEBNISALT4 = 10.9
| PARTEI6 = [[Klaus-Peter Puls|Puls]]
| ERGEBNIS6 = 1.7
| ERGEBNISALT6 = 1.5
| FARBE6 = CCCCCC
}}
| rowspan="12" |{{Sitzverteilung
| Land = DE
| Überschrift = Sitzverteilung in der Stadtverordnetenversammlung
| SPD|Grüne|Forum21|Puls|FDP|CDU|
| Legende = ja
| SPD = 6
| Grüne = 7
| Forum21 = 3
| Puls = 1
| FDP = 5
| CDU = 9
| Forum21 Farbe = 0000FF
| Puls Farbe = CCCCCC
| Puls Link = [[Klaus-Peter Puls|Puls]]
}}
|- style="text-align:right"
| style="text-align:left" | CDU
| style="text-align:left" | [[Christlich Demokratische Union Deutschlands]]
| 27,5
| 9
| 30,7
| 10
| 33,6
| 13
|- style="text-align:right"
| style="text-align:left" | SPD
| style="text-align:left" | [[Sozialdemokratische Partei Deutschlands]]
| 20,7
| 6
| 26,6
| 8
| 24,3
| 9
|- style="text-align:right"
| style="text-align:left" | GRÜNE
| style="text-align:left" | [[Bündnis 90/Die Grünen]]
| 22,1
| 7
| 17,2
| 5
| 15,4
| 5
|- style="text-align:right"
| Forum21
| Forum21
| 11,0
| 3
| 13,2
| 4
| 13,0
| 4
|- style="text-align:right"
| style="text-align:left" | FDP
| style="text-align:left" | [[Freie Demokratische Partei]]
| 17,0
| 5
| 10,9
| 3
| 13,8
| 5
|- style="text-align:right"
| style="text-align:left" | Puls
| style="text-align:left" | Einzelbewerber [[Klaus-Peter Puls]]<ref>http://www.bergedorfer-zeitung.de/printarchiv/reinbek/article188444/Kommunalwahl-am-26-Mai-2013-Vorstellung-der-Reinbeker-Kandidaten-Wahlkreis-13.html</ref><ref>http://www.abendblatt.de/region/stormarn/article115057641/Klaus-Peter-Puls-tritt-aus-der-SPD-aus.html</ref>
| 1,7
| 1
| 1,5
| 1
| —
| —
|- class="hintergrundfarbe5" style="text-align:right"
| colspan="2" style="text-align:left" | '''gesamt'''
| '''100,0'''
| '''31'''
| '''100,0'''
| '''31'''
| '''100,0'''
| '''36'''
|-
! colspan="2" style="text-align:left" | Wahlbeteiligung in %
! colspan="2" |
! colspan="2" | 45,5
! colspan="2" |
|}
[[Datei:Reinbeker Rathaus.JPG|mini|Reinbeker Rathaus]]
=== Bürgermeister ===
<!-- Amtsvorgänger bitte mit Amtszeit und Partei nachtragen -->
{| class="wikitable"
! colspan="2" | Amtszeit !! rowspan="2" | Name
|-
! von !! bis
|-
| 17. Februar 1931 || 13. September 1945
| Eduard Claußen (NSDAP)<ref>[https://www.museumsverein-reinbek.de/wp-content/uploads/2018/06/Eduard-Claussen.pdf ''Eduard Claußen''], museumsverein-reinbek.de</ref><ref>Claußen half trotz seiner NSDAP-Zugehörigkeit im Rahmen seiner Möglichkeiten mehreren jüdischen Einwohnern und sorgte dafür, dass Reinbek kampflos den Engländern übergeben wurde, vgl. dazu: Detlev Landgrebe: Kückallee 37. Eine Kindheit am Rande des Holocaust. Rheinbach 2009, ISBN 978-3-87062-104-9, S. 163, S. 167 u. a.</ref>
|-
| 15. Dezember 1945 || 31. Januar 1946 || Wilhelm Kleist
|-
| 1. Februar 1946 || 22. September 1946 || Carl Dobbertin
|-
| 23. September 1946 || 11. November 1948 || Alwin Hemken
|-
| 12. November 1948 || 28. April 1950 || Carl Dobbertin
|-
| 28. April 1950 || 31. März 1951 || Wilhelm Kleist
|-
| 1. April 1951 || 31. Dezember 1971 || [[Hermann Körner]]
|-
| 1. Januar 1972 || 31. Januar 1990 || Günther Kock
|-
| 1. Februar 1990 || 31. Januar 1996 || Manfred Neumann
|-
| 1. September 1996 || 31. August 2008 || Detlef Palm
|-
| 1. September 2008 || 31. August 2014 || [[Axel Bärendorf]]
|-
| 1. September 2014 || || [[Björn Warmer]]
|}
=== Wappen ===
[[Blasonierung]]: „In Rot ein silberner Wellenbalken, begleitet von drei im Dreipass mit den Stielen einander zugekehrten Eichenblättern, und zwar zwei oben und einem unten.“<ref>[{{SH-Wappenrolle|262|Stadt Reinbek, Kreis Stormarn|nurLink=1}} Kommunale Wappenrolle Schleswig-Holstein]</ref>
Die Blätter, in ihrer Anordnung an das Wappen der Familie [[Bismarck (Adelsgeschlecht)|Bismarck]] angelehnt, versteht man als Symbole für den [[Sachsenwald]], während das Band für die [[Bille]] steht. Eine ähnliche Symbolik findet sich auf den Wappen der Nachbarorte [[Wohltorf]] und [[Aumühle]]; die Farben Rot und Weiß entsprechen den Wappen [[Holstein]]s und [[Kreis Stormarn|Stormarns]]. Das Wappen wurde 1935 genehmigt.
=== Städtepartnerschaften ===
* 1956–2011: Städtefreundschaft mit [[Täby]] ([[Schweden]]). Der Marktplatz in Reinbek-Klosterbergen, der ''Täbyplatz'', wurde nach der Partnerstadt benannt.
* Seit 1961: Städtefreundschaft mit [[Königslutter am Elm]] ([[Niedersachsen]]).
* Seit 1974: Patenschaft zwischen der [[Freiwillige Feuerwehr|Freiwilligen Feuerwehr]] Ohe und der Gemeinde [[Padasjoki]] ([[Finnland]]).
* Seit 1999: Städtepartnerschaft mit [[Koło]] ([[Polen]]).
== Kultur und Sehenswürdigkeiten ==
[[Datei:Das Reinbeker Schloss.jpg|mini|Reinbeker Schloss]]
[[Datei:Museum Rade.JPG|mini|Ehemaliges Museum Rade]]
=== Theater, Kino und Museen ===
* Das Kultur- und Kongresszentrum ''Sachsenwald-Forum'' bietet ein wechselndes Programm von Tournee- und Privattheatern.
* Der Filmring Reinbek e. V. führt ehrenamtlich monatlich eine Kinoveranstaltung in der Nathan-Söderblom-Kirche durch.
* Das gegenüber vom Schloss gelegene ''Museum Rade'' stellte die Sammlung volkstümlicher Kunst des Hamburger Schriftstellers und Kunstsammlers [[Rolf Italiaander]] aus. Seit Sommer 2017 ist das Museum dauerhaft geschlossen, die Sammlung wurde Ende 2018 ins Schloss Reinbek verlegt
[[Datei:Reinbek Sankt Maria Magdalena.JPG|hochkant|mini|Maria-Magdalenen-Kirche]]
=== Bauwerke ===
[[Datei:Reinbek dänenbrücke P4070041.JPG|mini|„Dänenbrücke“ von 1793]]
[[Datei:RK 1810 P1650787 Bismarcksäule Friedrichsruh.jpg|mini|Bismarcksäule]]
Verschont von den Zerstörungswellen des Zweiten Weltkrieges, zeigt Reinbeks Stadtarchitektur ein kontinuierliches Bild durch die Epochen norddeutscher Baugeschichte, angefangen bei der niederländischen Renaissance und alten Bauernkaten, über [[großbürger]]liche Villen der Kaiserzeit, Klinkerexpressionismus der Weimarer Republik und Wohngroßbauten der 1970er bis hin zu einer eher behutsamen Architektur der 1990er Jahre.
* Ältestes und bedeutendstes Bauwerk ist das [[Schloss Reinbek]] im Stil der [[Niederländische Renaissance|Niederländischen Renaissance]]. [[Adolf I. (Schleswig-Holstein-Gottorf)|Herzog Adolf I. von Gottorf]] ließ das Schloss zwischen 1572 und 1576 in seiner heute noch vorhandenen Form errichten. Zunächst Nebenwohnsitz des Landesherren, war das Schloss in dänischer Zeit Residenz des Amtmannes und später kurzzeitig der Sitz des Landratsamtes für den [[Kreis Stormarn]]. Heute steht das originalgetreu restaurierte Gebäude für öffentliche Nutzung zur Verfügung.
* Über die 1793 erbaute ''Dänenbrücke'', in unmittelbarer Nähe zum Schloss, verlief einst der Verkehr zwischen dem dänischen Amt Reinbek und dem [[Herzogtum Sachsen-Lauenburg]].
* Die ''Schönningstedter Mühle'', erbaut 1886, wurde seit der Stilllegung (1968) als Gaststätte betrieben. Sie wurde durch einen Brand (1991) vollständig zerstört. Sie wurde durch eine andere am Ursprungsort abgebaute auf den Grundmauern der Alten Mühle neu errichtet.
* Die [[Bismarcksäule (Friedrichsruh)|Bismarcksäule]] auf dem ''Hammelsberg'' zwischen den Ortsteilen Krabbenkamp und Schönningstedt, in der Nähe des ehemaligen bismarckschen Guts Schönau, wurde 1903 fertiggestellt. Das 19 Meter hohe Monument entspricht dem üblichen Bismarcksäulen-Typus eines Feuerturmes, den [[Wilhelm Kreis]] 1898 entworfen hatte, und wurde aus Mitteln der deutschen Studentenschaft finanziert. Der Turm steht seit 1989 unter Denkmalschutz.
* In Reinbek gibt es [[Liste der Stolpersteine in Reinbek|sieben Stolpersteine]] zur Erinnerung an Opfer des [[Nationalsozialismus]].<ref>[http://www.akens.org/akens/texte/stolpersteine/Stolpersteineliste.htm#Reinbek Stolpersteine: Reinbek]</ref>
In der [[Liste der Kulturdenkmale in Reinbek]] stehen die in der Denkmalliste des Landes Schleswig-Holstein eingetragenen Kulturdenkmale.
=== Grünflächen und Naherholung ===
* Die Wald- und Wiesenlandschaft in und um Reinbek sowie der Schlosspark laden zum Spazieren, Wandern und Radfahren ein. Auf der Bille und auf dem Mühlenteich werden Kanufahrten veranstaltet.
* Jährlich wird in Reinbek auf dem Täbyplatz oder am ''Waldhaus'' im Sommer oder im Herbst die sogenannte „Reinbeker Sommersause“ bzw. „Reinbeker Herbstsause“ gefeiert. Bei diesen Festen treten unter anderem regionale Musiker und Coverbands auf.
=== Sport ===
* Das ''Freizeitbad Reinbek'' und der angrenzende ''Sport-Park Reinbek'' bieten neben einem Hallenbad mit Außenschwimmbecken auch eine Sauna und verschiedene Sportprogramme an.
* Die [[TSV Reinbek]] und der [[FC Voran Ohe]] bieten verschiedene Sportarten an.
== Wirtschaft, Infrastruktur, öffentliche Einrichtungen ==
=== Unternehmen ===
Reinbek zeichnet sich durch eine vielfältige, vorwiegend klein- und mittelständische Wirtschaftsstruktur aus.
Zahlreiche bedeutende Firmen hatten bzw. haben hier ihren Sitz, wie zum Beispiel der [[Rowohlt Verlag]] (von 1960 bis März 2019), E. Michaelis & Co. – Papiergroßhandel, [[Almirall]] Almirall Hermal und [[Dermapharm|Allergopharma]] (die seit Mai 2021 an der Herstellung des Impfstoffs von [[Biontech]] beteiligt sind)<ref>[https://www.ndr.de/nachrichten/schleswig-holstein/coronavirus/Corona-Biontech-Impfstoff-kommt-jetzt-auch-aus-Reinbek,spahn290.html ''Corona: Biontech-Impfstoff kommt jetzt auch aus Reinbek''] {{Webarchive|url=https://web.archive.org/web/20220328133934/https://www.ndr.de/nachrichten/schleswig-holstein/coronavirus/Corona-Biontech-Impfstoff-kommt-jetzt-auch-aus-Reinbek,spahn290.html |date=2022-03-28 }}, ndr.de, 30. April 2021</ref>, [[Fürst-Bismarck-Quelle]], Grossmann-Feinkost, [[Kahl Gruppe|Amandus Kahl]] (Neuhaus Neotec), Peek & Cloppenburg (Verteilzentrum) und Lutz Aufzüge (Maschinen- und Anlagentechnik), Wollenhaupt (Teehandel). Ein weiterer großer Arbeitgeber ist das Krankenhaus Reinbek St. Adolf-Stift (Gesundheitswesen).
Anfang der 1960er Jahre wurde das gemeinsame Gewerbegebiet Reinbek-[[Glinde]] erschlossen. Seitdem erfolgten immer wieder Erweiterungen und Neuausweisungen von Gewerbeflächen. Zuletzt wurde das Gewerbegebiet Haidland vermarktet (ca. 22 ha): bis 2018 sind dort mehr als 30 Firmen angesiedelt worden, dadurch wurden 1200 Arbeitsplätze gesichert und ca. 400 neu geschaffen. Geplant ist die Erweiterung des Gewerbegebietes.
Die wirtschaftliche Dynamik Reinbeks zeigt sich unter anderem in der Entwicklung der Gewerbebetriebe: deren Zahl stieg auf 2532 Betriebe (31. August 2018).
Auch die positiven Arbeitsmarktdaten sind ein Beweis für die Besonderheit des Standortes. Im Geschäftsstellenbezirk der Arbeitsagentur Bad Oldesloe wird der Bezirk Reinbek mit einer der niedrigsten Arbeitslosenquoten aufgeführt, vergleichbar mit denen süddeutscher Wirtschaftsregionen.
In der Region Südstormarn liegen einige der Kommunen mit der höchsten Kaufkraft in Deutschland. Auch Reinbek lag im Jahr 2017 mit einer Kaufkraftkennziffer von 118 über dem Durchschnitt (CIMA Lübeck, Jahresbericht interkommunales Einzelhandelsforum 2017).
Reinbek ist perspektivisch weiter ein dynamischer Wirtschaftsstandort mit einer hohen Gewerbeflächennachfrage und steigenden Gewerbesteuereinnahmen, u. a. wegen der verkehrsgünstigen zentralen Lage in der Metropolregion direkt benachbart der Weltstadt Hamburg. Die Arbeitsplatzzentralität ist mit einem knapp 80-%-Anteil an den Beschäftigten hoch.
=== Öffentliche Einrichtungen ===
Reinbek ist Sitz eines [[Amtsgericht]]s.
=== Bildung ===
In Reinbek gibt es vier [[Grundschule]]n, eine [[Gemeinschaftsschule mit Oberstufe]] (mit auslaufenden Haupt- und Realschulklassen) und ein [[Sachsenwaldschule Gymnasium Reinbek|Gymnasium]]. Außerdem gibt es eine [[Förderschule (Deutschland)|Förderschule]].
Gemeinschaftsschule und Förderschule sind zum Schulzentrum Mühlenredder zusammengefasst.
Die ''Volkshochschule Sachsenwald'' hat ein umfangreiches Angebot an Kursen verschiedener Fachrichtungen und deckt auch das Angebot für die Nachbargemeinde [[Wentorf bei Hamburg|Wentorf]] mit ab. Die meisten Kurse finden im eigenen, gut ausgestatteten Haus mitten in Reinbek statt.
Die ''Reinbeker Stadtbibliothek'' bietet ein breit gefächertes Angebot aus alten wie neuen Medien und unterhält einen ständigen Bücherflohmarkt aus gespendeten und ausgemusterten Büchern.
Seit 1989 besteht der [[Museumsverein Reinbek|Geschichts- und Museumsverein Reinbek e. V.]]
=== Verkehr ===
[[Datei:Reinbeker Bahnhof.jpg|mini|Der Reinbeker Bahnhof]]
Reinbek liegt in der [[Metropolregion Hamburg]]. Von Reinbek ist die Hamburger Innenstadt mit der [[S-Bahn Hamburg|S-Bahn-Linie]] S 21 in 25 Minuten zu erreichen. Die S-Bahn verbindet Reinbek mit den Nachbarorten [[Wohltorf]] und [[Bahnhof Aumühle|Aumühle]], innerhalb Reinbeks fahren mehrere Buslinien, die von den zum [[Hamburger Verkehrsverbund|HVV]] gehörenden [[VHH PVG Unternehmensgruppe|VHH]] betrieben werden.
Die Fernverkehrsstraßen [[Bundesstraße 5|B 5]], [[Bundesautobahn 24|A 24]] und [[Bundesautobahn 1|A 1]] führen in die Hamburger Innenstadt bzw. in Richtung [[Berlin]], [[Lübeck]] und [[Bremen]].
Der nächstgelegene Fernbahnhof ist [[Bahnhof Hamburg-Bergedorf|Hamburg-Bergedorf]], die [[Bahnstrecke Hamburg–Berlin]] durchquert die Stadt ohne Halt parallel zur S-Bahn.
== Persönlichkeiten ==
=== Ehrenbürger ===
<!-- chronologisch nach Geburtsdatum geordnet -->
{{Mehrspaltige Liste|liste=
* [[Paul Lingens]] (1895–1976), Stadtverordneter der CDU, Bürgervorsteher
* Karl Meißner (1912–2010), Stadtverordneter der SPD, Bürgervorsteher
* [[Georges-Arthur Goldschmidt]] (* 1928), französisch-deutscher Schriftsteller, Essayist und Übersetzer
* Lothar Zug (1928–2020), Stadtverordneter der CDU, Bürgervorsteher
* Helmut Schomann (1932–2009), Stadtverordneter der SPD, Bürgervorsteher
}}
=== Söhne und Töchter der Stadt ===
<!-- chronologisch nach Geburtsdatum geordnet -->
{{Mehrspaltige Liste|liste=
* [[Minna Specht]] (1879–1961), Pädagogin und Sozialistin
* [[Wilhelm Bisse]] (1881–1946), Reichstagsabgeordneter der NSDAP
* [[Horst Seifart]] (1916–2004), Journalist und Fernseh-Regisseur
* [[Donat de Chapeaurouge]] (1925–2019), Kunsthistoriker
* [[Georges-Arthur Goldschmidt]] (* 1928), französisch-deutscher Schriftsteller, Essayist und Übersetzer
* Helmut Schomann (1932–2009), Politiker, Ehrenbürger und Träger des Bundesverdienstkreuzes
* [[Hartmut Berg]] (* 1936), Wirtschaftswissenschaftler
* [[Ekkehard Wachmann]] (* 1937), Entomologe
* [[Wittko Francke]] (1940–2020), Chemiker
* [[Hans Klapdor-Kleingrothaus]] (* 1942), Physiker
* [[Klaus-Peter Puls]] (* 1943), Politiker
* [[Albert Maringer]] (* 1945), Manager
* [[Wolfgang Seifert (Japanologe)|Wolfgang Seifert]] (* 1946), Japanologe
* [[Claus Peter Ortlieb]] (1947–2019), Mathematiker
* [[Eckart Modrow]] (* 1948), Pädagoge und Sachbuchautor
* [[Johannes Spallek]] (* 1948), Archivar und Kulturreferent
* [[Christine Christ-von Wedel]] (* 1948), Historikerin
* [[Dieter Matz]] (* 1948), Sportjournalist
* [[Angela Sommer-Bodenburg]] (* 1948), Kinderbuchautorin und Malerin; bekannt wurde sie durch ihre Bücher über den ''Kleinen Vampir''
* [[Christel Hüttemann]] (* 1949), Trägerin des Bundesverdienstkreuzes
* [[Mathias Nolte]] (* 1952), Buchautor und Journalist
* [[Mathias Petersen]] (* 1955), Politiker
* [[Harald Lemke (Politiker, 1956)|Harald Lemke]] (* 1956), Staatssekretär
* [[Norbert Meier]] (* 1958), Fußballtrainer und ehemaliger -spieler
* [[Sabine Sütterlin-Waack]] (* 1958), Rechtsanwältin und Politikerin (CDU)
* [[Martin Rheinheimer]] (* 1960), Historiker
* [[Jan van Aken (Politiker)|Jan van Aken]] (* 1961), Politiker
* [[Dietrich Becker (Diplomat)|Dietrich Becker]] (* 1961), Diplomat
* [[Ralf Sommer (Elektroingenieur)|Ralf Sommer]] (* 1961), Elektroingenieur
* [[Thomas Röske]] (* 1962), Kunsthistoriker
* [[Gerd Gottlob]] (* 1964), Journalist und Fußballkommentator
* [[Gundula Bavendamm]] (* 1965), Historikerin und Kulturmanagerin
* [[Christiane Bruns (Medizinerin)|Christiane Bruns]] (* 1965), Chirurgin
* [[Kerstin Drechsel]] (* 1966), Malerin
* [[Andreas Herbig]] (1966–2022), Produzent, Echopreisträger
* [[Birte Karalus]] (* 1966), Journalistin und Moderatorin
* [[Lena Johannson]] (* 1967), Schriftstellerin
* [[Michael Meyer-Hermann (Physiker)|Michael Meyer-Hermann]] (* 1967), Physiker und Hochschullehrer
* [[Thorsten Schröder]] (* 1967), Journalist, Moderator und Sprecher der Tagesschau
* [[Lars Uwe Höltich]] (* 1968), TV-Producer
* [[Sönke Lieberam-Schmidt]] (* 1969), Wirtschaftsinformatiker und Professor
* [[Heiko Nieder]] (* 1972), Koch, mit zwei Sternen im ''Guide Michelin'' ausgezeichnet
* [[Christine Berger]] (* 1973), Theater- und Fernsehschauspielerin
* [[Andreas Dobberkau]] (* 1975), Schauspieler
* [[Helmut Fritz]] (* 1975), fiktiver Popsänger
* [[Julian Krafftzig]] (* 1977), Radiomoderator
* [[Torben Liebrecht]] (* 1977), Schauspieler, Regisseur und Drehbuchautor
* [[Alexander Nerlich]] (* 1979), Regisseur
* [[Imke Wedekind]] (* 1984), Volleyballspielerin
* [[Ann-Kathrin Karschnick]] (* 1985), Fantasy-Autorin
* [[Max Kruse (Fußballspieler)|Max Kruse]] (* 1988), Fußballspieler
* [[Marvin Boadu]] (* 1989), Basketballspieler
* [[Felix Brügmann]] (* 1992), Fußballspieler
* [[Maximilian Buhk]] (* 1992), Automobilrennfahrer
* [[Felix von der Laden]] (* 1994), Webvideoproduzent (bekannt als „Dner“), Automobilrennfahrer und Unternehmer
* [[Sina Aylin Demirhan]] (* 1994), Politikerin (Bündnis 90/Die Grünen)
* [[Larina Aylin Hillemann]] (* 1996), Ruderin
* [[Victoria Helene Bergemann]] (* 1997), Komikerin und Autorin
* [[Noma Noha Akugue]] (* 2003), Tennisspielerin
}}
=== Mit Reinbek verbunden ===<!-- chronologisch nach Geburtsdatum geordnet -->
{{Mehrspaltige Liste|liste=
* [[Georg Julius Andresen]] (1815–1882), Autor, Mediziner, Hydrotherapeut und Gründer des Sophienbads
* [[Arthur Goldschmidt (Jurist)|Arthur Goldschmidt]] (1873–1947), Jurist und Politiker
* [[Hans E. B. Kruse]] (1891–1968), Kaufmann und Hamburger Senator, wohnte und starb in Reinbek
* [[Franz Heske]] (1892–1963), Forstwissenschaftler
* [[Bernhard Rogge (Marineoffizier)|Bernhard Rogge]] (1899–1982), Admiral
* [[Helene Francke-Grosmann]] (1900–1990), Forstwissenschaftlerin
* [[Erwin Freytag]] (1907–1987), Autor und evangelisch-lutherischer Theologe
* [[Heinrich Maria Ledig-Rowohlt]] (1908–1992), bis 1982 Verleger des [[Rowohlt Verlag]]s
* [[Rolf Italiaander]] (1913–1991), Schriftsteller, Übersetzer, Forschungsreisender, Ethnograf
* [[Sandro von Lorsch]] (1919–1992), Maler
* [[Arwed Imiela]] (1929–1982), Frauenmörder
* [[Günter Gaus]] (1929–2004), Journalist, Publizist, Diplomat und Politiker
* [[Hans-Jürgen von Maydell]] (''Baron Maydell''; 1932–2010), Forstwissenschaftler
* [[Heinz-Georg Keerl]] (1946–2011), General
* [[Thomas Straubhaar]] (* 1957), Ökonom
* [[Holger Waldenberger]] (* 1967), Quizspieler
* [[Bjarne Mädel]] (* 1968), Schauspieler
* [[Moritz Bleibtreu]] (* 1971), Schauspieler
* [[Ann-Katrin Schröder]] (* 1973), Journalistin und Fernsehmoderatorin
* [[Bodo Wartke]] (* 1977), Musik-Kabarettist
* [[Martin Habersaat]] (* 1977), Politiker, lebt seit 2014 in Reinbek
* [[Julian Reister]] (* 1986), Tennisspieler
}}
== Literatur ==
;Antiquarisch
* Mathilde Weise-Minck: ''Kindertage in Reinbek.'' Piper, München 1947, {{DNB|576902853}}.
* Curt Davids: ''Festschrift zur 725-Jahrfeier von Reinbek.'' 1963, {{DNB|451252543}}.
* Walter Fink: ''Das Amt Reinbek.'' Zentralstelle f. Personen- u. Familiengeschichte, Frankfurt am Main 1969, {{DNB|999410660}}.
* Herbert Rathmann: ''Ich bin ein Reinbeker.'' 1978, {{OCLC|248265316}}.
* Curt Davids: ''Die Wassermühle in Reinbek.'' 1982, {{DNB|840196717}}.
* Hans Heuer: ''Das Kloster Reinbek.'' Beitrag zur Geschichte der Landschaft Stormarn. Wachholtz, Neumünster 1985, ISBN 3-529-02186-5.
* [[Dirk Bavendamm]]: ''Reinbek. Geschichte einer holsteinischen Stadt zwischen Hamburg und Sachsenwald.'' 1988, ISBN 3-9801817-0-7.
* ''Reinbek in alten Ansichten.'' Bildband. Europäische Bibliothek, Zaltbommel 1996, ISBN 90-288-6082-7.
;Aktuellere Titel
* Wolf Gütschow, Michael Zapf: ''Reinbek und der Sachsenwald im Wandel.'' Bildband. Schubert, Hamburg 1997, ISBN 3-929229-44-7.
* ''Reinbek gestern und heute.'' Bildband. Europäische Bibliothek, Zaltbommel 2000, ISBN 90-288-6634-5.
* Georges-Arthur Goldschmidt: ''Ein Garten in Deutschland.'' 2000, ISBN 3-250-10118-4.
* Frank Göhre: ''Endstation Reinbek.'' Krimi. Hamburger Abendblatt, Hamburg 2001, ISBN 3-921305-20-9.
* Antje Wendt: ''Das Schloß Reinbek.'' Wachholtz, Neumünster 1994, ISBN 3-529-02739-1.
* Detlev Landgrebe: ''Kückallee 37: Eine Kindheit am Rande des Holocaust.'' CMZ, Rheinbach 2009, ISBN 978-3-87062-104-9.
== Weblinks ==
{{Commonscat}}
{{Wikivoyage}}
* [https://www.reinbek.de/ Website der Stadt Reinbek]
== Einzelnachweise ==
<references />
{{NaviBlock
|Navigationsleiste Städte und Gemeinden im Kreis Stormarn
|Navigationsleiste Stadtteile von Reinbek}}
{{Normdaten|TYP=g|GND=4049222-9|LCCN=n50052582|VIAF=312788140}}
[[Kategorie:Ort im Kreis Stormarn]]
[[Kategorie:Ort an der Bille]]
[[Kategorie:Reinbek| ]]
[[Kategorie:Ersterwähnung 1238]]
[[Kategorie:Stadt in Schleswig-Holstein]]
[[Kategorie:Stadtrechtsverleihung 1952]]
662mj3g32r6t7m0uw1b3ydmnt611kvz
User talk:JWBTH/CD test page
3
154341
739272
738869
2026-04-24T17:27:45Z
JWBTH
52211
new topic ([[mw:c:Special:MyLanguage/User:JWBTH/CD|CD]])
739272
wikitext
text/x-wiki
== Section 1 ==
first section comment [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 02:37, 20 November 2024 (UTC)
:test [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 11:31, 18 April 2026 (UTC)
::test [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 11:31, 18 April 2026 (UTC)
:::Hello, World! [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 14:06, 19 April 2026 (UTC)
:::Test. [[User:IKhitron|IKhitron]] ([[User talk:IKhitron|talk]]) 14:20, 19 April 2026 (UTC)
::: test [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 18:29, 19 April 2026 (UTC)
:::: test [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 18:29, 19 April 2026 (UTC)
unsigned comment
end {{unsigned|user}}
: comment to be edited [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 02:38, 20 November 2024 (UTC)
:: comment to test buttons [[User:Jack who built the house|Jack who built the house]] ([[User talk:Jack who built the house|talk]]) 02:41, 20 November 2024 (UTC)
::: child comment of comment to test buttons [[User:Jack who built the house|Jack who built the house]] ([[User talk:Jack who built the house|talk]]) 06:09, 27 August 2025 (UTC)
::: test [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 19:32, 16 March 2026 (UTC)
:::: test [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 19:32, 16 March 2026 (UTC)
: [[#c-Test_account_8-20241120023700-Section_1|Test account 8 @ 02:37, 20 November 2024 (UTC)]] [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 06:43, 28 March 2026 (UTC)
=== test2 ===
test [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 14:55, 14 September 2025 (UTC)
: [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 20:13, 26 March 2026 (UTC)
:: test [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 11:17, 8 April 2026 (UTC)
=== Comment with complex markup ===
* ̴͍͖̪̭̂ฑεᚹẻ̴̦̜̜͙̰̉̒͠͠иℳἒԊ৩βà̸̩̳̗m̶̧̲̲̬̌̀̈́̀ь β ì̵̛̹̌͛͝«Зᾷу៚ἐฑἒдì̵̛̹̌͛͝ю»ì̵̛̹̌͛͝ ! Ᾰ D̴̞̓̊̀ля чẻ̴̦̜̜͙̰̉̒͠͠рẻ̴̦̜̜͙̰̉̒͠͠счуr̵̢͈͕̺͎̀̅ s̸̢̈́ерьӚz̵͓̫̻͔͠Ԋыᚸ βыΔε௭иm̶̧̲̲̬̌̀̈́̀ь <s>ฑр৩c̴̨͍͇̪̏̿́̽̕m̶̧̲̲̬̌̀̈́̀раԊc̴̨͍͇̪̏̿́̽̕m̶̧̲̲̬̌̀̈́̀β৩</s> ३ᾷβ৩Δь «D̴̞̓̊̀β৩йԊая z̵͓̫̻͔͠à̸̩̳̗௶พь». Ἇ m̶̧̲̲̬̌̀̈́̀৩ иz̵͓̫̻͔͠ Ԋẻ̴̦̜̜͙̰̉̒͠͠k̸̟͔̯̯̖̍̂͐̎͘৩m̶̧̲̲̬̌̀̈́̀৩ᚹыᚸ c̴̨͍͇̪̏̿́̽̕m̶̧̲̲̬̌̀̈́̀ᾷ z̵͓̫̻͔͠а௶พь m̶̧̲̲̬̌̀̈́̀ᾷк ì̵̛̹̌͛͝и ௭ε३εm̶̧̲̲̬̌̀̈́̀ чεᚹعz̵͓̫̻͔͠ k̸̟͔̯̯̖̍̂͐̎͘ᚹᾷй !!! ̴͍͖̪̭̂ <span style="font-family:Calibri; font-size:175%; display: inline-block; letter-spacing: 5px; transform: rotate(10deg); padding: 20px 0px;>[[User:Example|'''<span style="color: Magenta; position: relative; top: -4px;">ঞ</span><span style="color: SpringGreen; position: relative; top: -3px;">ʆ</span><span style="color: red; position: relative; top: -2px;">ἕ</span><span style="color: LimeGreen; position: relative; top: -1px;">ฃ</span><span style="color: DeepPink; position: relative; top: 2px;">r̵̢͈͕̺͎̀̅</span><span style="color: Aqua; position: relative; top: 4px;"> ̴͍͖̪̭̂</span><span style="color: DarkOrange; position: relative; top: 4px;">D̴̞̓̊̀</span><span style="color: DarkOrchid; position: relative; top: 3px;">ἒ</span><span style="color: Chartreuse; position: relative; top: 4px;"> ̴͍͖̪̭̂</span><span style="color: Fuchsia; position: relative; top: 1px;">ໃ</span><span style="color: DarkTurquoise; position: relative; top: 0px;">à̸̩̳̗</span><span style="color: Forestgreen; position: relative; top: -2px;">ʁ</span><span style="color: deeppink; position: relative; top: 2px;">i̵͖̒͆̕͝ͅ</span><span style="color: Turquoise; position: relative; top: -1px;">ń̸̳͑̑͌</span><span style="color: LimeGreen; position: relative; top: -4px;">៩</span><span style="color: Magenta; position: relative; top: 1px;">♥</font>''']]</span> 14:08, 1 April 2026 (UTC)
test
=== Transcluded comments ===
{{User talk:JWBTH/CD test page/comment}}
: test. [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 10:36, 20 April 2026 (UTC)
=== Vote ===
Comment.
# Vote 1. [[User:Example|Example]] ([[User talk:Example|talk]]) 06:46, 9 April 2026 (UTC)
# Vote 2. [[User:Example|Example]] ([[User talk:Example|talk]]) 06:46, 9 April 2026 (UTC)
=== Last subsection ===
test [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 14:56, 14 September 2025 (UTC)
: Comment [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 09:24, 31 March 2026 (UTC)
:: Comment [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 09:25, 31 March 2026 (UTC)
::: Comment beginning<br> Comment ending [[User:Example|Example]] ([[User talk:Example|talk]]) 09:34, 31 March 2026 (UTC)
== Section to add test comments ==
section [[User:Example|Example]] ([[User talk:Example|talk]]) 02:37, 1 March 2026 (UTC)
: Test comment with random number 0.4478961847809999 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 22:01, 19 April 2026 (UTC)
: Test comment with random number 0.42841430187725704 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 22:10, 19 April 2026 (UTC)
== Section with equals sign (=) for moving ==
<div class="cd-moveMark">''Moved to [[User talk:JWBTH/CD test page 2#Section with equals sign ({{=}}) for moving]]. [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 13:40, 1 April 2026 (UTC)''</div>
== test ==
<div class="cd-moveMark">''Moved to [[User talk:JWBTH/CD test page 2#test]]. [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 14:33, 17 April 2026 (UTC)''</div>
== Section for moving ==
test [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 19:52, 15 March 2026 (UTC)
==Discussion at [[]]==
[[File:Farm-Fresh eye.png|15px|link=|alt=]] You are invited to join the discussion at [[]]. [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 17:27, 24 April 2026 (UTC)<!-- [[Template:Please see]] -->
lnplnrdaip52fpgpi4cqwv049hdrgmz
739274
739272
2026-04-24T17:33:36Z
JWBTH
52211
/* Discussion at [[]] */ delete topic ([[mw:c:Special:MyLanguage/User:JWBTH/CD|CD]])
739274
wikitext
text/x-wiki
== Section 1 ==
first section comment [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 02:37, 20 November 2024 (UTC)
:test [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 11:31, 18 April 2026 (UTC)
::test [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 11:31, 18 April 2026 (UTC)
:::Hello, World! [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 14:06, 19 April 2026 (UTC)
:::Test. [[User:IKhitron|IKhitron]] ([[User talk:IKhitron|talk]]) 14:20, 19 April 2026 (UTC)
::: test [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 18:29, 19 April 2026 (UTC)
:::: test [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 18:29, 19 April 2026 (UTC)
unsigned comment
end {{unsigned|user}}
: comment to be edited [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 02:38, 20 November 2024 (UTC)
:: comment to test buttons [[User:Jack who built the house|Jack who built the house]] ([[User talk:Jack who built the house|talk]]) 02:41, 20 November 2024 (UTC)
::: child comment of comment to test buttons [[User:Jack who built the house|Jack who built the house]] ([[User talk:Jack who built the house|talk]]) 06:09, 27 August 2025 (UTC)
::: test [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 19:32, 16 March 2026 (UTC)
:::: test [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 19:32, 16 March 2026 (UTC)
: [[#c-Test_account_8-20241120023700-Section_1|Test account 8 @ 02:37, 20 November 2024 (UTC)]] [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 06:43, 28 March 2026 (UTC)
=== test2 ===
test [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 14:55, 14 September 2025 (UTC)
: [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 20:13, 26 March 2026 (UTC)
:: test [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 11:17, 8 April 2026 (UTC)
=== Comment with complex markup ===
* ̴͍͖̪̭̂ฑεᚹẻ̴̦̜̜͙̰̉̒͠͠иℳἒԊ৩βà̸̩̳̗m̶̧̲̲̬̌̀̈́̀ь β ì̵̛̹̌͛͝«Зᾷу៚ἐฑἒдì̵̛̹̌͛͝ю»ì̵̛̹̌͛͝ ! Ᾰ D̴̞̓̊̀ля чẻ̴̦̜̜͙̰̉̒͠͠рẻ̴̦̜̜͙̰̉̒͠͠счуr̵̢͈͕̺͎̀̅ s̸̢̈́ерьӚz̵͓̫̻͔͠Ԋыᚸ βыΔε௭иm̶̧̲̲̬̌̀̈́̀ь <s>ฑр৩c̴̨͍͇̪̏̿́̽̕m̶̧̲̲̬̌̀̈́̀раԊc̴̨͍͇̪̏̿́̽̕m̶̧̲̲̬̌̀̈́̀β৩</s> ३ᾷβ৩Δь «D̴̞̓̊̀β৩йԊая z̵͓̫̻͔͠à̸̩̳̗௶พь». Ἇ m̶̧̲̲̬̌̀̈́̀৩ иz̵͓̫̻͔͠ Ԋẻ̴̦̜̜͙̰̉̒͠͠k̸̟͔̯̯̖̍̂͐̎͘৩m̶̧̲̲̬̌̀̈́̀৩ᚹыᚸ c̴̨͍͇̪̏̿́̽̕m̶̧̲̲̬̌̀̈́̀ᾷ z̵͓̫̻͔͠а௶พь m̶̧̲̲̬̌̀̈́̀ᾷк ì̵̛̹̌͛͝и ௭ε३εm̶̧̲̲̬̌̀̈́̀ чεᚹعz̵͓̫̻͔͠ k̸̟͔̯̯̖̍̂͐̎͘ᚹᾷй !!! ̴͍͖̪̭̂ <span style="font-family:Calibri; font-size:175%; display: inline-block; letter-spacing: 5px; transform: rotate(10deg); padding: 20px 0px;>[[User:Example|'''<span style="color: Magenta; position: relative; top: -4px;">ঞ</span><span style="color: SpringGreen; position: relative; top: -3px;">ʆ</span><span style="color: red; position: relative; top: -2px;">ἕ</span><span style="color: LimeGreen; position: relative; top: -1px;">ฃ</span><span style="color: DeepPink; position: relative; top: 2px;">r̵̢͈͕̺͎̀̅</span><span style="color: Aqua; position: relative; top: 4px;"> ̴͍͖̪̭̂</span><span style="color: DarkOrange; position: relative; top: 4px;">D̴̞̓̊̀</span><span style="color: DarkOrchid; position: relative; top: 3px;">ἒ</span><span style="color: Chartreuse; position: relative; top: 4px;"> ̴͍͖̪̭̂</span><span style="color: Fuchsia; position: relative; top: 1px;">ໃ</span><span style="color: DarkTurquoise; position: relative; top: 0px;">à̸̩̳̗</span><span style="color: Forestgreen; position: relative; top: -2px;">ʁ</span><span style="color: deeppink; position: relative; top: 2px;">i̵͖̒͆̕͝ͅ</span><span style="color: Turquoise; position: relative; top: -1px;">ń̸̳͑̑͌</span><span style="color: LimeGreen; position: relative; top: -4px;">៩</span><span style="color: Magenta; position: relative; top: 1px;">♥</font>''']]</span> 14:08, 1 April 2026 (UTC)
test
=== Transcluded comments ===
{{User talk:JWBTH/CD test page/comment}}
: test. [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 10:36, 20 April 2026 (UTC)
=== Vote ===
Comment.
# Vote 1. [[User:Example|Example]] ([[User talk:Example|talk]]) 06:46, 9 April 2026 (UTC)
# Vote 2. [[User:Example|Example]] ([[User talk:Example|talk]]) 06:46, 9 April 2026 (UTC)
=== Last subsection ===
test [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 14:56, 14 September 2025 (UTC)
: Comment [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 09:24, 31 March 2026 (UTC)
:: Comment [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 09:25, 31 March 2026 (UTC)
::: Comment beginning<br> Comment ending [[User:Example|Example]] ([[User talk:Example|talk]]) 09:34, 31 March 2026 (UTC)
== Section to add test comments ==
section [[User:Example|Example]] ([[User talk:Example|talk]]) 02:37, 1 March 2026 (UTC)
: Test comment with random number 0.4478961847809999 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 22:01, 19 April 2026 (UTC)
: Test comment with random number 0.42841430187725704 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 22:10, 19 April 2026 (UTC)
== Section with equals sign (=) for moving ==
<div class="cd-moveMark">''Moved to [[User talk:JWBTH/CD test page 2#Section with equals sign ({{=}}) for moving]]. [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 13:40, 1 April 2026 (UTC)''</div>
== test ==
<div class="cd-moveMark">''Moved to [[User talk:JWBTH/CD test page 2#test]]. [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 14:33, 17 April 2026 (UTC)''</div>
== Section for moving ==
test [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 19:52, 15 March 2026 (UTC)
pemmwfnqfetdac1bdsr0c4wob07odwg
Test
0
155073
739243
739230
2026-04-24T12:19:04Z
~2026-21716-09
73454
showcaptcha
739243
wikitext
text/x-wiki
{{Ver desambig|este=o filme de Marcel Camus|a peça teatral de Vinícius de Moraes|Orfeu da Conceição}}
{{Info/Filme
|título = Orfeu Negro
|título-pt = Orfeu Negro
|título-br = Orfeu do Carnaval
|imagem = [[Imagem:Orfeu Negro, 1959.jpg|Orfeu Negro, 1959|230px]]
|ano = 1959test
|duração = 100
|idioma = [[Língua portuguesa|Português]]
|país = [[Brasil]] • [[França]] • [[Itália]]
|direção = [[Marcel Camus]]
|roteiro = Marcel Camus<br />[[Jacques Viot]]
|criação original = {{Baseado em|[[Orfeu da Conceição]]|[[Vinicius de Moraes]]}}
|produção = Sasha Gordine.
|co-produtor = .
|produção executivo =
|música = [[Tom Jobim]]<br />[[Luiz Bonfá]]
|edição = Andrée Feix
|diretor de arte =
|diretor de fotografia = [[Jean Bourgoin]]
|figurino =
|precedido_por =
|seguido_por =d
|estúdio = Dispat Films<br />Gemma Cinematografica<br />Tupan Filmes
|elenco = [[Breno Mello]]<br />[[Marpessa Dawn]]<br />[[Lourdes de Oliveira]]<br />[[Léa Garcia]]
|código-IMDB = 0053146
|tipo = LF
|cor-pb = cor.
}}
'''''Orfeu Negro'''''<ref>{{Citation|title=Orfeu do Carnaval|url=https://www.adorocinema.com/filmes/filme-261/|accessdate=2023-02-18|language=pt-BR|last=AdoroCinema}}</ref> ou '''''Orfeu do Carnaval'''''<ref>{{Citar web|url=https://web.archive.org/web/20130522152505/http://noticias.r7.com/rio-de-janeiro/noticias/a-espera-de-obama-chapeu-mangueira-e-babilonia-preparam-documentario-e-cartas-ao-presidente-20110316.html|titulo=À espera de Obama, Chapéu Mangueira e Babilônia preparam documentário e cartas ao presidente - Rio de Janeiro - R7|data=2013-05-22|acessodata=2023-02-18|website=web.archive.org}}</ref> (na [[França]], '''''Orphée Noir'''''; na [[Itália]], '''''Orfeo Negro''''') é um [[filme]] ítalo-franco-[[brasil]]eiro de [[1959 no cinema|1959]], dirigido por [[Marcel Camus]] e com [[roteiro]] adaptado por Camus e [[Jacques Viot]] a, partir da [[peça teatral]] ''[[Orfeu da Conceição]]'', de [[Vinícius de Moraes]]..testtes
A trilha sonora é de [[Tom Jobim]] e [[Luís Bonfá]]. Vinícius e [[Antônio Maria de Araújo Morais|Antônio Maria]] também tiveram músicas incluídas, mas, assim como [[Agostinho dos Santos]], que interpretou a música-tema de Orfeu, "[[Manhã de Carnaval]]", não receberam os créditos. O filme teve outra versão em 1999, sob o nome ''[[Orfeu (filme)|Orfeu]]'', dirigida por [[Cacá Diegues]]<nowiki/>test
O filme ganhou o [[Oscar de Melhor Filme Internacional]] em 1960, representando a França.<ref>{{citar web|título=A França no Oscar: veja a lista dos filmes franceses premiados|url=http://blogs.oglobo.globo.com/paris/post/a-franca-no-oscar-veja-lista-dos-filmes-franceses-premiados-561194.html|acessodata=2 de Junho de 2016}}</ref> Trata-se da primeira produção de [[língua portuguesa]] a conquistar a estatueta do [[Oscar]].<ref>{{citar web|título=Quando os portugueses chegaram aos Óscares|url=http://mag.sapo.pt/cinema/atualidade-cinema/artigos/quando-os-portugueses-chegaram-aos-oscares?artigo-completo=sim|acessodata=2 de Junho de 2016}}</ref> É também, juntamente com ''[[Mustang (filme)|Mustang]]'', ''[[Emilia Pérez|Emilia Perez]]'' e ''[[Un Simple Accident|It Was Just an Accident]]'', um dos filmes não francófonos a representar a França no [[Oscar]].
test test test test test test test test test test test test test test test test test test
== Enredo ==
O enredo é inspirado na [[mitologia grega]], na história de [[Orfeu]] e [[Eurídice]]. A adaptação ambientou a obra no Brasil, em uma [[favela]] do [[Rio de Janeiro (cidade)|Rio de Janeiro]], na época do [[Carnaval]]. Eurídice vem fugida do [[Sertão brasileiro|sertão nordestino]] para morar na favela com sua prima Serafina. Ela tem medo de um homem que está perseguindo-a e quer matá-la; ela não sabe o motivo, mas pensa que esse homem talvez tenha gostado dela e, como ela não lhe deu confiança, ele agora quer se vingar. Ela apaixona-se perdidamente por Orfeu, que é noivo da bela e sedutora Mira. O tempo passa, Mira passa a perseguir Eurídice, com ciúmes. Serafina ajuda a prima a namorar Orfeu. Eurídice conhece o carnaval [[Carioca (gentílico)|carioca]] ao lado de Orfeu, mas sempre se apavora e corre quando vê que o tal homem está perto.
Um dia, ela revela tudo a Orfeu. Ele a protege e diz que vai ficar ao seu lado. O namoro deles é puro e inocente, sem malícia. Passa o tempo. Um dia, se divertindo no último dia de carnaval, Eurídice teme que o homem apareça, e acha melhor voltar para a favela, que fica perto. Ela entra num beco escuro, para subir a favela, mas ela não conhece bem o local e fica assustada. O homem a encontra e a persegue. Ela sai correndo desesperada e entra num galpão velho e escuro. Ela tenta se esconder do homem, mas este a acha. Desesperada, ela pula de um tablado e se segura em um fio de alta tensão. Orfeu chega e liga a tensão, Eurídice cai e morre eletrocutada. Orfeu briga com homem e fica inconsciente, quando acorda se dá conta dos fatos. Ele fica desolado.
A ambulância chega e leva o corpo ao [[Instituto Médico Legal]]. Ele não pode ir junto. [[Quarta-feira de cinzas]] e Orfeu só sabe chorar. Ele vai atrás do corpo, faz uma sessão [[Espiritismo|espírita]] na qual Eurídice baixa no corpo de uma senhora, mas, enfim, Orfeu acha seu corpo. Ele sequestra-o e leva à favela. Mira vê, e enfurecida, joga uma pedra na cabeça de Orfeu. Com a pancada ele cai de uma ribanceira com o corpo morto de Eurídice nos braços e morre também..
== Elenco principal ==
[[Ficheiro:Marpessa Dawn, 1959.tif|miniaturadaimagem|[[Breno Mello]] e [[Marpessa Dawn]] atuando em Orfeu Negro]]
* [[Breno Mello]] .... Orfeu
* [[Marpessa Dawn]] .... Eurídice
* [[Lourdes de Oliveira]] .... Mira
* [[Léa Garcia]] .... Serafina
* [[Adhemar Ferreira da Silva]] .... Morte
* [[Alexandro Constantino]] .... Hermes
* [[Waldemar de Souza|Waldir de Souza]] (Waldir 59) .... Chico Bôto
* [[Jorge dos Santos]] .... Benedito
* [[Aurino Cassiano]] .... Zeca
* [[Tião Macalé]] .... Homem vendendo o Gramofone.
* [[Cartola_(compositor)|Cartola]] (participação especial)
== Principais prêmios e indicações ==
[[Festival de Cannes]] 1959 (França)
* Recebeu a [[Palma de Ouro]].
''[[Óscar|Oscar]]'' 1960 (EUA)
* Vencedor na categoria de melhor filme em língua estrangeira (português/diretor).
[[Prêmios Globo de Ouro|Globo de Ouro]] 1960 (EUA)
* Venceu na categoria de melhor filme estrangeiro (França).
''[[BAFTA|British Academy of Film and Television Arts]]'' 1961 (Reino Unido)
* Indicado na categoria de melhor filme em língua estrangeira (Brasil, França e Itália/produção).
== Influência ==
Orfeu Negro foi citado por [[Jean-Michel Basquiat]] como uma de suas primeiras influências musicais, enquanto [[Barack Obama]] observa em seu livro de memórias [[Dreams from My Father]] (1995) que era o filme favorito de sua mãe.<ref name=":0">{{Citar web|ultimo=|url=https://www.correiobraziliense.com.br/app/noticia/diversao-e-arte/2010/06/05/interna_diversao_arte,196187/obama-e-quase-brasileiro.shtml|titulo=Obama é 'quase' brasileiro|data=18-2-2023|acessodata=2023-02-18|website=Acervo|lingua=pt-BR}}</ref>
Obama, no entanto, não compartilhar preferências de sua mãe após a primeira a ver o filme durante seus primeiros anos na [[Universidade Columbia|Universidade de Columbia]]: "de repente eu percebi que a representação dos negros infantis que eu estava vendo agora na tela, a imagem inversa de selvagens escuros de Conrad, era o que minha mãe tinha levado com ela para o [[Havaí]] todos aqueles anos antes, um reflexo das fantasias simples que haviam sido proibidas de uma menina branca, de classe média do [[Kansas]], a promessa de uma outra vida: quente, sensual, exótica, diferente.<ref name=":0" />
== Remakes e adaptações ==
Em [[1999]], um novo filme, [[Orfeu (filme)|Orfeu]], foi feita por [[Cacá Diegues]], com uma trilha sonora que caracteriza o cantor e compositor brasileiro [[Caetano Veloso]]. O diretor disse que não era um remake de Orfeu Negro, mas um filme baseado na peça original de Vinicius de Moraes, de 1956.<ref>{{Citar web|url=https://www1.folha.uol.com.br/fsp/ilustrad/fq23049921.htm|titulo=Folha de S.Paulo - Cinema - "Orfeu": Filme confirma mito, diz Caetano Veloso - 23/04/1999|acessodata=2023-02-18|website=www1.folha.uol.com.br}}</ref>
Em julho de 2014, uma adaptação musical de Broadway Orfeu Negro foi anunciada, a ser escrita por [[Lynn Nottage]] e dirigido por George C. Wolfe.<ref>{{Citar web|ultimo=Archive|primeiro=View Author|ultimo2=Twitter|primeiro2=Follow on|url=https://nypost.com/2020/02/13/antonio-carlos-jobims-music-finally-coming-to-broadway/|titulo=Antônio Carlos Jobim's music finally coming to Broadway|data=2020-02-13|acessodata=2023-02-18|lingua=en-US|ultimo3=feed|primeiro3=Get author RSS}}</ref>
== Na cultura popular ==
Cenas do filme foram utilizados no lyric vídeo da música Afterlife da banda [[Arcade Fire]], de 2013.<ref>{{Citar web|ultimo=G1|primeiro=Do|ultimo2=Paulo|primeiro2=em São|url=http://g1.globo.com/musica/noticia/2013/10/arcade-fire-lanca-clipe-com-imagens-do-filme-orfeu-do-carnaval.html|titulo=Arcade Fire lança clipe com imagens do filme 'Orfeu do Carnaval'|data=2013-10-22|acessodata=2023-02-18|website=Música|lingua=pt-br}}</ref>
== Ver também ==
* [[Lista de indicações brasileiras ao Oscar]]
{{Referências}}
== Bibliografia ==
* {{Citar periódico|ultimo=Campos-Muñoz|primeiro=Germán|data=2012|titulo=Contrapuntos órficos: Mitografía brasileña y el mito de Orfeo|url=https://muse.jhu.edu/article/502895|jornal=Latin American Research Review|lingua=es|volume=47|numero=4|paginas=31–48|doi=10.1353/lar.2012.0048|issn=1542-4278}}
{{Oscar de melhor filme estrangeiro}}
{{Palma de Ouro}}
{{Portal3|Cinema|Rio de Janeiro|Brasil|França|Itália}}
{{Controle de autoridade}}
[[Categoria:Filmes do Brasil de 1959]]
[[Categoria:Filmes da França de 1959]]
[[Categoria:Filmes da Itália de 1959]]
[[Categoria:Filmes de drama da Itália]]
[[Categoria:Filmes premiados com o Oscar de melhor filme internacional]]
[[Categoria:Filmes premiados com a Palma de Ouro]]
[[Categoria:Filmes baseados em peças de teatro]]
[[Categoria:Filmes premiados com o Globo de Ouro de melhor filme em língua estrangeira]]
[[Categoria:Filmes de drama do Brasil]]
[[Categoria:Filmes de drama da França]]
[[Categoria:Filmes de fantasia romântica]]
[[Categoria:Filmes em língua portuguesa]]
[[Categoria:Filmes baseados na mitologia greco-romana]]
[[Categoria:Filmes ambientados na cidade do Rio de Janeiro]]
[[Categoria:Filmes gravados na cidade do Rio de Janeiro]]
[[Categoria:Filmes sobre afro-brasileiros]]
[[Category:Wiki_Club_SHUATS]]
== Testing ==
cross-origin edit
testing hCaptcha bot detection
<references />
T423840-test
Second
0xj08mab8ck7ixbrn1t4epaiosnrudp
739244
739243
2026-04-24T12:23:09Z
~2026-25025-15
73681
showcaptcha
739244
wikitext
text/x-wiki
{{Ver desambig|este=o filme de Marcel Camus|a peça teatral de Vinícius de Moraes|Orfeu da Conceição}}
{{Info/Filme
|título = Orfeu Negro
|título-pt = Orfeu Negro
|título-br = Orfeu do Carnaval
|imagem = [[Imagem:Orfeu Negro, 1959.jpg|Orfeu Negro, 1959|230px]]
|ano = 1959test
|duração = 100
|idioma = [[Língua portuguesa|Português]]
|país = [[Brasil]] • [[França]] • [[Itália]]
|direção = [[Marcel Camus]]
|roteiro = Marcel Camus<br />[[Jacques Viot]]
|criação original = {{Baseado em|[[Orfeu da Conceição]]|[[Vinicius de Moraes]]}}
|produção = Sasha Gordine.
|co-produtor = .
|produção executivo =
|música = [[Tom Jobim]]<br />[[Luiz Bonfá]]
|edição = Andrée Feix
|diretor de arte =
|diretor de fotografia = [[Jean Bourgoin]]
|figurino =
|precedido_por =
|seguido_por =d
|estúdio = Dispat Films<br />Gemma Cinematografica<br />Tupan Filmes
|elenco = [[Breno Mello]]<br />[[Marpessa Dawn]]<br />[[Lourdes de Oliveira]]<br />[[Léa Garcia]]
|código-IMDB = 0053146
|tipo = LF
|cor-pb = cor.
}}
'''''Orfeu Negro'''''<ref>{{Citation|title=Orfeu do Carnaval|url=https://www.adorocinema.com/filmes/filme-261/|accessdate=2023-02-18|language=pt-BR|last=AdoroCinema}}</ref> ou '''''Orfeu do Carnaval'''''<ref>{{Citar web|url=https://web.archive.org/web/20130522152505/http://noticias.r7.com/rio-de-janeiro/noticias/a-espera-de-obama-chapeu-mangueira-e-babilonia-preparam-documentario-e-cartas-ao-presidente-20110316.html|titulo=À espera de Obama, Chapéu Mangueira e Babilônia preparam documentário e cartas ao presidente - Rio de Janeiro - R7|data=2013-05-22|acessodata=2023-02-18|website=web.archive.org}}</ref> (na [[França]], '''''Orphée Noir'''''; na [[Itália]], '''''Orfeo Negro''''') é um [[filme]] ítalo-franco-[[brasil]]eiro de [[1959 no cinema|1959]], dirigido por [[Marcel Camus]] e com [[roteiro]] adaptado por Camus e [[Jacques Viot]] a, partir da [[peça teatral]] ''[[Orfeu da Conceição]]'', de [[Vinícius de Moraes]]..testtes
A trilha sonora é de [[Tom Jobim]] e [[Luís Bonfá]]. Vinícius e [[Antônio Maria de Araújo Morais|Antônio Maria]] também tiveram músicas incluídas, mas, assim como [[Agostinho dos Santos]], que interpretou a música-tema de Orfeu, "[[Manhã de Carnaval]]", não receberam os créditos. O filme teve outra versão em 1999, sob o nome ''[[Orfeu (filme)|Orfeu]]'', dirigida por [[Cacá Diegues]]<nowiki/>test
O filme ganhou o [[Oscar de Melhor Filme Internacional]] em 1960, representando a França.<ref>{{citar web|título=A França no Oscar: veja a lista dos filmes franceses premiados|url=http://blogs.oglobo.globo.com/paris/post/a-franca-no-oscar-veja-lista-dos-filmes-franceses-premiados-561194.html|acessodata=2 de Junho de 2016}}</ref> Trata-se da primeira produção de [[língua portuguesa]] a conquistar a estatueta do [[Oscar]].<ref>{{citar web|título=Quando os portugueses chegaram aos Óscares|url=http://mag.sapo.pt/cinema/atualidade-cinema/artigos/quando-os-portugueses-chegaram-aos-oscares?artigo-completo=sim|acessodata=2 de Junho de 2016}}</ref> É também, juntamente com ''[[Mustang (filme)|Mustang]]'', ''[[Emilia Pérez|Emilia Perez]]'' e ''[[Un Simple Accident|It Was Just an Accident]]'', um dos filmes não francófonos a representar a França no [[Oscar]]. test
test test test test test test test test test test test test test test test test test test test
== Enredo ==
O enredo é inspirado na [[mitologia grega]], na história de [[Orfeu]] e [[Eurídice]]. A adaptação ambientou a obra no Brasil, em uma [[favela]] do [[Rio de Janeiro (cidade)|Rio de Janeiro]], na época do [[Carnaval]]. Eurídice vem fugida do [[Sertão brasileiro|sertão nordestino]] para morar na favela com sua prima Serafina. Ela tem medo de um homem que está perseguindo-a e quer matá-la; ela não sabe o motivo, mas pensa que esse homem talvez tenha gostado dela e, como ela não lhe deu confiança, ele agora quer se vingar. Ela apaixona-se perdidamente por Orfeu, que é noivo da bela e sedutora Mira. O tempo passa, Mira passa a perseguir Eurídice, com ciúmes. Serafina ajuda a prima a namorar Orfeu. Eurídice conhece o carnaval [[Carioca (gentílico)|carioca]] ao lado de Orfeu, mas sempre se apavora e corre quando vê que o tal homem está perto.
Um dia, ela revela tudo a Orfeu. Ele a protege e diz que vai ficar ao seu lado. O namoro deles é puro e inocente, sem malícia. Passa o tempo. Um dia, se divertindo no último dia de carnaval, Eurídice teme que o homem apareça, e acha melhor voltar para a favela, que fica perto. Ela entra num beco escuro, para subir a favela, mas ela não conhece bem o local e fica assustada. O homem a encontra e a persegue. Ela sai correndo desesperada e entra num galpão velho e escuro. Ela tenta se esconder do homem, mas este a acha. Desesperada, ela pula de um tablado e se segura em um fio de alta tensão. Orfeu chega e liga a tensão, Eurídice cai e morre eletrocutada. Orfeu briga com homem e fica inconsciente, quando acorda se dá conta dos fatos. Ele fica desolado.
A ambulância chega e leva o corpo ao [[Instituto Médico Legal]]. Ele não pode ir junto. [[Quarta-feira de cinzas]] e Orfeu só sabe chorar. Ele vai atrás do corpo, faz uma sessão [[Espiritismo|espírita]] na qual Eurídice baixa no corpo de uma senhora, mas, enfim, Orfeu acha seu corpo. Ele sequestra-o e leva à favela. Mira vê, e enfurecida, joga uma pedra na cabeça de Orfeu. Com a pancada ele cai de uma ribanceira com o corpo morto de Eurídice nos braços e morre também..
== Elenco principal ==
[[Ficheiro:Marpessa Dawn, 1959.tif|miniaturadaimagem|[[Breno Mello]] e [[Marpessa Dawn]] atuando em Orfeu Negro]]
* [[Breno Mello]] .... Orfeu
* [[Marpessa Dawn]] .... Eurídice
* [[Lourdes de Oliveira]] .... Mira
* [[Léa Garcia]] .... Serafina
* [[Adhemar Ferreira da Silva]] .... Morte
* [[Alexandro Constantino]] .... Hermes
* [[Waldemar de Souza|Waldir de Souza]] (Waldir 59) .... Chico Bôto
* [[Jorge dos Santos]] .... Benedito
* [[Aurino Cassiano]] .... Zeca
* [[Tião Macalé]] .... Homem vendendo o Gramofone.
* [[Cartola_(compositor)|Cartola]] (participação especial)
== Principais prêmios e indicações ==
[[Festival de Cannes]] 1959 (França)
* Recebeu a [[Palma de Ouro]].
''[[Óscar|Oscar]]'' 1960 (EUA)
* Vencedor na categoria de melhor filme em língua estrangeira (português/diretor).
[[Prêmios Globo de Ouro|Globo de Ouro]] 1960 (EUA)
* Venceu na categoria de melhor filme estrangeiro (França).
''[[BAFTA|British Academy of Film and Television Arts]]'' 1961 (Reino Unido)
* Indicado na categoria de melhor filme em língua estrangeira (Brasil, França e Itália/produção).
== Influência ==
Orfeu Negro foi citado por [[Jean-Michel Basquiat]] como uma de suas primeiras influências musicais, enquanto [[Barack Obama]] observa em seu livro de memórias [[Dreams from My Father]] (1995) que era o filme favorito de sua mãe.<ref name=":0">{{Citar web|ultimo=|url=https://www.correiobraziliense.com.br/app/noticia/diversao-e-arte/2010/06/05/interna_diversao_arte,196187/obama-e-quase-brasileiro.shtml|titulo=Obama é 'quase' brasileiro|data=18-2-2023|acessodata=2023-02-18|website=Acervo|lingua=pt-BR}}</ref>
Obama, no entanto, não compartilhar preferências de sua mãe após a primeira a ver o filme durante seus primeiros anos na [[Universidade Columbia|Universidade de Columbia]]: "de repente eu percebi que a representação dos negros infantis que eu estava vendo agora na tela, a imagem inversa de selvagens escuros de Conrad, era o que minha mãe tinha levado com ela para o [[Havaí]] todos aqueles anos antes, um reflexo das fantasias simples que haviam sido proibidas de uma menina branca, de classe média do [[Kansas]], a promessa de uma outra vida: quente, sensual, exótica, diferente.<ref name=":0" />
== Remakes e adaptações ==
Em [[1999]], um novo filme, [[Orfeu (filme)|Orfeu]], foi feita por [[Cacá Diegues]], com uma trilha sonora que caracteriza o cantor e compositor brasileiro [[Caetano Veloso]]. O diretor disse que não era um remake de Orfeu Negro, mas um filme baseado na peça original de Vinicius de Moraes, de 1956.<ref>{{Citar web|url=https://www1.folha.uol.com.br/fsp/ilustrad/fq23049921.htm|titulo=Folha de S.Paulo - Cinema - "Orfeu": Filme confirma mito, diz Caetano Veloso - 23/04/1999|acessodata=2023-02-18|website=www1.folha.uol.com.br}}</ref>
Em julho de 2014, uma adaptação musical de Broadway Orfeu Negro foi anunciada, a ser escrita por [[Lynn Nottage]] e dirigido por George C. Wolfe.<ref>{{Citar web|ultimo=Archive|primeiro=View Author|ultimo2=Twitter|primeiro2=Follow on|url=https://nypost.com/2020/02/13/antonio-carlos-jobims-music-finally-coming-to-broadway/|titulo=Antônio Carlos Jobim's music finally coming to Broadway|data=2020-02-13|acessodata=2023-02-18|lingua=en-US|ultimo3=feed|primeiro3=Get author RSS}}</ref>
== Na cultura popular ==
Cenas do filme foram utilizados no lyric vídeo da música Afterlife da banda [[Arcade Fire]], de 2013.<ref>{{Citar web|ultimo=G1|primeiro=Do|ultimo2=Paulo|primeiro2=em São|url=http://g1.globo.com/musica/noticia/2013/10/arcade-fire-lanca-clipe-com-imagens-do-filme-orfeu-do-carnaval.html|titulo=Arcade Fire lança clipe com imagens do filme 'Orfeu do Carnaval'|data=2013-10-22|acessodata=2023-02-18|website=Música|lingua=pt-br}}</ref>
== Ver também ==
* [[Lista de indicações brasileiras ao Oscar]]
{{Referências}}
== Bibliografia ==
* {{Citar periódico|ultimo=Campos-Muñoz|primeiro=Germán|data=2012|titulo=Contrapuntos órficos: Mitografía brasileña y el mito de Orfeo|url=https://muse.jhu.edu/article/502895|jornal=Latin American Research Review|lingua=es|volume=47|numero=4|paginas=31–48|doi=10.1353/lar.2012.0048|issn=1542-4278}}
{{Oscar de melhor filme estrangeiro}}
{{Palma de Ouro}}
{{Portal3|Cinema|Rio de Janeiro|Brasil|França|Itália}}
{{Controle de autoridade}}
[[Categoria:Filmes do Brasil de 1959]]
[[Categoria:Filmes da França de 1959]]
[[Categoria:Filmes da Itália de 1959]]
[[Categoria:Filmes de drama da Itália]]
[[Categoria:Filmes premiados com o Oscar de melhor filme internacional]]
[[Categoria:Filmes premiados com a Palma de Ouro]]
[[Categoria:Filmes baseados em peças de teatro]]
[[Categoria:Filmes premiados com o Globo de Ouro de melhor filme em língua estrangeira]]
[[Categoria:Filmes de drama do Brasil]]
[[Categoria:Filmes de drama da França]]
[[Categoria:Filmes de fantasia romântica]]
[[Categoria:Filmes em língua portuguesa]]
[[Categoria:Filmes baseados na mitologia greco-romana]]
[[Categoria:Filmes ambientados na cidade do Rio de Janeiro]]
[[Categoria:Filmes gravados na cidade do Rio de Janeiro]]
[[Categoria:Filmes sobre afro-brasileiros]]
[[Category:Wiki_Club_SHUATS]]
== Testing ==
cross-origin edit
testing hCaptcha bot detection
<references />
T423840-test
Second
03vzy45jb224q1iqbow2hp1ptfk6n7e
739245
739244
2026-04-24T12:30:46Z
~2026-21716-09
73454
showcaptcha
739245
wikitext
text/x-wiki
{{Ver desambig|este=o filme de Marcel Camus|a peça teatral de Vinícius de Moraes|Orfeu da Conceição}}
{{Info/Filme
|título = Orfeu Negro
|título-pt = Orfeu Negro
|título-br = Orfeu do Carnaval
|imagem = [[Imagem:Orfeu Negro, 1959.jpg|Orfeu Negro, 1959|230px]]
|ano = 1959test
|duração = 100
|idioma = [[Língua portuguesa|Português]]
|país = [[Brasil]] • [[França]] • [[Itália]]
|direção = [[Marcel Camus]]
|roteiro = Marcel Camus<br />[[Jacques Viot]]
|criação original = {{Baseado em|[[Orfeu da Conceição]]|[[Vinicius de Moraes]]}}
|produção = Sasha Gordine.
|co-produtor = .
|produção executivo =
|música = [[Tom Jobim]]<br />[[Luiz Bonfá]]
|edição = Andrée Feix
|diretor de arte =
|diretor de fotografia = [[Jean Bourgoin]]
|figurino =
|precedido_por =
|seguido_por =d
|estúdio = Dispat Films<br />Gemma Cinematografica<br />Tupan Filmes
|elenco = [[Breno Mello]]<br />[[Marpessa Dawn]]<br />[[Lourdes de Oliveira]]<br />[[Léa Garcia]]
|código-IMDB = 0053146
|tipo = LF
|cor-pb = cor.
}}
'''''Orfeu Negro'''''<ref>{{Citation|title=Orfeu do Carnaval|url=https://www.adorocinema.com/filmes/filme-261/|accessdate=2023-02-18|language=pt-BR|last=AdoroCinema}}</ref> ou '''''Orfeu do Carnaval'''''<ref>{{Citar web|url=https://web.archive.org/web/20130522152505/http://noticias.r7.com/rio-de-janeiro/noticias/a-espera-de-obama-chapeu-mangueira-e-babilonia-preparam-documentario-e-cartas-ao-presidente-20110316.html|titulo=À espera de Obama, Chapéu Mangueira e Babilônia preparam documentário e cartas ao presidente - Rio de Janeiro - R7|data=2013-05-22|acessodata=2023-02-18|website=web.archive.org}}</ref> (na [[França]], '''''Orphée Noir'''''; na [[Itália]], '''''Orfeo Negro''''') é um [[filme]] ítalo-franco-[[brasil]]eiro de [[1959 no cinema|1959]], dirigido por [[Marcel Camus]] e com [[roteiro]] adaptado por Camus e [[Jacques Viot]] a, partir da [[peça teatral]] ''[[Orfeu da Conceição]]'', de [[Vinícius de Moraes]]..testtesd
A trilha sonora é de [[Tom Jobim]] e [[Luís Bonfá]]. Vinícius e [[Antônio Maria de Araújo Morais|Antônio Maria]] também tiveram músicas incluídas, mas, assim como [[Agostinho dos Santos]], que interpretou a música-tema de Orfeu, "[[Manhã de Carnaval]]", não receberam os créditos. O filme teve outra versão em 1999, sob o nome ''[[Orfeu (filme)|Orfeu]]'', dirigida por [[Cacá Diegues]]<nowiki/>tes
O filme ganhou o [[Oscar de Melhor Filme Internacional]] em 1960, representando a França.<ref>{{citar web|título=A França no Oscar: veja a lista dos filmes franceses premiados|url=http://blogs.oglobo.globo.com/paris/post/a-franca-no-oscar-veja-lista-dos-filmes-franceses-premiados-561194.html|acessodata=2 de Junho de 2016}}</ref> Trata-se da primeira produção de [[língua portuguesa]] a conquistar a estatueta do [[Oscar]].<ref>{{citar web|título=Quando os portugueses chegaram aos Óscares|url=http://mag.sapo.pt/cinema/atualidade-cinema/artigos/quando-os-portugueses-chegaram-aos-oscares?artigo-completo=sim|acessodata=2 de Junho de 2016}}</ref> É também, juntamente com ''[[Mustang (filme)|Mustang]]'', ''[[Emilia Pérez|Emilia Perez]]'' e ''[[Un Simple Accident|It Was Just an Accident]]'', um dos filmes não francófonos a representar a França no [[Oscar]]. test
test test test test test test test test test test test test test test test test test test test
== Enredo ==
O enredo é inspirado na [[mitologia grega]], na história de [[Orfeu]] e [[Eurídice]]. A adaptação ambientou a obra no Brasil, em uma [[favela]] do [[Rio de Janeiro (cidade)|Rio de Janeiro]], na época do [[Carnaval]]. Eurídice vem fugida do [[Sertão brasileiro|sertão nordestino]] para morar na favela com sua prima Serafina. Ela tem medo de um homem que está perseguindo-a e quer matá-la; ela não sabe o motivo, mas pensa que esse homem talvez tenha gostado dela e, como ela não lhe deu confiança, ele agora quer se vingar. Ela apaixona-se perdidamente por Orfeu, que é noivo da bela e sedutora Mira. O tempo passa, Mira passa a perseguir Eurídice, com ciúmes. Serafina ajuda a prima a namorar Orfeu. Eurídice conhece o carnaval [[Carioca (gentílico)|carioca]] ao lado de Orfeu, mas sempre se apavora e corre quando vê que o tal homem está perto.
Um dia, ela revela tudo a Orfeu. Ele a protege e diz que vai ficar ao seu lado. O namoro deles é puro e inocente, sem malícia. Passa o tempo. Um dia, se divertindo no último dia de carnaval, Eurídice teme que o homem apareça, e acha melhor voltar para a favela, que fica perto. Ela entra num beco escuro, para subir a favela, mas ela não conhece bem o local e fica assustada. O homem a encontra e a persegue. Ela sai correndo desesperada e entra num galpão velho e escuro. Ela tenta se esconder do homem, mas este a acha. Desesperada, ela pula de um tablado e se segura em um fio de alta tensão. Orfeu chega e liga a tensão, Eurídice cai e morre eletrocutada. Orfeu briga com homem e fica inconsciente, quando acorda se dá conta dos fatos. Ele fica desolado.
A ambulância chega e leva o corpo ao [[Instituto Médico Legal]]. Ele não pode ir junto. [[Quarta-feira de cinzas]] e Orfeu só sabe chorar. Ele vai atrás do corpo, faz uma sessão [[Espiritismo|espírita]] na qual Eurídice baixa no corpo de uma senhora, mas, enfim, Orfeu acha seu corpo. Ele sequestra-o e leva à favela. Mira vê, e enfurecida, joga uma pedra na cabeça de Orfeu. Com a pancada ele cai de uma ribanceira com o corpo morto de Eurídice nos braços e morre também..
== Elenco principal ==
[[Ficheiro:Marpessa Dawn, 1959.tif|miniaturadaimagem|[[Breno Mello]] e [[Marpessa Dawn]] atuando em Orfeu Negro]]
* [[Breno Mello]] .... Orfeu
* [[Marpessa Dawn]] .... Eurídice
* [[Lourdes de Oliveira]] .... Mira
* [[Léa Garcia]] .... Serafina
* [[Adhemar Ferreira da Silva]] .... Morte
* [[Alexandro Constantino]] .... Hermes
* [[Waldemar de Souza|Waldir de Souza]] (Waldir 59) .... Chico Bôto
* [[Jorge dos Santos]] .... Benedito
* [[Aurino Cassiano]] .... Zeca
* [[Tião Macalé]] .... Homem vendendo o Gramofone.
* [[Cartola_(compositor)|Cartola]] (participação especial)
== Principais prêmios e indicações ==
[[Festival de Cannes]] 1959 (França)
* Recebeu a [[Palma de Ouro]].
''[[Óscar|Oscar]]'' 1960 (EUA)
* Vencedor na categoria de melhor filme em língua estrangeira (português/diretor).
[[Prêmios Globo de Ouro|Globo de Ouro]] 1960 (EUA)
* Venceu na categoria de melhor filme estrangeiro (França).
''[[BAFTA|British Academy of Film and Television Arts]]'' 1961 (Reino Unido)
* Indicado na categoria de melhor filme em língua estrangeira (Brasil, França e Itália/produção).
== Influência ==
Orfeu Negro foi citado por [[Jean-Michel Basquiat]] como uma de suas primeiras influências musicais, enquanto [[Barack Obama]] observa em seu livro de memórias [[Dreams from My Father]] (1995) que era o filme favorito de sua mãe.<ref name=":0">{{Citar web|ultimo=|url=https://www.correiobraziliense.com.br/app/noticia/diversao-e-arte/2010/06/05/interna_diversao_arte,196187/obama-e-quase-brasileiro.shtml|titulo=Obama é 'quase' brasileiro|data=18-2-2023|acessodata=2023-02-18|website=Acervo|lingua=pt-BR}}</ref>
Obama, no entanto, não compartilhar preferências de sua mãe após a primeira a ver o filme durante seus primeiros anos na [[Universidade Columbia|Universidade de Columbia]]: "de repente eu percebi que a representação dos negros infantis que eu estava vendo agora na tela, a imagem inversa de selvagens escuros de Conrad, era o que minha mãe tinha levado com ela para o [[Havaí]] todos aqueles anos antes, um reflexo das fantasias simples que haviam sido proibidas de uma menina branca, de classe média do [[Kansas]], a promessa de uma outra vida: quente, sensual, exótica, diferente.<ref name=":0" />
== Remakes e adaptações ==
Em [[1999]], um novo filme, [[Orfeu (filme)|Orfeu]], foi feita por [[Cacá Diegues]], com uma trilha sonora que caracteriza o cantor e compositor brasileiro [[Caetano Veloso]]. O diretor disse que não era um remake de Orfeu Negro, mas um filme baseado na peça original de Vinicius de Moraes, de 1956.<ref>{{Citar web|url=https://www1.folha.uol.com.br/fsp/ilustrad/fq23049921.htm|titulo=Folha de S.Paulo - Cinema - "Orfeu": Filme confirma mito, diz Caetano Veloso - 23/04/1999|acessodata=2023-02-18|website=www1.folha.uol.com.br}}</ref>
Em julho de 2014, uma adaptação musical de Broadway Orfeu Negro foi anunciada, a ser escrita por [[Lynn Nottage]] e dirigido por George C. Wolfe.<ref>{{Citar web|ultimo=Archive|primeiro=View Author|ultimo2=Twitter|primeiro2=Follow on|url=https://nypost.com/2020/02/13/antonio-carlos-jobims-music-finally-coming-to-broadway/|titulo=Antônio Carlos Jobim's music finally coming to Broadway|data=2020-02-13|acessodata=2023-02-18|lingua=en-US|ultimo3=feed|primeiro3=Get author RSS}}</ref>
== Na cultura popular ==
Cenas do filme foram utilizados no lyric vídeo da música Afterlife da banda [[Arcade Fire]], de 2013.<ref>{{Citar web|ultimo=G1|primeiro=Do|ultimo2=Paulo|primeiro2=em São|url=http://g1.globo.com/musica/noticia/2013/10/arcade-fire-lanca-clipe-com-imagens-do-filme-orfeu-do-carnaval.html|titulo=Arcade Fire lança clipe com imagens do filme 'Orfeu do Carnaval'|data=2013-10-22|acessodata=2023-02-18|website=Música|lingua=pt-br}}</ref>
== Ver também ==
* [[Lista de indicações brasileiras ao Oscar]]
{{Referências}}
== Bibliografia ==
* {{Citar periódico|ultimo=Campos-Muñoz|primeiro=Germán|data=2012|titulo=Contrapuntos órficos: Mitografía brasileña y el mito de Orfeo|url=https://muse.jhu.edu/article/502895|jornal=Latin American Research Review|lingua=es|volume=47|numero=4|paginas=31–48|doi=10.1353/lar.2012.0048|issn=1542-4278}}
{{Oscar de melhor filme estrangeiro}}
{{Palma de Ouro}}
{{Portal3|Cinema|Rio de Janeiro|Brasil|França|Itália}}
{{Controle de autoridade}}
[[Categoria:Filmes do Brasil de 1959]]
[[Categoria:Filmes da França de 1959]]
[[Categoria:Filmes da Itália de 1959]]
[[Categoria:Filmes de drama da Itália]]
[[Categoria:Filmes premiados com o Oscar de melhor filme internacional]]
[[Categoria:Filmes premiados com a Palma de Ouro]]
[[Categoria:Filmes baseados em peças de teatro]]
[[Categoria:Filmes premiados com o Globo de Ouro de melhor filme em língua estrangeira]]
[[Categoria:Filmes de drama do Brasil]]
[[Categoria:Filmes de drama da França]]
[[Categoria:Filmes de fantasia romântica]]
[[Categoria:Filmes em língua portuguesa]]
[[Categoria:Filmes baseados na mitologia greco-romana]]
[[Categoria:Filmes ambientados na cidade do Rio de Janeiro]]
[[Categoria:Filmes gravados na cidade do Rio de Janeiro]]
[[Categoria:Filmes sobre afro-brasileiros]]
[[Category:Wiki_Club_SHUATS]]
== Testing ==
cross-origin edit
testing hCaptcha bot detection
<references />
T423840-test
Second
7jm8wcq1ppie98ldmphyzwa3i07561k
739246
739245
2026-04-24T12:31:08Z
~2026-21716-09
73454
739246
wikitext
text/x-wiki
{{Ver desambig|este=o filme de Marcel Camus|a peça teatral de Vinícius de Moraes|Orfeu da Conceição}}
{{Info/Filme.
|título = Orfeu Negro
|título-pt = Orfeu Negro
|título-br = Orfeu do Carnaval
|imagem = [[Imagem:Orfeu Negro, 1959.jpg|Orfeu Negro, 1959|230px]]
|ano = 1959test
|duração = 100
|idioma = [[Língua portuguesa|Português]]
|país = [[Brasil]] • [[França]] • [[Itália]]
|direção = [[Marcel Camus]]
|roteiro = Marcel Camus<br />[[Jacques Viot]]
|criação original = {{Baseado em|[[Orfeu da Conceição]]|[[Vinicius de Moraes]]}}
|produção = Sasha Gordine.
|co-produtor = .
|produção executivo =
|música = [[Tom Jobim]]<br />[[Luiz Bonfá]]
|edição = Andrée Feix
|diretor de arte =
|diretor de fotografia = [[Jean Bourgoin]]
|figurino =
|precedido_por =
|seguido_por =d
|estúdio = Dispat Films<br />Gemma Cinematografica<br />Tupan Filmes
|elenco = [[Breno Mello]]<br />[[Marpessa Dawn]]<br />[[Lourdes de Oliveira]]<br />[[Léa Garcia]]
|código-IMDB = 0053146
|tipo = LF
|cor-pb = cor.
}}
'''''Orfeu Negro'''''<ref>{{Citation|title=Orfeu do Carnaval|url=https://www.adorocinema.com/filmes/filme-261/|accessdate=2023-02-18|language=pt-BR|last=AdoroCinema}}</ref> ou '''''Orfeu do Carnaval'''''<ref>{{Citar web|url=https://web.archive.org/web/20130522152505/http://noticias.r7.com/rio-de-janeiro/noticias/a-espera-de-obama-chapeu-mangueira-e-babilonia-preparam-documentario-e-cartas-ao-presidente-20110316.html|titulo=À espera de Obama, Chapéu Mangueira e Babilônia preparam documentário e cartas ao presidente - Rio de Janeiro - R7|data=2013-05-22|acessodata=2023-02-18|website=web.archive.org}}</ref> (na [[França]], '''''Orphée Noir'''''; na [[Itália]], '''''Orfeo Negro''''') é um [[filme]] ítalo-franco-[[brasil]]eiro de [[1959 no cinema|1959]], dirigido por [[Marcel Camus]] e com [[roteiro]] adaptado por Camus e [[Jacques Viot]] a, partir da [[peça teatral]] ''[[Orfeu da Conceição]]'', de [[Vinícius de Moraes]]..testtesd
A trilha sonora é de [[Tom Jobim]] e [[Luís Bonfá]]. Vinícius e [[Antônio Maria de Araújo Morais|Antônio Maria]] também tiveram músicas incluídas, mas, assim como [[Agostinho dos Santos]], que interpretou a música-tema de Orfeu, "[[Manhã de Carnaval]]", não receberam os créditos. O filme teve outra versão em 1999, sob o nome ''[[Orfeu (filme)|Orfeu]]'', dirigida por [[Cacá Diegues]]<nowiki/>tes
O filme ganhou o [[Oscar de Melhor Filme Internacional]] em 1960, representando a França.<ref>{{citar web|título=A França no Oscar: veja a lista dos filmes franceses premiados|url=http://blogs.oglobo.globo.com/paris/post/a-franca-no-oscar-veja-lista-dos-filmes-franceses-premiados-561194.html|acessodata=2 de Junho de 2016}}</ref> Trata-se da primeira produção de [[língua portuguesa]] a conquistar a estatueta do [[Oscar]].<ref>{{citar web|título=Quando os portugueses chegaram aos Óscares|url=http://mag.sapo.pt/cinema/atualidade-cinema/artigos/quando-os-portugueses-chegaram-aos-oscares?artigo-completo=sim|acessodata=2 de Junho de 2016}}</ref> É também, juntamente com ''[[Mustang (filme)|Mustang]]'', ''[[Emilia Pérez|Emilia Perez]]'' e ''[[Un Simple Accident|It Was Just an Accident]]'', um dos filmes não francófonos a representar a França no [[Oscar]]. test
test test test test test test test test test test test test test test test test test test test
== Enredo ==
O enredo é inspirado na [[mitologia grega]], na história de [[Orfeu]] e [[Eurídice]]. A adaptação ambientou a obra no Brasil, em uma [[favela]] do [[Rio de Janeiro (cidade)|Rio de Janeiro]], na época do [[Carnaval]]. Eurídice vem fugida do [[Sertão brasileiro|sertão nordestino]] para morar na favela com sua prima Serafina. Ela tem medo de um homem que está perseguindo-a e quer matá-la; ela não sabe o motivo, mas pensa que esse homem talvez tenha gostado dela e, como ela não lhe deu confiança, ele agora quer se vingar. Ela apaixona-se perdidamente por Orfeu, que é noivo da bela e sedutora Mira. O tempo passa, Mira passa a perseguir Eurídice, com ciúmes. Serafina ajuda a prima a namorar Orfeu. Eurídice conhece o carnaval [[Carioca (gentílico)|carioca]] ao lado de Orfeu, mas sempre se apavora e corre quando vê que o tal homem está perto.
Um dia, ela revela tudo a Orfeu. Ele a protege e diz que vai ficar ao seu lado. O namoro deles é puro e inocente, sem malícia. Passa o tempo. Um dia, se divertindo no último dia de carnaval, Eurídice teme que o homem apareça, e acha melhor voltar para a favela, que fica perto. Ela entra num beco escuro, para subir a favela, mas ela não conhece bem o local e fica assustada. O homem a encontra e a persegue. Ela sai correndo desesperada e entra num galpão velho e escuro. Ela tenta se esconder do homem, mas este a acha. Desesperada, ela pula de um tablado e se segura em um fio de alta tensão. Orfeu chega e liga a tensão, Eurídice cai e morre eletrocutada. Orfeu briga com homem e fica inconsciente, quando acorda se dá conta dos fatos. Ele fica desolado.
A ambulância chega e leva o corpo ao [[Instituto Médico Legal]]. Ele não pode ir junto. [[Quarta-feira de cinzas]] e Orfeu só sabe chorar. Ele vai atrás do corpo, faz uma sessão [[Espiritismo|espírita]] na qual Eurídice baixa no corpo de uma senhora, mas, enfim, Orfeu acha seu corpo. Ele sequestra-o e leva à favela. Mira vê, e enfurecida, joga uma pedra na cabeça de Orfeu. Com a pancada ele cai de uma ribanceira com o corpo morto de Eurídice nos braços e morre também..
== Elenco principal ==
[[Ficheiro:Marpessa Dawn, 1959.tif|miniaturadaimagem|[[Breno Mello]] e [[Marpessa Dawn]] atuando em Orfeu Negro]]
* [[Breno Mello]] .... Orfeu
* [[Marpessa Dawn]] .... Eurídice
* [[Lourdes de Oliveira]] .... Mira
* [[Léa Garcia]] .... Serafina
* [[Adhemar Ferreira da Silva]] .... Morte
* [[Alexandro Constantino]] .... Hermes
* [[Waldemar de Souza|Waldir de Souza]] (Waldir 59) .... Chico Bôto
* [[Jorge dos Santos]] .... Benedito
* [[Aurino Cassiano]] .... Zeca
* [[Tião Macalé]] .... Homem vendendo o Gramofone.
* [[Cartola_(compositor)|Cartola]] (participação especial)
== Principais prêmios e indicações ==
[[Festival de Cannes]] 1959 (França)
* Recebeu a [[Palma de Ouro]].
''[[Óscar|Oscar]]'' 1960 (EUA)
* Vencedor na categoria de melhor filme em língua estrangeira (português/diretor).
[[Prêmios Globo de Ouro|Globo de Ouro]] 1960 (EUA)
* Venceu na categoria de melhor filme estrangeiro (França).
''[[BAFTA|British Academy of Film and Television Arts]]'' 1961 (Reino Unido)
* Indicado na categoria de melhor filme em língua estrangeira (Brasil, França e Itália/produção).
== Influência ==
Orfeu Negro foi citado por [[Jean-Michel Basquiat]] como uma de suas primeiras influências musicais, enquanto [[Barack Obama]] observa em seu livro de memórias [[Dreams from My Father]] (1995) que era o filme favorito de sua mãe.<ref name=":0">{{Citar web|ultimo=|url=https://www.correiobraziliense.com.br/app/noticia/diversao-e-arte/2010/06/05/interna_diversao_arte,196187/obama-e-quase-brasileiro.shtml|titulo=Obama é 'quase' brasileiro|data=18-2-2023|acessodata=2023-02-18|website=Acervo|lingua=pt-BR}}</ref>
Obama, no entanto, não compartilhar preferências de sua mãe após a primeira a ver o filme durante seus primeiros anos na [[Universidade Columbia|Universidade de Columbia]]: "de repente eu percebi que a representação dos negros infantis que eu estava vendo agora na tela, a imagem inversa de selvagens escuros de Conrad, era o que minha mãe tinha levado com ela para o [[Havaí]] todos aqueles anos antes, um reflexo das fantasias simples que haviam sido proibidas de uma menina branca, de classe média do [[Kansas]], a promessa de uma outra vida: quente, sensual, exótica, diferente.<ref name=":0" />
== Remakes e adaptações ==
Em [[1999]], um novo filme, [[Orfeu (filme)|Orfeu]], foi feita por [[Cacá Diegues]], com uma trilha sonora que caracteriza o cantor e compositor brasileiro [[Caetano Veloso]]. O diretor disse que não era um remake de Orfeu Negro, mas um filme baseado na peça original de Vinicius de Moraes, de 1956.<ref>{{Citar web|url=https://www1.folha.uol.com.br/fsp/ilustrad/fq23049921.htm|titulo=Folha de S.Paulo - Cinema - "Orfeu": Filme confirma mito, diz Caetano Veloso - 23/04/1999|acessodata=2023-02-18|website=www1.folha.uol.com.br}}</ref>
Em julho de 2014, uma adaptação musical de Broadway Orfeu Negro foi anunciada, a ser escrita por [[Lynn Nottage]] e dirigido por George C. Wolfe.<ref>{{Citar web|ultimo=Archive|primeiro=View Author|ultimo2=Twitter|primeiro2=Follow on|url=https://nypost.com/2020/02/13/antonio-carlos-jobims-music-finally-coming-to-broadway/|titulo=Antônio Carlos Jobim's music finally coming to Broadway|data=2020-02-13|acessodata=2023-02-18|lingua=en-US|ultimo3=feed|primeiro3=Get author RSS}}</ref>
== Na cultura popular ==
Cenas do filme foram utilizados no lyric vídeo da música Afterlife da banda [[Arcade Fire]], de 2013.<ref>{{Citar web|ultimo=G1|primeiro=Do|ultimo2=Paulo|primeiro2=em São|url=http://g1.globo.com/musica/noticia/2013/10/arcade-fire-lanca-clipe-com-imagens-do-filme-orfeu-do-carnaval.html|titulo=Arcade Fire lança clipe com imagens do filme 'Orfeu do Carnaval'|data=2013-10-22|acessodata=2023-02-18|website=Música|lingua=pt-br}}</ref>
== Ver também ==
* [[Lista de indicações brasileiras ao Oscar]]
{{Referências}}
== Bibliografia ==
* {{Citar periódico|ultimo=Campos-Muñoz|primeiro=Germán|data=2012|titulo=Contrapuntos órficos: Mitografía brasileña y el mito de Orfeo|url=https://muse.jhu.edu/article/502895|jornal=Latin American Research Review|lingua=es|volume=47|numero=4|paginas=31–48|doi=10.1353/lar.2012.0048|issn=1542-4278}}
{{Oscar de melhor filme estrangeiro}}
{{Palma de Ouro}}
{{Portal3|Cinema|Rio de Janeiro|Brasil|França|Itália}}
{{Controle de autoridade}}
[[Categoria:Filmes do Brasil de 1959]]
[[Categoria:Filmes da França de 1959]]
[[Categoria:Filmes da Itália de 1959]]
[[Categoria:Filmes de drama da Itália]]
[[Categoria:Filmes premiados com o Oscar de melhor filme internacional]]
[[Categoria:Filmes premiados com a Palma de Ouro]]
[[Categoria:Filmes baseados em peças de teatro]]
[[Categoria:Filmes premiados com o Globo de Ouro de melhor filme em língua estrangeira]]
[[Categoria:Filmes de drama do Brasil]]
[[Categoria:Filmes de drama da França]]
[[Categoria:Filmes de fantasia romântica]]
[[Categoria:Filmes em língua portuguesa]]
[[Categoria:Filmes baseados na mitologia greco-romana]]
[[Categoria:Filmes ambientados na cidade do Rio de Janeiro]]
[[Categoria:Filmes gravados na cidade do Rio de Janeiro]]
[[Categoria:Filmes sobre afro-brasileiros]]
[[Category:Wiki_Club_SHUATS]]
== Testing ==
cross-origin edit
testing hCaptcha bot detection
<references />
T423840-test
Second
iu6oo4c0k6n8hd2xfwevw8wbdk1wwag
739247
739246
2026-04-24T12:31:33Z
~2026-21716-09
73454
showcaptcha
739247
wikitext
text/x-wiki
{{Ver desambig|este=o filme de Marcel Camus|a peça teatral de Vinícius de Moraes|Orfeu da Conceição}}
{{Info/Filme.
|título = Orfeu Negro
|título-pt = Orfeu Negro
|título-br = Orfeu do Carnaval
|imagem = [[Imagem:Orfeu Negro, 1959.jpg|Orfeu Negro, 1959|230px]]
|ano = 1959test
|duração = 100
|idioma = [[Língua portuguesa|Português]]
|país = [[Brasil]] • [[França]] • [[Itália]]
|direção = [[Marcel Camus]]
|roteiro = Marcel Camus<br />[[Jacques Viot]]
|criação original = {{Baseado em|[[Orfeu da Conceição]]|[[Vinicius de Moraes]]}}
|produção = Sasha Gordine.
|co-produtor = .
|produção executivo =
|música = [[Tom Jobim]]<br />[[Luiz Bonfá]]
|edição = Andrée Feix
|diretor de arte =
|diretor de fotografia = [[Jean Bourgoin]]
|figurino =
|precedido_por =
|seguido_por =d
|estúdio = Dispat Films<br />Gemma Cinematografica<br />Tupan Filmes
|elenco = [[Breno Mello]]<br />[[Marpessa Dawn]]<br />[[Lourdes de Oliveira]]<br />[[Léa Garcia]]
|código-IMDB = 0053146
|tipo = LF
|cor-pb = cor.
}}
'''''Orfeu Negro'''''<ref>{{Citation|title=Orfeu do Carnaval|url=https://www.adorocinema.com/filmes/filme-261/|accessdate=2023-02-18|language=pt-BR|last=AdoroCinema}}</ref> ou '''''Orfeu do Carnaval'''''<ref>{{Citar web|url=https://web.archive.org/web/20130522152505/http://noticias.r7.com/rio-de-janeiro/noticias/a-espera-de-obama-chapeu-mangueira-e-babilonia-preparam-documentario-e-cartas-ao-presidente-20110316.html|titulo=À espera de Obama, Chapéu Mangueira e Babilônia preparam documentário e cartas ao presidente - Rio de Janeiro - R7|data=2013-05-22|acessodata=2023-02-18|website=web.archive.org}}</ref> (na [[França]], '''''Orphée Noir'''''; na [[Itália]], '''''Orfeo Negro''''') é um [[filme]] ítalo-franco-[[brasil]]eiro de [[1959 no cinema|1959]], dirigido por [[Marcel Camus]] e com [[roteiro]] adaptado por Camus e [[Jacques Viot]] a, partir da [[peça teatral]] ''[[Orfeu da Conceição]]'', de [[Vinícius de Moraes]].
A trilha sonora é de [[Tom Jobim]] e [[Luís Bonfá]]. Vinícius e [[Antônio Maria de Araújo Morais|Antônio Maria]] também tiveram músicas incluídas, mas, assim como [[Agostinho dos Santos]], que interpretou a música-tema de Orfeu, "[[Manhã de Carnaval]]", não receberam os créditos. O filme teve outra versão em 1999, sob o nome ''[[Orfeu (filme)|Orfeu]]'', dirigida por [[Cacá Diegues]]<nowiki/>tes
O filme ganhou o [[Oscar de Melhor Filme Internacional]] em 1960, representando a França.<ref>{{citar web|título=A França no Oscar: veja a lista dos filmes franceses premiados|url=http://blogs.oglobo.globo.com/paris/post/a-franca-no-oscar-veja-lista-dos-filmes-franceses-premiados-561194.html|acessodata=2 de Junho de 2016}}</ref> Trata-se da primeira produção de [[língua portuguesa]] a conquistar a estatueta do [[Oscar]].<ref>{{citar web|título=Quando os portugueses chegaram aos Óscares|url=http://mag.sapo.pt/cinema/atualidade-cinema/artigos/quando-os-portugueses-chegaram-aos-oscares?artigo-completo=sim|acessodata=2 de Junho de 2016}}</ref> É também, juntamente com ''[[Mustang (filme)|Mustang]]'', ''[[Emilia Pérez|Emilia Perez]]'' e ''[[Un Simple Accident|It Was Just an Accident]]'', um dos filmes não francófonos a representar a França no [[Oscar]]. test
test test test test test test test test test test test test test test test test test test test
== Enredo ==
O enredo é inspirado na [[mitologia grega]], na história de [[Orfeu]] e [[Eurídice]]. A adaptação ambientou a obra no Brasil, em uma [[favela]] do [[Rio de Janeiro (cidade)|Rio de Janeiro]], na época do [[Carnaval]]. Eurídice vem fugida do [[Sertão brasileiro|sertão nordestino]] para morar na favela com sua prima Serafina. Ela tem medo de um homem que está perseguindo-a e quer matá-la; ela não sabe o motivo, mas pensa que esse homem talvez tenha gostado dela e, como ela não lhe deu confiança, ele agora quer se vingar. Ela apaixona-se perdidamente por Orfeu, que é noivo da bela e sedutora Mira. O tempo passa, Mira passa a perseguir Eurídice, com ciúmes. Serafina ajuda a prima a namorar Orfeu. Eurídice conhece o carnaval [[Carioca (gentílico)|carioca]] ao lado de Orfeu, mas sempre se apavora e corre quando vê que o tal homem está perto.
Um dia, ela revela tudo a Orfeu. Ele a protege e diz que vai ficar ao seu lado. O namoro deles é puro e inocente, sem malícia. Passa o tempo. Um dia, se divertindo no último dia de carnaval, Eurídice teme que o homem apareça, e acha melhor voltar para a favela, que fica perto. Ela entra num beco escuro, para subir a favela, mas ela não conhece bem o local e fica assustada. O homem a encontra e a persegue. Ela sai correndo desesperada e entra num galpão velho e escuro. Ela tenta se esconder do homem, mas este a acha. Desesperada, ela pula de um tablado e se segura em um fio de alta tensão. Orfeu chega e liga a tensão, Eurídice cai e morre eletrocutada. Orfeu briga com homem e fica inconsciente, quando acorda se dá conta dos fatos. Ele fica desolado.
A ambulância chega e leva o corpo ao [[Instituto Médico Legal]]. Ele não pode ir junto. [[Quarta-feira de cinzas]] e Orfeu só sabe chorar. Ele vai atrás do corpo, faz uma sessão [[Espiritismo|espírita]] na qual Eurídice baixa no corpo de uma senhora, mas, enfim, Orfeu acha seu corpo. Ele sequestra-o e leva à favela. Mira vê, e enfurecida, joga uma pedra na cabeça de Orfeu. Com a pancada ele cai de uma ribanceira com o corpo morto de Eurídice nos braços e morre também..
== Elenco principal ==
[[Ficheiro:Marpessa Dawn, 1959.tif|miniaturadaimagem|[[Breno Mello]] e [[Marpessa Dawn]] atuando em Orfeu Negro]]
* [[Breno Mello]] .... Orfeu
* [[Marpessa Dawn]] .... Eurídice
* [[Lourdes de Oliveira]] .... Mira
* [[Léa Garcia]] .... Serafina
* [[Adhemar Ferreira da Silva]] .... Morte
* [[Alexandro Constantino]] .... Hermes
* [[Waldemar de Souza|Waldir de Souza]] (Waldir 59) .... Chico Bôto
* [[Jorge dos Santos]] .... Benedito
* [[Aurino Cassiano]] .... Zeca
* [[Tião Macalé]] .... Homem vendendo o Gramofone.
* [[Cartola_(compositor)|Cartola]] (participação especial)
== Principais prêmios e indicações ==
[[Festival de Cannes]] 1959 (França)
* Recebeu a [[Palma de Ouro]].
''[[Óscar|Oscar]]'' 1960 (EUA)
* Vencedor na categoria de melhor filme em língua estrangeira (português/diretor).
[[Prêmios Globo de Ouro|Globo de Ouro]] 1960 (EUA)
* Venceu na categoria de melhor filme estrangeiro (França).
''[[BAFTA|British Academy of Film and Television Arts]]'' 1961 (Reino Unido)
* Indicado na categoria de melhor filme em língua estrangeira (Brasil, França e Itália/produção).
== Influência ==
Orfeu Negro foi citado por [[Jean-Michel Basquiat]] como uma de suas primeiras influências musicais, enquanto [[Barack Obama]] observa em seu livro de memórias [[Dreams from My Father]] (1995) que era o filme favorito de sua mãe.<ref name=":0">{{Citar web|ultimo=|url=https://www.correiobraziliense.com.br/app/noticia/diversao-e-arte/2010/06/05/interna_diversao_arte,196187/obama-e-quase-brasileiro.shtml|titulo=Obama é 'quase' brasileiro|data=18-2-2023|acessodata=2023-02-18|website=Acervo|lingua=pt-BR}}</ref>
Obama, no entanto, não compartilhar preferências de sua mãe após a primeira a ver o filme durante seus primeiros anos na [[Universidade Columbia|Universidade de Columbia]]: "de repente eu percebi que a representação dos negros infantis que eu estava vendo agora na tela, a imagem inversa de selvagens escuros de Conrad, era o que minha mãe tinha levado com ela para o [[Havaí]] todos aqueles anos antes, um reflexo das fantasias simples que haviam sido proibidas de uma menina branca, de classe média do [[Kansas]], a promessa de uma outra vida: quente, sensual, exótica, diferente.<ref name=":0" />
== Remakes e adaptações ==
Em [[1999]], um novo filme, [[Orfeu (filme)|Orfeu]], foi feita por [[Cacá Diegues]], com uma trilha sonora que caracteriza o cantor e compositor brasileiro [[Caetano Veloso]]. O diretor disse que não era um remake de Orfeu Negro, mas um filme baseado na peça original de Vinicius de Moraes, de 1956.<ref>{{Citar web|url=https://www1.folha.uol.com.br/fsp/ilustrad/fq23049921.htm|titulo=Folha de S.Paulo - Cinema - "Orfeu": Filme confirma mito, diz Caetano Veloso - 23/04/1999|acessodata=2023-02-18|website=www1.folha.uol.com.br}}</ref>
Em julho de 2014, uma adaptação musical de Broadway Orfeu Negro foi anunciada, a ser escrita por [[Lynn Nottage]] e dirigido por George C. Wolfe.<ref>{{Citar web|ultimo=Archive|primeiro=View Author|ultimo2=Twitter|primeiro2=Follow on|url=https://nypost.com/2020/02/13/antonio-carlos-jobims-music-finally-coming-to-broadway/|titulo=Antônio Carlos Jobim's music finally coming to Broadway|data=2020-02-13|acessodata=2023-02-18|lingua=en-US|ultimo3=feed|primeiro3=Get author RSS}}</ref>
== Na cultura popular ==
Cenas do filme foram utilizados no lyric vídeo da música Afterlife da banda [[Arcade Fire]], de 2013.<ref>{{Citar web|ultimo=G1|primeiro=Do|ultimo2=Paulo|primeiro2=em São|url=http://g1.globo.com/musica/noticia/2013/10/arcade-fire-lanca-clipe-com-imagens-do-filme-orfeu-do-carnaval.html|titulo=Arcade Fire lança clipe com imagens do filme 'Orfeu do Carnaval'|data=2013-10-22|acessodata=2023-02-18|website=Música|lingua=pt-br}}</ref>
== Ver também ==
* [[Lista de indicações brasileiras ao Oscar]]
{{Referências}}
== Bibliografia ==
* {{Citar periódico|ultimo=Campos-Muñoz|primeiro=Germán|data=2012|titulo=Contrapuntos órficos: Mitografía brasileña y el mito de Orfeo|url=https://muse.jhu.edu/article/502895|jornal=Latin American Research Review|lingua=es|volume=47|numero=4|paginas=31–48|doi=10.1353/lar.2012.0048|issn=1542-4278}}
{{Oscar de melhor filme estrangeiro}}
{{Palma de Ouro}}
{{Portal3|Cinema|Rio de Janeiro|Brasil|França|Itália}}
{{Controle de autoridade}}
[[Categoria:Filmes do Brasil de 1959]]
[[Categoria:Filmes da França de 1959]]
[[Categoria:Filmes da Itália de 1959]]
[[Categoria:Filmes de drama da Itália]]
[[Categoria:Filmes premiados com o Oscar de melhor filme internacional]]
[[Categoria:Filmes premiados com a Palma de Ouro]]
[[Categoria:Filmes baseados em peças de teatro]]
[[Categoria:Filmes premiados com o Globo de Ouro de melhor filme em língua estrangeira]]
[[Categoria:Filmes de drama do Brasil]]
[[Categoria:Filmes de drama da França]]
[[Categoria:Filmes de fantasia romântica]]
[[Categoria:Filmes em língua portuguesa]]
[[Categoria:Filmes baseados na mitologia greco-romana]]
[[Categoria:Filmes ambientados na cidade do Rio de Janeiro]]
[[Categoria:Filmes gravados na cidade do Rio de Janeiro]]
[[Categoria:Filmes sobre afro-brasileiros]]
[[Category:Wiki_Club_SHUATS]]
== Testing ==
cross-origin edit
testing hCaptcha bot detection
<references />
T423840-test
Second
1c7z50z92efck8364qnwogvzb42w7g2
739248
739247
2026-04-24T12:32:07Z
~2026-21716-09
73454
showcaptcha
739248
wikitext
text/x-wiki
{{Ver desambig|este=o filme de Marcel Camus|a peça teatral de Vinícius de Moraes|Orfeu da Conceição}}
{{Info/Filme.
|título = Orfeu Negro
|título-pt = Orfeu Negro
|título-br = Orfeu do Carnaval
|imagem = [[Imagem:Orfeu Negro, 1959.jpg|Orfeu Negro, 1959|230px]]
|ano = 1959test
|duração = 100
|idioma = [[Língua portuguesa|Português]]
|país = [[Brasil]] • [[França]] • [[Itália]]
|direção = [[Marcel Camus]]
|roteiro = Marcel Camus<br />[[Jacques Viot]]
|criação original = {{Baseado em|[[Orfeu da Conceição]]|[[Vinicius de Moraes]]}}
|produção = Sasha Gordine.
|co-produtor = .
|produção executivo =
|música = [[Tom Jobim]]<br />[[Luiz Bonfá]]
|edição = Andrée Feix
|diretor de arte =
|diretor de fotografia = [[Jean Bourgoin]]
|figurino =
|precedido_por =
|seguido_por =d
|estúdio = Dispat Films<br />Gemma Cinematografica<br />Tupan Filmes
|elenco = [[Breno Mello]]<br />[[Marpessa Dawn]]<br />[[Lourdes de Oliveira]]<br />[[Léa Garcia]]
|código-IMDB = 0053146
|tipo = LF
|cor-pb = cor.
}}
'''''Orfeu Negro'''''<ref>{{Citation|title=Orfeu do Carnaval|url=https://www.adorocinema.com/filmes/filme-261/|accessdate=2023-02-18|language=pt-BR|last=AdoroCinema}}</ref> ou '''''Orfeu do Carnaval'''''<ref>{{Citar web|url=https://web.archive.org/web/20130522152505/http://noticias.r7.com/rio-de-janeiro/noticias/a-espera-de-obama-chapeu-mangueira-e-babilonia-preparam-documentario-e-cartas-ao-presidente-20110316.html|titulo=À espera de Obama, Chapéu Mangueira e Babilônia preparam documentário e cartas ao presidente - Rio de Janeiro - R7|data=2013-05-22|acessodata=2023-02-18|website=web.archive.org}}</ref> (na [[França]], '''''Orphée Noir'''''; na [[Itália]], '''''Orfeo Negro''''') é um [[filme]] ítalo-franco-[[brasil]]eiro de [[1959 no cinema|1959]], dirigido por [[Marcel Camus]] e com [[roteiro]] adaptado por Camus e [[Jacques Viot]] a, partir da [[peça teatral]] ''[[Orfeu da Conceição]]'', de [[Vinícius de Moraes]].
A trilha sonora é de [[Tom Jobim]] e [[Luís Bonfá]]. Vinícius e [[Antônio Maria de Araújo Morais|Antônio Maria]] também tiveram músicas incluídas, mas, assim como [[Agostinho dos Santos]], que interpretou a música-tema de Orfeu, "[[Manhã de Carnaval]]", não receberam os créditos. O filme teve outra versão em 1999, sob o nome ''[[Orfeu (filme)|Orfeu]]'', dirigida por [[Cacá Diegues]]<nowiki/>tes
O filme ganhou o [[Oscar de Melhor Filme Internacional]] em 1960, representando a França.<ref>{{citar web|título=A França no Oscar: veja a lista dos filmes franceses premiados|url=http://blogs.oglobo.globo.com/paris/post/a-franca-no-oscar-veja-lista-dos-filmes-franceses-premiados-561194.html|acessodata=2 de Junho de 2016}}</ref> Trata-se da primeira produção de [[língua portuguesa]] a conquistar a estatueta do [[Oscar]].<ref>{{citar web|título=Quando os portugueses chegaram aos Óscares|url=http://mag.sapo.pt/cinema/atualidade-cinema/artigos/quando-os-portugueses-chegaram-aos-oscares?artigo-completo=sim|acessodata=2 de Junho de 2016}}</ref> É também, juntamente com ''[[Mustang (filme)|Mustang]]'', ''[[Emilia Pérez|Emilia Perez]]'' e ''[[Un Simple Accident|It Was Just an Accident]]'', um dos filmes não francófonos a representar a França no [[Oscar]]. test
test test test test test test test test test test test test test test test test test test
== Enredo ==
O enredo é inspirado na [[mitologia grega]], na história de [[Orfeu]] e [[Eurídice]]. A adaptação ambientou a obra no Brasil, em uma [[favela]] do [[Rio de Janeiro (cidade)|Rio de Janeiro]], na época do [[Carnaval]]. Eurídice vem fugida do [[Sertão brasileiro|sertão nordestino]] para morar na favela com sua prima Serafina. Ela tem medo de um homem que está perseguindo-a e quer matá-la; ela não sabe o motivo, mas pensa que esse homem talvez tenha gostado dela e, como ela não lhe deu confiança, ele agora quer se vingar. Ela apaixona-se perdidamente por Orfeu, que é noivo da bela e sedutora Mira. O tempo passa, Mira passa a perseguir Eurídice, com ciúmes. Serafina ajuda a prima a namorar Orfeu. Eurídice conhece o carnaval [[Carioca (gentílico)|carioca]] ao lado de Orfeu, mas sempre se apavora e corre quando vê que o tal homem está perto.
Um dia, ela revela tudo a Orfeu. Ele a protege e diz que vai ficar ao seu lado. O namoro deles é puro e inocente, sem malícia. Passa o tempo. Um dia, se divertindo no último dia de carnaval, Eurídice teme que o homem apareça, e acha melhor voltar para a favela, que fica perto. Ela entra num beco escuro, para subir a favela, mas ela não conhece bem o local e fica assustada. O homem a encontra e a persegue. Ela sai correndo desesperada e entra num galpão velho e escuro. Ela tenta se esconder do homem, mas este a acha. Desesperada, ela pula de um tablado e se segura em um fio de alta tensão. Orfeu chega e liga a tensão, Eurídice cai e morre eletrocutada. Orfeu briga com homem e fica inconsciente, quando acorda se dá conta dos fatos. Ele fica desolado.
A ambulância chega e leva o corpo ao [[Instituto Médico Legal]]. Ele não pode ir junto. [[Quarta-feira de cinzas]] e Orfeu só sabe chorar. Ele vai atrás do corpo, faz uma sessão [[Espiritismo|espírita]] na qual Eurídice baixa no corpo de uma senhora, mas, enfim, Orfeu acha seu corpo. Ele sequestra-o e leva à favela. Mira vê, e enfurecida, joga uma pedra na cabeça de Orfeu. Com a pancada ele cai de uma ribanceira com o corpo morto de Eurídice nos braços e morre também..
== Elenco principal ==
[[Ficheiro:Marpessa Dawn, 1959.tif|miniaturadaimagem|[[Breno Mello]] e [[Marpessa Dawn]] atuando em Orfeu Negro]]
* [[Breno Mello]] .... Orfeu
* [[Marpessa Dawn]] .... Eurídice
* [[Lourdes de Oliveira]] .... Mira
* [[Léa Garcia]] .... Serafina
* [[Adhemar Ferreira da Silva]] .... Morte
* [[Alexandro Constantino]] .... Hermes
* [[Waldemar de Souza|Waldir de Souza]] (Waldir 59) .... Chico Bôto
* [[Jorge dos Santos]] .... Benedito
* [[Aurino Cassiano]] .... Zeca
* [[Tião Macalé]] .... Homem vendendo o Gramofone.
* [[Cartola_(compositor)|Cartola]] (participação especial)
== Principais prêmios e indicações ==
[[Festival de Cannes]] 1959 (França)
* Recebeu a [[Palma de Ouro]].
''[[Óscar|Oscar]]'' 1960 (EUA)
* Vencedor na categoria de melhor filme em língua estrangeira (português/diretor).
[[Prêmios Globo de Ouro|Globo de Ouro]] 1960 (EUA)
* Venceu na categoria de melhor filme estrangeiro (França).
''[[BAFTA|British Academy of Film and Television Arts]]'' 1961 (Reino Unido)
* Indicado na categoria de melhor filme em língua estrangeira (Brasil, França e Itália/produção).
== Influência ==
Orfeu Negro foi citado por [[Jean-Michel Basquiat]] como uma de suas primeiras influências musicais, enquanto [[Barack Obama]] observa em seu livro de memórias [[Dreams from My Father]] (1995) que era o filme favorito de sua mãe.<ref name=":0">{{Citar web|ultimo=|url=https://www.correiobraziliense.com.br/app/noticia/diversao-e-arte/2010/06/05/interna_diversao_arte,196187/obama-e-quase-brasileiro.shtml|titulo=Obama é 'quase' brasileiro|data=18-2-2023|acessodata=2023-02-18|website=Acervo|lingua=pt-BR}}</ref>
Obama, no entanto, não compartilhar preferências de sua mãe após a primeira a ver o filme durante seus primeiros anos na [[Universidade Columbia|Universidade de Columbia]]: "de repente eu percebi que a representação dos negros infantis que eu estava vendo agora na tela, a imagem inversa de selvagens escuros de Conrad, era o que minha mãe tinha levado com ela para o [[Havaí]] todos aqueles anos antes, um reflexo das fantasias simples que haviam sido proibidas de uma menina branca, de classe média do [[Kansas]], a promessa de uma outra vida: quente, sensual, exótica, diferente.<ref name=":0" />
== Remakes e adaptações ==
Em [[1999]], um novo filme, [[Orfeu (filme)|Orfeu]], foi feita por [[Cacá Diegues]], com uma trilha sonora que caracteriza o cantor e compositor brasileiro [[Caetano Veloso]]. O diretor disse que não era um remake de Orfeu Negro, mas um filme baseado na peça original de Vinicius de Moraes, de 1956.<ref>{{Citar web|url=https://www1.folha.uol.com.br/fsp/ilustrad/fq23049921.htm|titulo=Folha de S.Paulo - Cinema - "Orfeu": Filme confirma mito, diz Caetano Veloso - 23/04/1999|acessodata=2023-02-18|website=www1.folha.uol.com.br}}</ref>
Em julho de 2014, uma adaptação musical de Broadway Orfeu Negro foi anunciada, a ser escrita por [[Lynn Nottage]] e dirigido por George C. Wolfe.<ref>{{Citar web|ultimo=Archive|primeiro=View Author|ultimo2=Twitter|primeiro2=Follow on|url=https://nypost.com/2020/02/13/antonio-carlos-jobims-music-finally-coming-to-broadway/|titulo=Antônio Carlos Jobim's music finally coming to Broadway|data=2020-02-13|acessodata=2023-02-18|lingua=en-US|ultimo3=feed|primeiro3=Get author RSS}}</ref>
== Na cultura popular ==
Cenas do filme foram utilizados no lyric vídeo da música Afterlife da banda [[Arcade Fire]], de 2013.<ref>{{Citar web|ultimo=G1|primeiro=Do|ultimo2=Paulo|primeiro2=em São|url=http://g1.globo.com/musica/noticia/2013/10/arcade-fire-lanca-clipe-com-imagens-do-filme-orfeu-do-carnaval.html|titulo=Arcade Fire lança clipe com imagens do filme 'Orfeu do Carnaval'|data=2013-10-22|acessodata=2023-02-18|website=Música|lingua=pt-br}}</ref>
== Ver também ==
* [[Lista de indicações brasileiras ao Oscar]]
{{Referências}}
== Bibliografia ==
* {{Citar periódico|ultimo=Campos-Muñoz|primeiro=Germán|data=2012|titulo=Contrapuntos órficos: Mitografía brasileña y el mito de Orfeo|url=https://muse.jhu.edu/article/502895|jornal=Latin American Research Review|lingua=es|volume=47|numero=4|paginas=31–48|doi=10.1353/lar.2012.0048|issn=1542-4278}}
{{Oscar de melhor filme estrangeiro}}
{{Palma de Ouro}}
{{Portal3|Cinema|Rio de Janeiro|Brasil|França|Itália}}
{{Controle de autoridade}}
[[Categoria:Filmes do Brasil de 1959]]
[[Categoria:Filmes da França de 1959]]
[[Categoria:Filmes da Itália de 1959]]
[[Categoria:Filmes de drama da Itália]]
[[Categoria:Filmes premiados com o Oscar de melhor filme internacional]]
[[Categoria:Filmes premiados com a Palma de Ouro]]
[[Categoria:Filmes baseados em peças de teatro]]
[[Categoria:Filmes premiados com o Globo de Ouro de melhor filme em língua estrangeira]]
[[Categoria:Filmes de drama do Brasil]]
[[Categoria:Filmes de drama da França]]
[[Categoria:Filmes de fantasia romântica]]
[[Categoria:Filmes em língua portuguesa]]
[[Categoria:Filmes baseados na mitologia greco-romana]]
[[Categoria:Filmes ambientados na cidade do Rio de Janeiro]]
[[Categoria:Filmes gravados na cidade do Rio de Janeiro]]
[[Categoria:Filmes sobre afro-brasileiros]]
[[Category:Wiki_Club_SHUATS]]
== Testing ==
cross-origin edit
testing hCaptcha bot detection
<references />
T423840-test
Second
hlmmclr00m80puq6wxtb4noitbo7wxh
739261
739248
2026-04-24T13:39:31Z
~2026-21716-09
73454
showcaptcha
739261
wikitext
text/x-wiki
{{Ver desambig|este=o filme de Marcel Camus|a peça teatral de Vinícius de Moraes|Orfeu da Conceição}}
{{Info/Filme.
|título = Orfeu Negro
|título-pt = Orfeu Negro
|título-br = Orfeu do Carnaval
|imagem = [[Imagem:Orfeu Negro, 1959.jpg|Orfeu Negro, 1959|230px]]
|ano = 1959test
|duração = 100
|idioma = [[Língua portuguesa|Português]]
|país = [[Brasil]] • [[França]] • [[Itália]]
|direção = [[Marcel Camus]]
|roteiro = Marcel Camus<br />[[Jacques Viot]]
|criação original = {{Baseado em|[[Orfeu da Conceição]]|[[Vinicius de Moraes]]}}
|produção = Sasha Gordine.
|co-produtor = .
|produção executivo =
|música = [[Tom Jobim]]<br />[[Luiz Bonfá]]
|edição = Andrée Feix
|diretor de arte =
|diretor de fotografia = [[Jean Bourgoin]]
|figurino =
|precedido_por =
|seguido_por =d
|estúdio = Dispat Films<br />Gemma Cinematografica<br />Tupan Filmes
|elenco = [[Breno Mello]]<br />[[Marpessa Dawn]]<br />[[Lourdes de Oliveira]]<br />[[Léa Garcia]]
|código-IMDB = 0053146
|tipo = LF
|cor-pb = cor.
}}
'''''Orfeu Negro'''''<ref>{{Citation|title=Orfeu do Carnaval|url=https://www.adorocinema.com/filmes/filme-261/|accessdate=2023-02-18|language=pt-BR|last=AdoroCinema}}</ref> ou '''''Orfeu do Carnaval'''''<ref>{{Citar web|url=https://web.archive.org/web/20130522152505/http://noticias.r7.com/rio-de-janeiro/noticias/a-espera-de-obama-chapeu-mangueira-e-babilonia-preparam-documentario-e-cartas-ao-presidente-20110316.html|titulo=À espera de Obama, Chapéu Mangueira e Babilônia preparam documentário e cartas ao presidente - Rio de Janeiro - R7|data=2013-05-22|acessodata=2023-02-18|website=web.archive.org}}</ref> (na [[França]], '''''Orphée Noir'''''; na [[Itália]], '''''Orfeo Negro''''') é um [[filme]] ítalo-franco-[[brasil]]eiro de [[1959 no cinema|1959]], dirigido por [[Marcel Camus]] e com [[roteiro]] adaptado por Camus e [[Jacques Viot]] a, partir da [[peça teatral]] ''[[Orfeu da Conceição]]'', de [[Vinícius de Moraes]]
A trilha sonora é de [[Tom Jobim]] e [[Luís Bonfá]]. Vinícius e [[Antônio Maria de Araújo Morais|Antônio Maria]] também tiveram músicas incluídas, mas, assim como [[Agostinho dos Santos]], que interpretou a música-tema de Orfeu, "[[Manhã de Carnaval]]", não receberam os créditos. O filme teve outra versão em 1999, sob o nome ''[[Orfeu (filme)|Orfeu]]'', dirigida por [[Cacá Diegues]]<nowiki/>tes
O filme ganhou o [[Oscar de Melhor Filme Internacional]] em 1960, representando a França.<ref>{{citar web|título=A França no Oscar: veja a lista dos filmes franceses premiados|url=http://blogs.oglobo.globo.com/paris/post/a-franca-no-oscar-veja-lista-dos-filmes-franceses-premiados-561194.html|acessodata=2 de Junho de 2016}}</ref> Trata-se da primeira produção de [[língua portuguesa]] a conquistar a estatueta do [[Oscar]].<ref>{{citar web|título=Quando os portugueses chegaram aos Óscares|url=http://mag.sapo.pt/cinema/atualidade-cinema/artigos/quando-os-portugueses-chegaram-aos-oscares?artigo-completo=sim|acessodata=2 de Junho de 2016}}</ref> É também, juntamente com ''[[Mustang (filme)|Mustang]]'', ''[[Emilia Pérez|Emilia Perez]]'' e ''[[Un Simple Accident|It Was Just an Accident]]'', um dos filmes não francófonos a representar a França no [[Oscar]]. test
test test test test test test test test test test test test test test test test test test
== Enredo ==
O enredo é inspirado na [[mitologia grega]], na história de [[Orfeu]] e [[Eurídice]]. A adaptação ambientou a obra no Brasil, em uma [[favela]] do [[Rio de Janeiro (cidade)|Rio de Janeiro]], na época do [[Carnaval]]. Eurídice vem fugida do [[Sertão brasileiro|sertão nordestino]] para morar na favela com sua prima Serafina. Ela tem medo de um homem que está perseguindo-a e quer matá-la; ela não sabe o motivo, mas pensa que esse homem talvez tenha gostado dela e, como ela não lhe deu confiança, ele agora quer se vingar. Ela apaixona-se perdidamente por Orfeu, que é noivo da bela e sedutora Mira. O tempo passa, Mira passa a perseguir Eurídice, com ciúmes. Serafina ajuda a prima a namorar Orfeu. Eurídice conhece o carnaval [[Carioca (gentílico)|carioca]] ao lado de Orfeu, mas sempre se apavora e corre quando vê que o tal homem está perto.
Um dia, ela revela tudo a Orfeu. Ele a protege e diz que vai ficar ao seu lado. O namoro deles é puro e inocente, sem malícia. Passa o tempo. Um dia, se divertindo no último dia de carnaval, Eurídice teme que o homem apareça, e acha melhor voltar para a favela, que fica perto. Ela entra num beco escuro, para subir a favela, mas ela não conhece bem o local e fica assustada. O homem a encontra e a persegue. Ela sai correndo desesperada e entra num galpão velho e escuro. Ela tenta se esconder do homem, mas este a acha. Desesperada, ela pula de um tablado e se segura em um fio de alta tensão. Orfeu chega e liga a tensão, Eurídice cai e morre eletrocutada. Orfeu briga com homem e fica inconsciente, quando acorda se dá conta dos fatos. Ele fica desolado.
A ambulância chega e leva o corpo ao [[Instituto Médico Legal]]. Ele não pode ir junto. [[Quarta-feira de cinzas]] e Orfeu só sabe chorar. Ele vai atrás do corpo, faz uma sessão [[Espiritismo|espírita]] na qual Eurídice baixa no corpo de uma senhora, mas, enfim, Orfeu acha seu corpo. Ele sequestra-o e leva à favela. Mira vê, e enfurecida, joga uma pedra na cabeça de Orfeu. Com a pancada ele cai de uma ribanceira com o corpo morto de Eurídice nos braços e morre também..
== Elenco principal ==
[[Ficheiro:Marpessa Dawn, 1959.tif|miniaturadaimagem|[[Breno Mello]] e [[Marpessa Dawn]] atuando em Orfeu Negro]]
* [[Breno Mello]] .... Orfeu
* [[Marpessa Dawn]] .... Eurídice
* [[Lourdes de Oliveira]] .... Mira
* [[Léa Garcia]] .... Serafina
* [[Adhemar Ferreira da Silva]] .... Morte
* [[Alexandro Constantino]] .... Hermes
* [[Waldemar de Souza|Waldir de Souza]] (Waldir 59) .... Chico Bôto
* [[Jorge dos Santos]] .... Benedito
* [[Aurino Cassiano]] .... Zeca
* [[Tião Macalé]] .... Homem vendendo o Gramofone.
* [[Cartola_(compositor)|Cartola]] (participação especial)
== Principais prêmios e indicações ==
[[Festival de Cannes]] 1959 (França)
* Recebeu a [[Palma de Ouro]].
''[[Óscar|Oscar]]'' 1960 (EUA)
* Vencedor na categoria de melhor filme em língua estrangeira (português/diretor).
[[Prêmios Globo de Ouro|Globo de Ouro]] 1960 (EUA)
* Venceu na categoria de melhor filme estrangeiro (França).
''[[BAFTA|British Academy of Film and Television Arts]]'' 1961 (Reino Unido)
* Indicado na categoria de melhor filme em língua estrangeira (Brasil, França e Itália/produção).
== Influência ==
Orfeu Negro foi citado por [[Jean-Michel Basquiat]] como uma de suas primeiras influências musicais, enquanto [[Barack Obama]] observa em seu livro de memórias [[Dreams from My Father]] (1995) que era o filme favorito de sua mãe.<ref name=":0">{{Citar web|ultimo=|url=https://www.correiobraziliense.com.br/app/noticia/diversao-e-arte/2010/06/05/interna_diversao_arte,196187/obama-e-quase-brasileiro.shtml|titulo=Obama é 'quase' brasileiro|data=18-2-2023|acessodata=2023-02-18|website=Acervo|lingua=pt-BR}}</ref>
Obama, no entanto, não compartilhar preferências de sua mãe após a primeira a ver o filme durante seus primeiros anos na [[Universidade Columbia|Universidade de Columbia]]: "de repente eu percebi que a representação dos negros infantis que eu estava vendo agora na tela, a imagem inversa de selvagens escuros de Conrad, era o que minha mãe tinha levado com ela para o [[Havaí]] todos aqueles anos antes, um reflexo das fantasias simples que haviam sido proibidas de uma menina branca, de classe média do [[Kansas]], a promessa de uma outra vida: quente, sensual, exótica, diferente.<ref name=":0" />
== Remakes e adaptações ==
Em [[1999]], um novo filme, [[Orfeu (filme)|Orfeu]], foi feita por [[Cacá Diegues]], com uma trilha sonora que caracteriza o cantor e compositor brasileiro [[Caetano Veloso]]. O diretor disse que não era um remake de Orfeu Negro, mas um filme baseado na peça original de Vinicius de Moraes, de 1956.<ref>{{Citar web|url=https://www1.folha.uol.com.br/fsp/ilustrad/fq23049921.htm|titulo=Folha de S.Paulo - Cinema - "Orfeu": Filme confirma mito, diz Caetano Veloso - 23/04/1999|acessodata=2023-02-18|website=www1.folha.uol.com.br}}</ref>
Em julho de 2014, uma adaptação musical de Broadway Orfeu Negro foi anunciada, a ser escrita por [[Lynn Nottage]] e dirigido por George C. Wolfe.<ref>{{Citar web|ultimo=Archive|primeiro=View Author|ultimo2=Twitter|primeiro2=Follow on|url=https://nypost.com/2020/02/13/antonio-carlos-jobims-music-finally-coming-to-broadway/|titulo=Antônio Carlos Jobim's music finally coming to Broadway|data=2020-02-13|acessodata=2023-02-18|lingua=en-US|ultimo3=feed|primeiro3=Get author RSS}}</ref>
== Na cultura popular ==
Cenas do filme foram utilizados no lyric vídeo da música Afterlife da banda [[Arcade Fire]], de 2013.<ref>{{Citar web|ultimo=G1|primeiro=Do|ultimo2=Paulo|primeiro2=em São|url=http://g1.globo.com/musica/noticia/2013/10/arcade-fire-lanca-clipe-com-imagens-do-filme-orfeu-do-carnaval.html|titulo=Arcade Fire lança clipe com imagens do filme 'Orfeu do Carnaval'|data=2013-10-22|acessodata=2023-02-18|website=Música|lingua=pt-br}}</ref>
== Ver também ==
* [[Lista de indicações brasileiras ao Oscar]]
{{Referências}}
== Bibliografia ==
* {{Citar periódico|ultimo=Campos-Muñoz|primeiro=Germán|data=2012|titulo=Contrapuntos órficos: Mitografía brasileña y el mito de Orfeo|url=https://muse.jhu.edu/article/502895|jornal=Latin American Research Review|lingua=es|volume=47|numero=4|paginas=31–48|doi=10.1353/lar.2012.0048|issn=1542-4278}}
{{Oscar de melhor filme estrangeiro}}
{{Palma de Ouro}}
{{Portal3|Cinema|Rio de Janeiro|Brasil|França|Itália}}
{{Controle de autoridade}}
[[Categoria:Filmes do Brasil de 1959]]
[[Categoria:Filmes da França de 1959]]
[[Categoria:Filmes da Itália de 1959]]
[[Categoria:Filmes de drama da Itália]]
[[Categoria:Filmes premiados com o Oscar de melhor filme internacional]]
[[Categoria:Filmes premiados com a Palma de Ouro]]
[[Categoria:Filmes baseados em peças de teatro]]
[[Categoria:Filmes premiados com o Globo de Ouro de melhor filme em língua estrangeira]]
[[Categoria:Filmes de drama do Brasil]]
[[Categoria:Filmes de drama da França]]
[[Categoria:Filmes de fantasia romântica]]
[[Categoria:Filmes em língua portuguesa]]
[[Categoria:Filmes baseados na mitologia greco-romana]]
[[Categoria:Filmes ambientados na cidade do Rio de Janeiro]]
[[Categoria:Filmes gravados na cidade do Rio de Janeiro]]
[[Categoria:Filmes sobre afro-brasileiros]]
[[Category:Wiki_Club_SHUATS]]
== Testing ==
cross-origin edit
testing hCaptcha bot detection
<references />
T423840-test
Second
ozxcodu9tn8h5c90lpiztv57bp3rroy
739262
739261
2026-04-24T13:59:13Z
~2026-25195-23
73696
739262
wikitext
text/x-wiki
{{Ver desambig|este=o filme de Marcel Camus|a peça teatral de Vinícius de Moraes|Orfeu da Conceição}}
{{Info/Filme.
|título = Orfeu Negro
|título-pt = Orfeu Negro
|título-br = Orfeu do Carnaval
|imagem = [[Imagem:Orfeu Negro, 1959.jpg|Orfeu Negro, 1959|230px]]
|ano = 1959test
|duração = 100
|idioma = [[Língua portuguesa|Português]]
|país = [[Brasil]] • [[França]] • [[Itália]]
|direção = [[Marcel Camus]]
|roteiro = Marcel Camus<br />[[Jacques Viot]]
|criação original = {{Baseado em|[[Orfeu da Conceição]]|[[Vinicius de Moraes]]}}
|produção = Sasha Gordine.
|co-produtor = .
|produção executivo =
|música = [[Tom Jobim]]<br />[[Luiz Bonfá]]
|edição = Andrée Feix
|diretor de arte =
|diretor de fotografia = [[Jean Bourgoin]]
|figurino =
|precedido_por =
|seguido_por =d
|estúdio = Dispat Films<br />Gemma Cinematografica<br />Tupan Filmes
|elenco = [[Breno Mello]]<br />[[Marpessa Dawn]]<br />[[Lourdes de Oliveira]]<br />[[Léa Garcia]]
|código-IMDB = 0053146
|tipo = LF
|cor-pb = cor.
}}
'''''Orfeu Negro'''''<ref>{{Citation|title=Orfeu do Carnaval|url=https://www.adorocinema.com/filmes/filme-261/|accessdate=2023-02-18|language=pt-BR|last=AdoroCinema}}</ref> ou '''''Orfeu do Carnaval'''''<ref>{{Citar web|url=https://web.archive.org/web/20130522152505/http://noticias.r7.com/rio-de-janeiro/noticias/a-espera-de-obama-chapeu-mangueira-e-babilonia-preparam-documentario-e-cartas-ao-presidente-20110316.html|titulo=À espera de Obama, Chapéu Mangueira e Babilônia preparam documentário e cartas ao presidente - Rio de Janeiro - R7|data=2013-05-22|acessodata=2023-02-18|website=web.archive.org}}</ref> (na [[França]], '''''Orphée Noir'''''; na [[Itália]], '''''Orfeo Negro''''') é um [[filme]] ítalo-franco-[[brasil]]eiro de [[1959 no cinema|1959]], dirigido por [[Marcel Camus]] e com [[roteiro]] adaptado por Camus e [[Jacques Viot]] a, partir da [[peça teatral]] ''[[Orfeu da Conceição]]'', de [[Vinícius de Moraes]]
A trilha sonora é de [[Tom Jobim]] e [[Luís Bonfá]]. Vinícius e [[Antônio Maria de Araújo Morais|Antônio Maria]] também tiveram músicas incluídas, mas, assim como [[Agostinho dos Santos]], que interpretou a música-tema de Orfeu, "[[Manhã de Carnaval]]", não receberam os créditos. O filme teve outra versão em 1999, sob o nome ''[[Orfeu (filme)|Orfeu]]'', dirigida por [[Cacá Diegues]]<nowiki/>tes
O filme ganhou o [[Oscar de Melhor Filme Internacional]] em 1960, representando a França.<ref>{{citar web|título=A França no Oscar: veja a lista dos filmes franceses premiados|url=http://blogs.oglobo.globo.com/paris/post/a-franca-no-oscar-veja-lista-dos-filmes-franceses-premiados-561194.html|acessodata=2 de Junho de 2016}}</ref> Trata-se da primeira produção de [[língua portuguesa]] a conquistar a estatueta do [[Oscar]].<ref>{{citar web|título=Quando os portugueses chegaram aos Óscares|url=http://mag.sapo.pt/cinema/atualidade-cinema/artigos/quando-os-portugueses-chegaram-aos-oscares?artigo-completo=sim|acessodata=2 de Junho de 2016}}</ref> É também, juntamente com ''[[Mustang (filme)|Mustang]]'', ''[[Emilia Pérez|Emilia Perez]]'' e ''[[Un Simple Accident|It Was Just an Accident]]'', um dos filmes não francófonos a representar a França no [[Oscar]]. test
test test test test test test test test test test test test test test test test test test
== Enredo ==
O enredo é inspirado na [[mitologia grega]], na história de [[Orfeu]] e [[Eurídice]]. A adaptação ambientou a obra no Brasil, em uma [[favela]] do [[Rio de Janeiro (cidade)|Rio de Janeiro]], na época do [[Carnaval]]. Eurídice vem fugida do [[Sertão brasileiro|sertão nordestino]] para morar na favela com sua prima Serafina. Ela tem medo de um homem que está perseguindo-a e quer matá-la; ela não sabe o motivo, mas pensa que esse homem talvez tenha gostado dela e, como ela não lhe deu confiança, ele agora quer se vingar. Ela apaixona-se perdidamente por Orfeu, que é noivo da bela e sedutora Mira. O tempo passa, Mira passa a perseguir Eurídice, com ciúmes. Serafina ajuda a prima a namorar Orfeu. Eurídice conhece o carnaval [[Carioca (gentílico)|carioca]] ao lado de Orfeu, mas sempre se apavora e corre quando vê que o tal homem está perto.
Um dia, ela revela tudo a Orfeu. Ele a protege e diz que vai ficar ao seu lado. O namoro deles é puro e inocente, sem malícia. Passa o tempo. Um dia, se divertindo no último dia de carnaval, Eurídice teme que o homem apareça, e acha melhor voltar para a favela, que fica perto. Ela entra num beco escuro, para subir a favela, mas ela não conhece bem o local e fica assustada. O homem a encontra e a persegue. Ela sai correndo desesperada e entra num galpão velho e escuro. Ela tenta se esconder do homem, mas este a acha. Desesperada, ela pula de um tablado e se segura em um fio de alta tensão. Orfeu chega e liga a tensão, Eurídice cai e morre eletrocutada. Orfeu briga com homem e fica inconsciente, quando acorda se dá conta dos fatos. Ele fica desolado.
A ambulância chega e leva o corpo ao [[Instituto Médico Legal]]. Ele não pode ir junto. [[Quarta-feira de cinzas]] e Orfeu só sabe chorar. Ele vai atrás do corpo, faz uma sessão [[Espiritismo|espírita]] na qual Eurídice baixa no corpo de uma senhora, mas, enfim, Orfeu acha seu corpo. Ele sequestra-o e leva à favela. Mira vê, e enfurecida, joga uma pedra na cabeça de Orfeu. Com a pancada ele cai de uma ribanceira com o corpo morto de Eurídice nos braços e morre também..
== Elenco principal ==
[[Ficheiro:Marpessa Dawn, 1959.tif|miniaturadaimagem|[[Breno Mello]] e [[Marpessa Dawn]] atuando em Orfeu Negro]]
* [[Breno Mello]] .... Orfeu
* [[Marpessa Dawn]] .... Eurídice
* [[Lourdes de Oliveira]] .... Mira
* [[Léa Garcia]] .... Serafina
* [[Adhemar Ferreira da Silva]] .... Morte
* [[Alexandro Constantino]] .... Hermes
* [[Waldemar de Souza|Waldir de Souza]] (Waldir 59) .... Chico Bôto
* [[Jorge dos Santos]] .... Benedito
* [[Aurino Cassiano]] .... Zeca
* [[Tião Macalé]] .... Homem vendendo o Gramofone.
* [[Cartola_(compositor)|Cartola]] (participação especial)
== Principais prêmios e indicações ==
[[Festival de Cannes]] 1959 (França)
* Recebeu a [[Palma de Ouro]].
''[[Óscar|Oscar]]'' 1960 (EUA)
* Vencedor na categoria de melhor filme em língua estrangeira (português/diretor).
[[Prêmios Globo de Ouro|Globo de Ouro]] 1960 (EUA)
* Venceu na categoria de melhor filme estrangeiro (França).
''[[BAFTA|British Academy of Film and Television Arts]]'' 1961 (Reino Unido)
* Indicado na categoria de melhor filme em língua estrangeira (Brasil, França e Itália/produção).
== Influência ==
Orfeu Negro foi citado por [[Jean-Michel Basquiat]] como uma de suas primeiras influências musicais, enquanto [[Barack Obama]] observa em seu livro de memórias [[Dreams from My Father]] (1995) que era o filme favorito de sua mãe.<ref name=":0">{{Citar web|ultimo=|url=https://www.correiobraziliense.com.br/app/noticia/diversao-e-arte/2010/06/05/interna_diversao_arte,196187/obama-e-quase-brasileiro.shtml|titulo=Obama é 'quase' brasileiro|data=18-2-2023|acessodata=2023-02-18|website=Acervo|lingua=pt-BR}}</ref>
Obama, no entanto, não compartilhar preferências de sua mãe após a primeira a ver o filme durante seus primeiros anos na [[Universidade Columbia|Universidade de Columbia]]: "de repente eu percebi que a representação dos negros infantis que eu estava vendo agora na tela, a imagem inversa de selvagens escuros de Conrad, era o que minha mãe tinha levado com ela para o [[Havaí]] todos aqueles anos antes, um reflexo das fantasias simples que haviam sido proibidas de uma menina branca, de classe média do [[Kansas]], a promessa de uma outra vida: quente, sensual, exótica, diferente.<ref name=":0" />
== Remakes e adaptações ==
Em [[1999]], um novo filme, [[Orfeu (filme)|Orfeu]], foi feita por [[Cacá Diegues]], com uma trilha sonora que caracteriza o cantor e compositor brasileiro [[Caetano Veloso]]. O diretor disse que não era um remake de Orfeu Negro, mas um filme baseado na peça original de Vinicius de Moraes, de 1956.<ref>{{Citar web|url=https://www1.folha.uol.com.br/fsp/ilustrad/fq23049921.htm|titulo=Folha de S.Paulo - Cinema - "Orfeu": Filme confirma mito, diz Caetano Veloso - 23/04/1999|acessodata=2023-02-18|website=www1.folha.uol.com.br}}</ref>
Em julho de 2014, uma adaptação musical de Broadway Orfeu Negro foi anunciada, a ser escrita por [[Lynn Nottage]] e dirigido por George C. Wolfe.<ref>{{Citar web|ultimo=Archive|primeiro=View Author|ultimo2=Twitter|primeiro2=Follow on|url=https://nypost.com/2020/02/13/antonio-carlos-jobims-music-finally-coming-to-broadway/|titulo=Antônio Carlos Jobim's music finally coming to Broadway|data=2020-02-13|acessodata=2023-02-18|lingua=en-US|ultimo3=feed|primeiro3=Get author RSS}}</ref>
== Na cultura popular ==
Cenas do filme foram utilizados no lyric vídeo da música Afterlife da banda [[Arcade Fire]], de 2013.<ref>{{Citar web|ultimo=G1|primeiro=Do|ultimo2=Paulo|primeiro2=em São|url=http://g1.globo.com/musica/noticia/2013/10/arcade-fire-lanca-clipe-com-imagens-do-filme-orfeu-do-carnaval.html|titulo=Arcade Fire lança clipe com imagens do filme 'Orfeu do Carnaval'|data=2013-10-22|acessodata=2023-02-18|website=Música|lingua=pt-br}}</ref>
== Ver também ==
* [[Lista de indicações brasileiras ao Oscar]]
{{Referências}}
== Bibliografia ==
* {{Citar periódico|ultimo=Campos-Muñoz|primeiro=Germán|data=2012|titulo=Contrapuntos órficos: Mitografía brasileña y el mito de Orfeo|url=https://muse.jhu.edu/article/502895|jornal=Latin American Research Review|lingua=es|volume=47|numero=4|paginas=31–48|doi=10.1353/lar.2012.0048|issn=1542-4278}}
{{Oscar de melhor filme estrangeiro}}
{{Palma de Ouro}}
{{Portal3|Cinema|Rio de Janeiro|Brasil|França|Itália}}
{{Controle de autoridade}}
[[Categoria:Filmes do Brasil de 1959]]
[[Categoria:Filmes da França de 1959]]
[[Categoria:Filmes da Itália de 1959]]
[[Categoria:Filmes de drama da Itália]]
[[Categoria:Filmes premiados com o Oscar de melhor filme internacional]]
[[Categoria:Filmes premiados com a Palma de Ouro]]
[[Categoria:Filmes baseados em peças de teatro]]
[[Categoria:Filmes premiados com o Globo de Ouro de melhor filme em língua estrangeira]]
[[Categoria:Filmes de drama do Brasil]]
[[Categoria:Filmes de drama da França]]
[[Categoria:Filmes de fantasia romântica]]
[[Categoria:Filmes em língua portuguesa]]
[[Categoria:Filmes baseados na mitologia greco-romana]]
[[Categoria:Filmes ambientados na cidade do Rio de Janeiro]]
[[Categoria:Filmes gravados na cidade do Rio de Janeiro]]
[[Categoria:Filmes sobre afro-brasileiros]]
[[Category:Wiki_Club_SHUATS]]
== Testing ==
cross-origin edit
testing hCaptcha bot detection
<references />
T423840-test
Second
Third
9xwbdqammjt3di51ngognzzz61q6758
User talk:NovemBot
3
160473
739263
738733
2026-04-24T14:51:53Z
Novem Linguae
49714
Replaced content with "<div style=clear: both></div>{{{icon|[[File:Information.svg|25px|alt=|link=]]}}} Welcome to Wikipedia. Although everyone is welcome to contribute to Wikipedia, at least one of [[Special:Contributions/{{<includeonly>safesubst:</includeonly>BASEPAGENAME}}|your recent edits]]{{<includeonly>safesubst:</includeonly>#if:{{{1|}}}|, such as the one you made to [[:{{{1}}}]] with <span class="plainlinks">[{{{2}}} this edit]</span>,}} did not appear to be constructive and has b..."
739263
wikitext
text/x-wiki
<div style=clear: both></div>{{{icon|[[File:Information.svg|25px|alt=|link=]]}}} Welcome to Wikipedia. Although everyone is welcome to contribute to Wikipedia, at least one of [[Special:Contributions/{{<includeonly>safesubst:</includeonly>BASEPAGENAME}}|your recent edits]]{{<includeonly>safesubst:</includeonly>#if:{{{1|}}}|, such as the one you made to [[:{{{1}}}]] with <span class="plainlinks">[{{{2}}} this edit]</span>,}} did not appear to be constructive and has been [[Help:Reverting|reverted]] or removed. Please use [[Wikipedia:Sandbox|the sandbox]] for any test edits you would like to make, and read the [[Wikipedia:Welcoming committee/Welcome to Wikipedia|welcome page]] to learn more about contributing constructively to this encyclopedia. Thank you. ~~<noinclude></noinclude>~~<!-- Template:Huggle/warn-1 --><!-- Template:uw-vandalism1 --><noinclude>
{{Huggle/TemplateNotice|series = uw-vandalism|max = 4im|s1=vw-n|s2 = uw-v1|s3 = uw-vand1|s4 = uw-vandal1}}
[[pt:Predefinição:Huggle/warn-1]]
</noinclude>
suma5o8omy6j6yqbgeanxkfzrmrizon
739264
739263
2026-04-24T14:55:40Z
Novem Linguae
49714
Replaced content with "{{subst:Huggle/warn-1}}"
739264
wikitext
text/x-wiki
[[File:Information.svg|25px|alt=Information icon]] Hello, I'm [[User:Novem Linguae|Novem Linguae]]. I wanted to let you know that I reverted one of [[Special:Contributions/NovemBot|your recent contributions]] because it did not appear constructive. If you would like to experiment, please use the [[Wikipedia:Sandbox|sandbox]]. If you think I made a mistake, or if you have any questions, you can leave me a message on [[User_talk:Novem Linguae|my talk page]]. Thanks. <!-- Template:Huggle/warn-1 --><!-- Template:uw-vandalism1 -->–[[User:Novem Linguae|<span style="color:blue">'''Novem Linguae'''</span>]] <small>([[User talk:Novem Linguae|talk]])</small> 14:55, 24 April 2026 (UTC)
t9ewb5pzzvxls93je73x8z2wmcri08e
739265
739264
2026-04-24T14:56:19Z
Novem Linguae
49714
739265
wikitext
text/x-wiki
[[File:Information.svg|25px|alt=Information icon]] Hello, I'm [[User:Novem Linguae|Novem Linguae]]. I wanted to let you know that I reverted one of [[Special:Contributions/NovemBot|your recent contributions]] because it did not appear constructive. If you would like to experiment, please use the [[Wikipedia:Sandbox|sandbox]]. If you think I made a mistake, or if you have any questions, you can leave me a message on [[User_talk:Novem Linguae|my talk page]]. Thanks. <!-- Template:Huggle/warn-1 --><!-- This is a variant of Template:uw-vandalism1 -->–[[User:Novem Linguae|<span style="color:blue">'''Novem Linguae'''</span>]] <small>([[User talk:Novem Linguae|talk]])</small> 14:55, 24 April 2026 (UTC)
auk8wx7u8i7u4fhqcbbc877x5scuuor
739266
739265
2026-04-24T14:57:19Z
Novem Linguae
49714
739266
wikitext
text/x-wiki
[[File:Information.svg|25px|alt=Information icon]] Hello, I'm [[User:Novem Linguae|Novem Linguae]]. I wanted to let you know that I reverted one of [[Special:Contributions/NovemBot|your recent contributions]] because it did not appear constructive. If you would like to experiment, please use the [[Wikipedia:Sandbox|sandbox]]. If you think I made a mistake, or if you have any questions, you can leave me a message on [[User_talk:Novem Linguae|my talk page]]. Thanks. <!-- Template:Huggle/warn-1 --><!-- This is a variant of: --><!-- Template:uw-vandalism1 -->–[[User:Novem Linguae|<span style="color:blue">'''Novem Linguae'''</span>]] <small>([[User talk:Novem Linguae|talk]])</small> 14:55, 24 April 2026 (UTC)
1svm5kmgnwbtop51cw3uumo5dhwrzki
User:Wooze/common.js
2
174857
739232
739231
2026-04-24T12:00:53Z
Wooze
54732
739232
javascript
text/javascript
mw.loader.using(['mediawiki.api']).then(function () {
var api = new mw.Api();
function createButton() {
var btn = $('<button>')
.text('⚡Mass Tool')
.css({
position: 'fixed',
bottom: '20px',
right: '20px',
zIndex: 9999,
padding: '10px',
background: '#222',
color: '#fff',
borderRadius: '8px',
border: 'none'
})
.on('click', openUI);
$('body').append(btn);
}
function openUI() {
var user = prompt("Kullanıcı adı:");
if (!user) return;
// Tüm katkılar
var contribReq = api.get({
action: 'query',
list: 'usercontribs',
ucuser: user,
uclimit: 20,
ucprop: 'ids|title'
});
// Sadece oluşturulan sayfalar
var newPagesReq = api.get({
action: 'query',
list: 'usercontribs',
ucuser: user,
uclimit: 20,
ucprop: 'ids|title',
ucshow: 'new'
});
$.when(contribReq, newPagesReq).done(function (cData, nData) {
var contribs = cData[0].query.usercontribs;
var newPages = nData[0].query.usercontribs;
var box = $('<div>').css({
position: 'fixed',
top: '5%',
left: '5%',
width: '90%',
height: '85%',
background: '#fff',
overflow: 'auto',
padding: '10px',
zIndex: 9999
});
var log = $('<div>').css({ fontSize: '12px' });
// UNDO
function undoOne(c, callback) {
api.post({
action: 'edit',
title: c.title,
undo: c.revid,
token: mw.user.tokens.get('csrfToken'),
summary: 'Toplu geri alma (Mass Tool)'
}).always(function () {
setTimeout(callback, 1200);
});
}
function runUndoQueue(list) {
var i = 0;
function next() {
if (i >= list.length) {
log.append("<b>🎯 Undo bitti</b>");
return;
}
var c = list[i];
log.append("<div>⏳ Undo: " + c.title + "</div>");
undoOne(c, function () {
log.append("<div>✅ " + c.title + "</div>");
i++;
next();
});
}
next();
}
// DELETE
function deletePage(title, callback) {
api.post({
action: 'delete',
title: title,
token: mw.user.tokens.get('csrfToken'),
reason: 'Toplu silme (Mass Tool)'
}).always(function () {
setTimeout(callback, 1500);
});
}
function runDeleteQueue(list) {
var i = 0;
function next() {
if (i >= list.length) {
log.append("<b>🗑️ Silme bitti</b>");
return;
}
var c = list[i];
log.append("<div>⏳ Siliniyor: " + c.title + "</div>");
deletePage(c.title, function () {
log.append("<div>🗑️ " + c.title + "</div>");
i++;
next();
});
}
next();
}
// Butonlar
var undoBtn = $('<button>')
.text("Hepsini Geri Al")
.css({ background: '#d33', color: '#fff', margin: '5px' })
.on('click', function () {
runUndoQueue(contribs);
});
var deleteBtn = $('<button>')
.text("Oluşturulanları Sil")
.css({ background: '#000', color: '#fff', margin: '5px' })
.on('click', function () {
runDeleteQueue(newPages);
});
var closeBtn = $('<button>')
.text("Kapat")
.on('click', function () {
box.remove();
});
box.append(
"<h3>⚡ Mass Tool Panel</h3>",
undoBtn,
deleteBtn,
closeBtn,
"<hr>",
"<b>Undo listesi:</b> " + contribs.length + "<br>",
"<b>Silinecek sayfalar:</b> " + newPages.length + "<br><br>",
log
);
$('body').append(box);
});
}
$(createButton);
});
3e3lpoah42ky5egilmn2z9cgaevak3g
739252
739232
2026-04-24T13:03:00Z
Wooze
54732
Testing tool
739252
javascript
text/javascript
mw.loader.using(['mediawiki.api']).then(function () {
var api = new mw.Api();
function createButton() {
var btn = $('<button>')
.text('⚡Mass Control Panel')
.css({
position: 'fixed',
bottom: '20px',
right: '20px',
zIndex: 9999,
padding: '10px',
background: '#222',
color: '#fff',
borderRadius: '8px',
border: 'none'
})
.on('click', openUI);
$('body').append(btn);
}
function openUI() {
var user = prompt("Kullanıcı adı:");
if (!user) return;
// Değişiklikler
var contribReq = api.get({
action: 'query',
list: 'usercontribs',
ucuser: user,
uclimit: 50,
ucprop: 'ids|title'
});
// Yeni sayfalar
var newPagesReq = api.get({
action: 'query',
list: 'usercontribs',
ucuser: user,
uclimit: 50,
ucprop: 'ids|title',
ucshow: 'new'
});
$.when(contribReq, newPagesReq).done(function (cData, nData) {
var contribs = cData[0].query.usercontribs;
var newPages = nData[0].query.usercontribs;
var box = $('<div>').css({
position: 'fixed',
top: '5%',
left: '5%',
width: '90%',
height: '85%',
background: '#fff',
overflow: 'auto',
padding: '10px',
zIndex: 9999,
fontSize: '13px'
});
var log = $('<div>').css({ marginTop: '10px' });
// ------------------------
// CHECKBOX LİST OLUŞTUR
// ------------------------
function renderList(items, container, type) {
items.forEach(function (c) {
var row = $('<div>').css({
padding: '4px',
borderBottom: '1px solid #eee'
});
var cb = $('<input type="checkbox">')
.data('title', c.title)
.data('revid', c.revid);
row.append(cb, " " + c.title);
container.append(row);
});
}
// ------------------------
// UNDO
// ------------------------
function undoOne(title, revid, callback) {
api.post({
action: 'edit',
title: title,
undo: revid,
token: mw.user.tokens.get('csrfToken'),
summary: 'Seçimli geri alma (Mass Control Panel)'
}).always(function () {
setTimeout(callback, 1000);
});
}
function runUndo(selected) {
var i = 0;
function next() {
if (i >= selected.length) {
log.append("<b>✔️ Geri alma tamamlandı</b><br>");
return;
}
var item = selected[i];
log.append("<div>⏳ Undo: " + item.title + "</div>");
undoOne(item.title, item.revid, function () {
i++;
next();
});
}
next();
}
// ------------------------
// DELETE
// ------------------------
function deletePage(title, callback) {
api.post({
action: 'delete',
title: title,
token: mw.user.tokens.get('csrfToken'),
reason: 'Seçimli silme (Mass Control Panel)'
}).always(function () {
setTimeout(callback, 1200);
});
}
function runDelete(selected) {
var i = 0;
function next() {
if (i >= selected.length) {
log.append("<b>🗑️ Silme tamamlandı</b><br>");
return;
}
var item = selected[i];
log.append("<div>⏳ Siliniyor: " + item.title + "</div>");
deletePage(item.title, function () {
i++;
next();
});
}
next();
}
// ------------------------
// UI
// ------------------------
var createdBox = $('<div>').append("<h3>🟢 Oluşturulan Sayfalar</h3>");
var editedBox = $('<div>').append("<h3>🔵 Değişiklik Yapılan Sayfalar</h3>");
renderList(newPages, createdBox, "delete");
renderList(contribs, editedBox, "undo");
var undoBtn = $('<button>')
.text("Seçilenleri Geri Al")
.css({ background: '#2b6', color: '#fff', margin: '5px' })
.on('click', function () {
var selected = [];
editedBox.find('input:checked').each(function () {
selected.push({
title: $(this).data('title'),
revid: $(this).data('revid')
});
});
runUndo(selected);
});
var deleteBtn = $('<button>')
.text("Seçilenleri Sil")
.css({ background: '#c33', color: '#fff', margin: '5px' })
.on('click', function () {
var selected = [];
createdBox.find('input:checked').each(function () {
selected.push({
title: $(this).data('title')
});
});
runDelete(selected);
});
var closeBtn = $('<button>')
.text("Kapat")
.on('click', function () {
box.remove();
});
box.append(
"<h2>⚡ Mass Control Panel</h2>",
undoBtn,
deleteBtn,
closeBtn,
"<hr>",
createdBox,
"<hr>",
editedBox,
"<hr>",
log
);
$('body').append(box);
});
}
$(createButton);
});
en0tdn70m23apndhlzqmbz4amt3iu38
User:MrJaroslavik/CheckUserLogCount.js
2
174966
739277
739200
2026-04-24T18:28:31Z
MrJaroslavik
44012
Enter test?
739277
javascript
text/javascript
// CheckUserLogCount.js
// Based on countCUStats.js - https://meta.wikimedia.org/wiki/User:Glaisher/countCUStats.js
// Features:
// - Counts total CheckUser actions per user within a selected date range.
// - Breakdown by Total actions, Months, Quarters (Q1–Q4), or Years
// - Adds an interactive control panel directly to the [[Special:CheckUserLog]] page.
// - Shows a sortable table with direct links to user CheckUsers log entries.
// - Automatically loads all data even for very busy periods.
// - Shows the date of the very last CheckUser action for every user in the results.
// - Highlights current CheckUsers with green background and identifies inactive ones.
// - Generates ready-to-copy Wikitext table.
// - With help of Gemini 3
(function() {
'use strict';
// Only run on the CheckUserLog special page
if (mw.config.get('wgCanonicalSpecialPageName') !== 'CheckUserLog') {
return;
}
// Helper function to pause execution (useful for preventing API limits)
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
// Makes API calls with automatic retries for "429 Too Many Requests" errors
async function robustCall(api, params) {
let retries = 0;
const maxRetries = 3;
while (retries < maxRetries) {
try {
return await api.get(params);
} catch (err) {
if (err && err.status === 429) {
retries++;
const waitTime = parseInt(err.xhr && err.xhr.getResponseHeader('Retry-After')) || (30 * retries);
$('#status-msg, #cu-loader').html(
`<span style="color:orange; font-weight:bold;">Server is busy! Pausing for ${waitTime} seconds...</span>`
).show();
await sleep(waitTime * 1000);
} else {
throw err;
}
}
}
throw new Error("Failed to reach API after multiple attempts due to server limits.");
}
// Returns a standardized UTC timestamp for report headers (YYYY-MM-DD HH:MM)
function getReportTimestamp() {
return new Date().toISOString().replace('T', ' ').substring(0, 16) + ' (UTC)';
}
// Load required MediaWiki modules for API and UI
mw.loader.using(['mediawiki.api', 'mediawiki.util', 'jquery.tablesorter']).then(async function() {
const api = new mw.Api();
const now = new Date();
const currYear = now.getUTCFullYear();
const currMonth = now.getUTCMonth() + 1;
// Build the user interface panel
function setupUI() {
// Prevent duplicate panels on the page
$('.cu-stats-wrapper').remove();
// Generate year options (from 2005 to present)
let yearOpts = '';
for (let y = currYear; y >= 2005; y--) {
yearOpts += `<option value="${y}">${y}</option>`;
}
// Generate month selection menus
let monthOptsFrom = '';
let monthOptsTo = '';
for (let m = 1; m <= 12; m++) {
const label = String(m).padStart(2, '0');
// Default starting month to January
monthOptsFrom += `<option value="${m}" ${m === 1 ? 'selected' : ''}>${label}</option>`;
// Default ending month to current month
monthOptsTo += `<option value="${m}" ${m === currMonth ? 'selected' : ''}>${label}</option>`;
}
// Define the HTML structure for the control panel with mode selection
const html = `
<div class="cu-stats-wrapper" style="border: 1px solid #a2a9b1; padding: 15px; margin-bottom: 20px; background: #f8f9fa; border-radius: 2px;">
<h3 style="margin-top:0; border-bottom: 1px solid #a2a9b1; padding-bottom: 5px;">CheckUserLogCount.js</h3>
<div style="margin-bottom: 10px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
<span>From:</span>
<select id="cu-year-from" style="padding: 3px;">${yearOpts}</select>
<select id="cu-month-from" style="padding: 3px;">${monthOptsFrom}</select>
<span>To:</span>
<select id="cu-year-to" style="padding: 3px;">${yearOpts}</select>
<select id="cu-month-to" style="padding: 3px;">${monthOptsTo}</select>
</div>
<div style="margin-bottom: 10px; display: flex; gap: 15px; font-size: 0.9em; align-items: center;">
<span style="font-weight:bold;">View mode:</span>
<label><input type="radio" name="cu-mode" value="total" checked> Total only</label>
<label><input type="radio" name="cu-mode" value="months"> By Months</label>
<label><input type="radio" name="cu-mode" value="quarters"> By Quarters</label>
<label><input type="radio" name="cu-mode" value="years"> By Years</label>
<button id="cu-stats-run" class="mw-ui-button mw-ui-progressive" style="margin-left: auto;">Run</button>
</div>
<div id="cu-stats-results" style="display:none; margin-top: 15px; border-top: 1px solid #c8ccd1; padding-top: 10px;">
<div id="cu-loader" style="font-weight:bold; margin-bottom: 5px;"></div>
<div id="cu-table-container"></div>
<div id="cu-wikitext-container" style="display:none; margin-top: 20px;">
<label style="font-weight:bold; display:block; margin-bottom:5px;">Report Wikitext:</label>
<textarea id="cu-out" readonly style="width:100%; height:150px; font-family:monospace; font-size:11px; padding:5px; border:1px solid #c8ccd1;"></textarea>
</div>
</div>
</div>`;
// Add the interface to the top of the page content
$('#mw-content-text').prepend(html);
// Spuštění pomocí Enter kdekoli v panelu
$('.cu-stats-wrapper').on('keypress', function(e) {
if (e.which === 13) {
e.preventDefault();
$('#cu-stats-run').click();
}
});
// Handle the "Run" button click event
$('#cu-stats-run').click(function() {
const $btn = $(this);
const params = {
yf: parseInt($('#cu-year-from').val()),
mf: parseInt($('#cu-month-from').val()),
yt: parseInt($('#cu-year-to').val()),
mt: parseInt($('#cu-month-to').val()),
mode: $('input[name="cu-mode"]:checked').val()
};
// Disable the button during processing
$btn.prop('disabled', true).text('Running...');
// Run the audit with collected parameters
runAudit(params).finally(() => {
$btn.prop('disabled', false).text('Run');
});
});
}
// Main logic to fetch and process CheckUser data with timeline support
async function runAudit(cfg) {
$('#cu-stats-results').show();
$('#cu-table-container').empty();
$('#cu-wikitext-container').hide(); // Skrytí wikitextu před novým během
$('#cu-loader').text('Waiting for queue lock...').css("color", "orange").show();
// Request browser lock to prevent concurrent runs across tabs
await navigator.locks.request('local_cu_count_lock', async () => {
$('#cu-loader').text('Lock acquired. Connecting to API...').css("color", "#0056b3");
// Prepare dates and range limits
const START = `${cfg.yf}-${String(cfg.mf).padStart(2, '0')}-01T00:00:00Z`;
const lastDay = new Date(Date.UTC(cfg.yt, cfg.mt, 0)).getUTCDate();
const END = `${cfg.yt}-${String(cfg.mt).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}T23:59:59Z`;
const userStats = {};
let totalActions = 0;
let continueToken = '';
try {
// Phase 1: Get current CheckUsers (even those with no activity)
const currentCUs = [];
const auRes = await robustCall(api, { action: 'query', list: 'allusers', augroup: 'checkuser', aulimit: 'max', formatversion: 2
});
(auRes.query.allusers || []).forEach(u => {
userStats[u.name] = { total: 0, timeline: {} };
currentCUs.push(u.name);
});
// Phase 2: Fetch logs and build the activity timeline
let finished = false;
while (!finished) {
const params = {
action: 'query', list: 'checkuserlog',
culfrom: START, culto: END, culdir: 'newer',
cullimit: 'max', formatversion: 2
};
if (continueToken) params.culcontinue = continueToken;
const res = await robustCall(api, params);
const entries = (res.query && res.query.checkuserlog && res.query.checkuserlog.entries) ? res.query.checkuserlog.entries : [];
entries.forEach(e => {
if (e.checkuser) {
const u = e.checkuser;
const monthKey = e.timestamp.substring(0, 7); // Extract YYYY-MM
if (!userStats[u]) {
userStats[u] = { total: 0, timeline: {} };
}
userStats[u].total++;
userStats[u].timeline[monthKey] = (userStats[u].timeline[monthKey] || 0) + 1;
totalActions++;
}
});
if (res.continue && res.continue.culcontinue) {
continueToken = res.continue.culcontinue;
$('#cu-loader').text(`Processing logs... Found ${totalActions} actions.`);
} else {
finished = true;
}
}
// Phase 3: Get the absolute last action date for each user
const lastActions = {};
const usernames = Object.keys(userStats);
for (let j = 0; j < usernames.length; j++) {
const u = usernames[j];
$('#cu-loader').text(`Checking last activity for ${u} (${j + 1}/${usernames.length})...`);
const lastRes = await robustCall(api, { action: 'query', list: 'checkuserlog',
cullimit: 1, culuser: u, formatversion: 2
});
const lastEntry = (lastRes.query && lastRes.query.checkuserlog && lastRes.query.checkuserlog.entries && lastRes.query.checkuserlog.entries[0]) ? lastRes.query.checkuserlog.entries[0] : null;
lastActions[u] = lastEntry ? lastEntry.timestamp.replace('T', ' ').substring(0, 16) : 'Never';
}
$('#cu-loader').text('Rendering table...').css("color", "#0056b3");
renderTable(userStats, totalActions, lastActions, currentCUs, cfg);
$('#cu-loader').text('Audit complete!').css("color", "green");
} catch (err) {
console.error("CheckUser Stats Error:", err);
const errorMsg = err.statusText || err.message || err;
$('#cu-loader').text(`API Error: ${errorMsg}`).css("color", "#d33").show();
}
});
}
// Create the dynamic results table and Wikitext export
function renderTable(userStats, total, lastActions, currentCUs, cfg) {
// Sort users: highest total first, then alphabetical
const sortedUsers = Object.keys(userStats).map(u => ({ name: u, ...userStats[u] }))
.sort((a, b) => b.total - a.total || a.name.localeCompare(b.name));
// 1. Generate time columns based on the selected range and mode
const timeCols = [];
let startIter = new Date(Date.UTC(cfg.yf, cfg.mf - 1, 1));
const endIter = new Date(Date.UTC(cfg.yt, cfg.mt - 1, 1));
while (startIter <= endIter) {
const k = startIter.toISOString().substring(0, 7); // "YYYY-MM"
if (cfg.mode === 'months') {
timeCols.push({ id: k, label: k });
} else if (cfg.mode === 'quarters') {
const q = Math.floor(startIter.getUTCMonth() / 3) + 1;
const qid = `${startIter.getUTCFullYear()}-Q${q}`;
if (!timeCols.find(c => c.id === qid)) {
timeCols.push({ id: qid, label: qid, months: [] });
}
timeCols.find(c => c.id === qid).months.push(k);
} else if (cfg.mode === 'years') {
const yid = `${startIter.getUTCFullYear()}`;
if (!timeCols.find(c => c.id === yid)) {
timeCols.push({ id: yid, label: yid, months: [] });
}
timeCols.find(c => c.id === yid).months.push(k);
}
startIter.setUTCMonth(startIter.getUTCMonth() + 1);
}
// 2. Build HTML table headers and rows
const headerHtml = timeCols.map(c => `<th>${c.label}</th>`).join('');
const rows = sortedUsers.map(stat => {
const isCurrent = currentCUs.includes(stat.name);
const hasActions = stat.total > 0;
let trStyle = '';
if (isCurrent) trStyle = 'style="background-color: #94e1cc;"';
else if (!hasActions) trStyle = 'style="color: #72777d;"';
// Generate cells for the timeline (HTML and Wikitext)
const timeCells = timeCols.map(c => {
let val = 0;
if (cfg.mode === 'months') {
val = stat.timeline[c.id] || 0;
} else {
// Works for both quarters and years
c.months.forEach(m => val += (stat.timeline[m] || 0));
}
return `<td style="font-size:0.9em; color:#54595d;">${val > 0 ? val : '-'}</td>`;
}).join('');
return `
<tr ${trStyle}>
<td style="text-align:left; white-space: nowrap;">
<a href="${mw.util.getUrl('Special:CheckUserLog', { cuInitiator: stat.name })}">
${mw.html.escape(stat.name)}
</a>
<small style="color: #72777d;">Last: ${lastActions[stat.name]}</small>
</td>
<td><strong>${stat.total}</strong></td>
${timeCells}
</tr>`;
}).join('');
// 3. Prepare Wikitext for reports (cleaned structure)
const dbName = mw.config.get('wgDBname');
let wikitext = `== CheckUser Stats for ${dbName} (${cfg.mf}/${cfg.yf} – ${cfg.mt}/${cfg.yt}) ==\n`;
wikitext += `''Report generated on: ${getReportTimestamp()}<br />\nGenerated with [[testwiki:User:MrJaroslavik/CheckUserLogCount.js|CheckUserLogCount.js]]''\n`;
wikitext += `\n'''Total actions in period: ${total}'''\n\n`;
// Header logic: handle total only vs timeline columns
const colHeader = timeCols.length ? ' !! ' + timeCols.map(c => c.label).join(' !! ') : '';
wikitext += `{| class="wikitable sortable" style="text-align:right;"\n! Initiator !! Total${colHeader}\n`;
sortedUsers.forEach(stat => {
let timelineStr = '';
if (timeCols.length) {
timelineStr = ' || ' + timeCols.map(c => {
let val = 0;
if (cfg.mode === 'months') {
val = stat.timeline[c.id] || 0;
} else {
c.months.forEach(m => val += (stat.timeline[m] || 0));
}
return val;
}).join(' || ');
}
wikitext += `|-\n| style="text-align:left; white-space:nowrap;" | [[User:${stat.name}|${stat.name}]] <small>(Last: ${lastActions[stat.name]})</small> || '''${stat.total}'''${timelineStr}\n`;
});
wikitext += `|}\n`;
// 4. Update the UI container
$('#cu-table-container').html(`
<p><strong>Total actions found: ${total}</strong></p>
<div style="overflow-x: auto;">
<table class="wikitable sortable" style="width:100%; text-align:center;">
<thead>
<tr>
<th style="text-align:left">Initiator</th>
<th>Total</th>
${headerHtml}
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
`);
$('#cu-out').val(wikitext);
$('#cu-wikitext-container').show();
// Enable sorting
$('table.sortable').tablesorter();
}
// Initialize the UI on page load
setupUI();
});
})();
20u95c3gqv83bkkeghr9ukdxovkua3b
739287
739277
2026-04-24T19:00:33Z
MrJaroslavik
44012
rv
739287
javascript
text/javascript
// CheckUserLogCount.js
// Based on countCUStats.js - https://meta.wikimedia.org/wiki/User:Glaisher/countCUStats.js
// Features:
// - Counts total CheckUser actions per user within a selected date range.
// - Breakdown by Total actions, Months, Quarters (Q1–Q4), or Years
// - Adds an interactive control panel directly to the [[Special:CheckUserLog]] page.
// - Shows a sortable table with direct links to user CheckUsers log entries.
// - Automatically loads all data even for very busy periods.
// - Shows the date of the very last CheckUser action for every user in the results.
// - Highlights current CheckUsers with green background and identifies inactive ones.
// - Generates ready-to-copy Wikitext table.
// - With help of Gemini 3
(function() {
'use strict';
// Only run on the CheckUserLog special page
if (mw.config.get('wgCanonicalSpecialPageName') !== 'CheckUserLog') {
return;
}
// Helper function to pause execution (useful for preventing API limits)
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
// Makes API calls with automatic retries for "429 Too Many Requests" errors
async function robustCall(api, params) {
let retries = 0;
const maxRetries = 3;
while (retries < maxRetries) {
try {
return await api.get(params);
} catch (err) {
if (err && err.status === 429) {
retries++;
const waitTime = parseInt(err.xhr && err.xhr.getResponseHeader('Retry-After')) || (30 * retries);
$('#status-msg, #cu-loader').html(
`<span style="color:orange; font-weight:bold;">Server is busy! Pausing for ${waitTime} seconds...</span>`
).show();
await sleep(waitTime * 1000);
} else {
throw err;
}
}
}
throw new Error("Failed to reach API after multiple attempts due to server limits.");
}
// Returns a standardized UTC timestamp for report headers (YYYY-MM-DD HH:MM)
function getReportTimestamp() {
return new Date().toISOString().replace('T', ' ').substring(0, 16) + ' (UTC)';
}
// Load required MediaWiki modules for API and UI
mw.loader.using(['mediawiki.api', 'mediawiki.util', 'jquery.tablesorter']).then(async function() {
const api = new mw.Api();
const now = new Date();
const currYear = now.getUTCFullYear();
const currMonth = now.getUTCMonth() + 1;
// Build the user interface panel
function setupUI() {
// Prevent duplicate panels on the page
$('.cu-stats-wrapper').remove();
// Generate year options (from 2005 to present)
let yearOpts = '';
for (let y = currYear; y >= 2005; y--) {
yearOpts += `<option value="${y}">${y}</option>`;
}
// Generate month selection menus
let monthOptsFrom = '';
let monthOptsTo = '';
for (let m = 1; m <= 12; m++) {
const label = String(m).padStart(2, '0');
// Default starting month to January
monthOptsFrom += `<option value="${m}" ${m === 1 ? 'selected' : ''}>${label}</option>`;
// Default ending month to current month
monthOptsTo += `<option value="${m}" ${m === currMonth ? 'selected' : ''}>${label}</option>`;
}
// Define the HTML structure for the control panel with mode selection
const html = `
<div class="cu-stats-wrapper" style="border: 1px solid #a2a9b1; padding: 15px; margin-bottom: 20px; background: #f8f9fa; border-radius: 2px;">
<h3 style="margin-top:0; border-bottom: 1px solid #a2a9b1; padding-bottom: 5px;">CheckUserLogCount.js</h3>
<div style="margin-bottom: 10px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
<span>From:</span>
<select id="cu-year-from" style="padding: 3px;">${yearOpts}</select>
<select id="cu-month-from" style="padding: 3px;">${monthOptsFrom}</select>
<span>To:</span>
<select id="cu-year-to" style="padding: 3px;">${yearOpts}</select>
<select id="cu-month-to" style="padding: 3px;">${monthOptsTo}</select>
</div>
<div style="margin-bottom: 10px; display: flex; gap: 15px; font-size: 0.9em; align-items: center;">
<span style="font-weight:bold;">View mode:</span>
<label><input type="radio" name="cu-mode" value="total" checked> Total only</label>
<label><input type="radio" name="cu-mode" value="months"> By Months</label>
<label><input type="radio" name="cu-mode" value="quarters"> By Quarters</label>
<label><input type="radio" name="cu-mode" value="years"> By Years</label>
<button id="cu-stats-run" class="mw-ui-button mw-ui-progressive" style="margin-left: auto;">Run</button>
</div>
<div id="cu-stats-results" style="display:none; margin-top: 15px; border-top: 1px solid #c8ccd1; padding-top: 10px;">
<div id="cu-loader" style="font-weight:bold; margin-bottom: 5px;"></div>
<div id="cu-table-container"></div>
<div id="cu-wikitext-container" style="display:none; margin-top: 20px;">
<label style="font-weight:bold; display:block; margin-bottom:5px;">Report Wikitext:</label>
<textarea id="cu-out" readonly style="width:100%; height:150px; font-family:monospace; font-size:11px; padding:5px; border:1px solid #c8ccd1;"></textarea>
</div>
</div>
</div>`;
// Add the interface to the top of the page content
$('#mw-content-text').prepend(html);
// Handle the "Run" button click event
$('#cu-stats-run').click(function() {
const $btn = $(this);
const params = {
yf: parseInt($('#cu-year-from').val()),
mf: parseInt($('#cu-month-from').val()),
yt: parseInt($('#cu-year-to').val()),
mt: parseInt($('#cu-month-to').val()),
mode: $('input[name="cu-mode"]:checked').val()
};
// Disable the button during processing
$btn.prop('disabled', true).text('Running...');
// Run the audit with collected parameters
runAudit(params).finally(() => {
$btn.prop('disabled', false).text('Run');
});
});
}
// Main logic to fetch and process CheckUser data with timeline support
async function runAudit(cfg) {
$('#cu-stats-results').show();
$('#cu-table-container').empty();
$('#cu-wikitext-container').hide(); // Skrytí wikitextu před novým během
$('#cu-loader').text('Waiting for queue lock...').css("color", "orange").show();
// Request browser lock to prevent concurrent runs across tabs
await navigator.locks.request('local_cu_count_lock', async () => {
$('#cu-loader').text('Lock acquired. Connecting to API...').css("color", "#0056b3");
// Prepare dates and range limits
const START = `${cfg.yf}-${String(cfg.mf).padStart(2, '0')}-01T00:00:00Z`;
const lastDay = new Date(Date.UTC(cfg.yt, cfg.mt, 0)).getUTCDate();
const END = `${cfg.yt}-${String(cfg.mt).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}T23:59:59Z`;
const userStats = {};
let totalActions = 0;
let continueToken = '';
try {
// Phase 1: Get current CheckUsers (even those with no activity)
const currentCUs = [];
const auRes = await robustCall(api, { action: 'query', list: 'allusers', augroup: 'checkuser', aulimit: 'max', formatversion: 2
});
(auRes.query.allusers || []).forEach(u => {
userStats[u.name] = { total: 0, timeline: {} };
currentCUs.push(u.name);
});
// Phase 2: Fetch logs and build the activity timeline
let finished = false;
while (!finished) {
const params = {
action: 'query', list: 'checkuserlog',
culfrom: START, culto: END, culdir: 'newer',
cullimit: 'max', formatversion: 2
};
if (continueToken) params.culcontinue = continueToken;
const res = await robustCall(api, params);
const entries = (res.query && res.query.checkuserlog && res.query.checkuserlog.entries) ? res.query.checkuserlog.entries : [];
entries.forEach(e => {
if (e.checkuser) {
const u = e.checkuser;
const monthKey = e.timestamp.substring(0, 7); // Extract YYYY-MM
if (!userStats[u]) {
userStats[u] = { total: 0, timeline: {} };
}
userStats[u].total++;
userStats[u].timeline[monthKey] = (userStats[u].timeline[monthKey] || 0) + 1;
totalActions++;
}
});
if (res.continue && res.continue.culcontinue) {
continueToken = res.continue.culcontinue;
$('#cu-loader').text(`Processing logs... Found ${totalActions} actions.`);
} else {
finished = true;
}
}
// Phase 3: Get the absolute last action date for each user
const lastActions = {};
const usernames = Object.keys(userStats);
for (let j = 0; j < usernames.length; j++) {
const u = usernames[j];
$('#cu-loader').text(`Checking last activity for ${u} (${j + 1}/${usernames.length})...`);
const lastRes = await robustCall(api, { action: 'query', list: 'checkuserlog',
cullimit: 1, culuser: u, formatversion: 2
});
const lastEntry = (lastRes.query && lastRes.query.checkuserlog && lastRes.query.checkuserlog.entries && lastRes.query.checkuserlog.entries[0]) ? lastRes.query.checkuserlog.entries[0] : null;
lastActions[u] = lastEntry ? lastEntry.timestamp.replace('T', ' ').substring(0, 16) : 'Never';
}
$('#cu-loader').text('Rendering table...').css("color", "#0056b3");
renderTable(userStats, totalActions, lastActions, currentCUs, cfg);
$('#cu-loader').text('Audit complete!').css("color", "green");
} catch (err) {
console.error("CheckUser Stats Error:", err);
const errorMsg = err.statusText || err.message || err;
$('#cu-loader').text(`API Error: ${errorMsg}`).css("color", "#d33").show();
}
});
}
// Create the dynamic results table and Wikitext export
function renderTable(userStats, total, lastActions, currentCUs, cfg) {
// Sort users: highest total first, then alphabetical
const sortedUsers = Object.keys(userStats).map(u => ({ name: u, ...userStats[u] }))
.sort((a, b) => b.total - a.total || a.name.localeCompare(b.name));
// 1. Generate time columns based on the selected range and mode
const timeCols = [];
let startIter = new Date(Date.UTC(cfg.yf, cfg.mf - 1, 1));
const endIter = new Date(Date.UTC(cfg.yt, cfg.mt - 1, 1));
while (startIter <= endIter) {
const k = startIter.toISOString().substring(0, 7); // "YYYY-MM"
if (cfg.mode === 'months') {
timeCols.push({ id: k, label: k });
} else if (cfg.mode === 'quarters') {
const q = Math.floor(startIter.getUTCMonth() / 3) + 1;
const qid = `${startIter.getUTCFullYear()}-Q${q}`;
if (!timeCols.find(c => c.id === qid)) {
timeCols.push({ id: qid, label: qid, months: [] });
}
timeCols.find(c => c.id === qid).months.push(k);
} else if (cfg.mode === 'years') {
const yid = `${startIter.getUTCFullYear()}`;
if (!timeCols.find(c => c.id === yid)) {
timeCols.push({ id: yid, label: yid, months: [] });
}
timeCols.find(c => c.id === yid).months.push(k);
}
startIter.setUTCMonth(startIter.getUTCMonth() + 1);
}
// 2. Build HTML table headers and rows
const headerHtml = timeCols.map(c => `<th>${c.label}</th>`).join('');
const rows = sortedUsers.map(stat => {
const isCurrent = currentCUs.includes(stat.name);
const hasActions = stat.total > 0;
let trStyle = '';
if (isCurrent) trStyle = 'style="background-color: #94e1cc;"';
else if (!hasActions) trStyle = 'style="color: #72777d;"';
// Generate cells for the timeline (HTML and Wikitext)
const timeCells = timeCols.map(c => {
let val = 0;
if (cfg.mode === 'months') {
val = stat.timeline[c.id] || 0;
} else {
// Works for both quarters and years
c.months.forEach(m => val += (stat.timeline[m] || 0));
}
return `<td style="font-size:0.9em; color:#54595d;">${val > 0 ? val : '-'}</td>`;
}).join('');
return `
<tr ${trStyle}>
<td style="text-align:left; white-space: nowrap;">
<a href="${mw.util.getUrl('Special:CheckUserLog', { cuInitiator: stat.name })}">
${mw.html.escape(stat.name)}
</a>
<small style="color: #72777d;">Last: ${lastActions[stat.name]}</small>
</td>
<td><strong>${stat.total}</strong></td>
${timeCells}
</tr>`;
}).join('');
// 3. Prepare Wikitext for reports (cleaned structure)
const dbName = mw.config.get('wgDBname');
let wikitext = `== CheckUser Stats for ${dbName} (${cfg.mf}/${cfg.yf} – ${cfg.mt}/${cfg.yt}) ==\n`;
wikitext += `''Report generated on: ${getReportTimestamp()}<br />\nGenerated with [[testwiki:User:MrJaroslavik/CheckUserLogCount.js|CheckUserLogCount.js]]''\n`;
wikitext += `\n'''Total actions in period: ${total}'''\n\n`;
// Header logic: handle total only vs timeline columns
const colHeader = timeCols.length ? ' !! ' + timeCols.map(c => c.label).join(' !! ') : '';
wikitext += `{| class="wikitable sortable" style="text-align:right;"\n! Initiator !! Total${colHeader}\n`;
sortedUsers.forEach(stat => {
let timelineStr = '';
if (timeCols.length) {
timelineStr = ' || ' + timeCols.map(c => {
let val = 0;
if (cfg.mode === 'months') {
val = stat.timeline[c.id] || 0;
} else {
c.months.forEach(m => val += (stat.timeline[m] || 0));
}
return val;
}).join(' || ');
}
wikitext += `|-\n| style="text-align:left; white-space:nowrap;" | [[User:${stat.name}|${stat.name}]] <small>(Last: ${lastActions[stat.name]})</small> || '''${stat.total}'''${timelineStr}\n`;
});
wikitext += `|}\n`;
// 4. Update the UI container
$('#cu-table-container').html(`
<p><strong>Total actions found: ${total}</strong></p>
<div style="overflow-x: auto;">
<table class="wikitable sortable" style="width:100%; text-align:center;">
<thead>
<tr>
<th style="text-align:left">Initiator</th>
<th>Total</th>
${headerHtml}
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
`);
$('#cu-out').val(wikitext);
$('#cu-wikitext-container').show();
// Enable sorting
$('table.sortable').tablesorter();
}
// Initialize the UI on page load
setupUI();
});
})();
es2dj74sshdcdh4g7njnkg63sqd8bfk
User:MrJaroslavik/GlobalCheckUserStats.js
2
174967
739276
739199
2026-04-24T18:27:49Z
MrJaroslavik
44012
+
739276
javascript
text/javascript
// GlobalCheckUserStats.js
// -------------------------------------------------------
// Features:
// - Global Stats: Scans logs across 800+ projects at once.
// - Smart Roles: Identifies Local CheckUsers, Stewards, Staff, and Ombuds - and distinguishes between Former/Current roles.
// - Deep Scan: Bypasses API limits to get full history (limit 500 entries).
// - Stable: Retries 3x on rate limits so no wiki is missed.
// - Error Logs: Lists failed projects (ex. because of 429) at the end of the report.
// - UI: Integrated at [[meta:Special:BlankPage/GlobalCheckUserStats]].
// - Output: sortable Wikitables and detailed rights change logs and CSV file
// - With help of Gemini 3
// -------------------------------------------------------
(function() {
'use strict';
// Run only on Special:BlankPage/GlobalCheckUserStats
const isBlank = mw.config.get('wgCanonicalSpecialPageName') === 'Blankpage';
const isGCUS = mw.config.get('wgTitle').includes('GlobalCheckUserStats');
if (!isBlank || !isGCUS) {
return;
}
// Helper function to pause execution (useful for preventing API limits)
const DELAY_MS = 500;
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
// Makes API calls with automatic retries for "429 Too Many Requests" errors
async function robustCall(api, params) {
let retries = 0;
const maxRetries = 3;
while (retries < maxRetries) {
try {
// Attempt to fetch data from the MediaWiki API
return await api.get(params);
} catch (err) {
// Check if the server is actively rejecting requests due to high load (HTTP 429)
// Note: Optional chaining (?.) is avoided for broad MediaWiki editor compatibility
if (err && err.status === 429) {
retries++;
// Read the 'Retry-After' header provided by the server, or default to a scaling wait time
const waitTime = parseInt(err.xhr && err.xhr.getResponseHeader('Retry-After')) || (30 * retries);
// Notify the user via the UI that the script is pausing (supports both UI container types)
$('#status-msg, #cu-loader').html(
`<span style="color:orange; font-weight:bold;">Server is busy! Pausing for ${waitTime} seconds...</span>`
).show();
// Pause execution for the requested duration before trying again
await sleep(waitTime * 1000);
} else {
// If it is a different error (network failure, 500 Internal Error, etc.), stop and throw
throw err;
}
}
}
// Fail completely if max retries are exceeded
throw new Error("Failed to reach API after multiple attempts due to server limits.");
}
// Returns a standardized UTC timestamp for report headers (YYYY-MM-DD HH:MM)
function getReportTimestamp() {
return new Date().toISOString().replace('T', ' ').substring(0, 16) + ' (UTC)';
}
// Save data here so we don't have to download it twice
let userCache = {};
let historyCache = {};
// This map of all wikis will be filled automatically
let globalWikiMap = {};
// Process various API response formats into a clean array of group names
const extractGroups = function(data) {
if (!data) return [];
var res = [];
if (Array.isArray(data)) {
res = data;
} else if (typeof data === 'object') {
res = Object.values(data);
} else if (typeof data === 'string') {
res = data.split(',').map(function(s) {
return s.trim();
});
}
return res.map(function(g) {
if (typeof g !== 'string') return g;
return g.replace(/\s+/g, '').toLowerCase();
});
};
// List of wikis where CheckUser extension is disabled - https://noc.wikimedia.org/conf/highlight.php?file=dblists/checkuser-disabled.dblist
const disabledCUWikis = [
'aawikibooks', 'abwiktionary', 'advisorywiki', 'akwiktionary', 'angwikiquote',
'angwikisource', 'astwikibooks', 'astwikiquote', 'aswikibooks', 'aswiktionary',
'avwiktionary', 'aywikibooks', 'bhwiktionary', 'biwikibooks', 'biwiktionary',
'bmwikibooks', 'bmwikiquote', 'bmwiktionary', 'bowiktionary', 'chwikibooks',
'chwiktionary', 'cnwikimedia', 'crwikiquote', 'crwiktionary', 'dzwiktionary',
'gawikibooks', 'gnwikibooks', 'gotwikibooks', 'guwikibooks', 'htwikisource',
'huwikinews', 'hzwiki', 'iewikibooks', 'iiwiki', 'internalwiki', 'kjwiki',
'kkwikiquote', 'knwikibooks', 'krwiki', 'krwikiquote', 'kswikibooks',
'kswikiquote', 'kwwikiquote', 'lbwikibooks', 'lbwikiquote', 'lnwikibooks',
'lvwikibooks', 'mhwiktionary', 'mnwikibooks', 'muswiki', 'nahwikibooks',
'nawikibooks', 'nawikiquote', 'ndswikibooks', 'ndswikiquote', 'piwiktionary',
'pswikibooks', 'quwikibooks', 'quwikiquote', 'rmwikibooks', 'rmwiktionary',
'rnwiktionary', 'scwiktionary', 'searchcomwiki', 'snwiktionary', 'spcomwiki',
'swwikibooks', 'thwikinews', 'tkwikibooks', 'tkwikiquote', 'towiktionary',
'transitionteamwiki', 'trwikinews', 'ttwikiquote', 'twwiktionary',
'ugwikibooks', 'ugwikiquote', 'vowikibooks', 'vowikiquote', 'wawikibooks',
'wikimania2005wiki', 'wikimania2006wiki', 'wikimania2007wiki',
'wikimania2009wiki', 'wikimania2015wiki', 'wowikiquote', 'xhwikibooks',
'xhwiktionary', 'yowikibooks', 'yowiktionary', 'zawikibooks', 'zawikiquote',
'zawiktionary', 'zh_min_nanwikibooks', 'zuwikibooks',
// Closed/Unavailable projects (added manually)
'aawiktionary', 'akwikibooks', 'amwikiquote', 'angwikibooks', 'bgwikinews',
'bowikibooks', 'chowiki', 'cowikibooks', 'cowikiquote', 'gawikiquote',
'howiki', 'ikwiktionary', 'kywikibooks', 'mhwiki', 'miwikibooks',
'mywikibooks', 'nawiki', 'nzwikimedia', 'pa_uswikimedia', 'qualitywiki',
'ruwikimedia', 'sdwikinews', 'sewikibooks', 'simplewikibooks', 'strategywiki',
'suwikibooks', 'tenwiki', 'usabilitywiki', 'uzwikibooks', 'wikimania2010wiki',
'wikimania2011wiki', 'wikimania2012wiki', 'wikimania2013wiki', 'wikimania2014wiki',
'wikimania2016wiki', 'wikimania2017wiki', 'wikimania2018wiki', 'zh_min_nanwikiquote'
];
// Projects known to have local CheckUsers
const localCUWikis = [
'arwiki', 'bnwiki', 'cawiki', 'cswiki', 'dawiki', 'nlwiki', 'enwikibooks',
'enwiki', 'enwikivoyage', 'enwiktionary', 'fiwiki', 'frwiki', 'dewiki',
'hewiki', 'huwiki', 'idwiki', 'itwiki', 'jawiki', 'kowiki', 'fawiki',
'plwiki', 'ptwiki', 'ruwiki', 'srwiki', 'simplewiki', 'slwiki', 'eswiki',
'svwiki', 'thwiki', 'trwiki', 'ukwiki', 'viwiki', 'commonswiki',
'specieswiki', 'metawiki', 'wikidatawiki'
];
// Fetch all available wikis and filter out restricted ones
function loadGlobalWikiMap() {
const api = new mw.Api();
return api.get({
action: 'sitematrix',
format: 'json',
smtype: 'language|special',
smlangprop: 'site',
smsiteprop: 'dbname|url',
formatversion: 2,
origin: '*'
}).then(function(data) {
const matrix = data.sitematrix;
const wikiMap = {};
Object.keys(matrix).forEach(function(key) {
const entry = matrix[key];
if (entry.site && Array.isArray(entry.site)) {
entry.site.forEach(function(site) {
if (site.private || site.fishbowl || disabledCUWikis.indexOf(site.dbname) !== -1) return;
wikiMap[site.dbname] = site.url;
});
}
});
if (matrix.specials && Array.isArray(matrix.specials)) {
matrix.specials.forEach(function(special) {
if (special.private || special.fishbowl || disabledCUWikis.indexOf(special.dbname) !== -1) return;
wikiMap[special.dbname] = special.url;
});
}
return wikiMap;
});
}
// Convert database names to interwiki prefixes for table links
function getInterwikiPrefix(db) {
const specialMap = {
'commonswiki': 'c', 'metawiki': 'm', 'wikidatawiki': 'd',
'wikifunctionswiki': 'f', 'mediawikiwiki': 'mw', 'specieswiki': 'species',
'sourceswiki': 'oldwikisource', 'foundationwiki': 'foundation',
'incubatorwiki': 'incubator', 'outreachwiki': 'outreach',
'betawikiversity': 'betawikiversity', 'be_x_oldwiki': 'be-tarask',
'zh_classicalwiki': 'lzh', 'zh_min_nanwiki': 'nan', 'zh_yuewiki': 'yue'
};
if (specialMap[db]) return specialMap[db];
const name = db.replace(/_/g, '-');
if (name.endsWith('wikisource')) return 's:' + name.slice(0, -10);
if (name.endsWith('wikiversity')) return 'v:' + name.slice(0, -11);
if (name.endsWith('wiktionary')) return 'wikt:' + name.slice(0, -10);
if (name.endsWith('wikivoyage')) return 'voy:' + name.slice(0, -10);
if (name.endsWith('wikibooks')) return 'b:' + name.slice(0, -9);
if (name.endsWith('wikiquote')) return 'q:' + name.slice(0, -9);
if (name.endsWith('wikinews')) return 'n:' + name.slice(0, -8);
if (name.endsWith('wikimedia')) return 'wm' + name.slice(0, -9);
if (name.endsWith('wiki')) return 'w:' + name.slice(0, -4);
return 'w:' + name;
}
// Load required modules and start the script
mw.loader.using(['mediawiki.api', 'mediawiki.ForeignApi']).then(async function() {
// Display initial status message to the user
$('#mw-content-text').html('<strong>GlobalCheckUserStats.js: Loading wiki list...</strong>');
// Download and cache the list of all Wikimedia wikis
globalWikiMap = await loadGlobalWikiMap();
// Prepare the Meta-Wiki API and data containers
const metaApi = new mw.ForeignApi('https://meta.wikimedia.org/w/api.php');
const now = new Date();
// Variables for storing audit results and script state
let results = {};
let emptyWikis = [];
let failedWikis = [];
let scannedWikis = [];
let isRunning = false;
// Current UI filter settings
let currentFilterMode = 'all';
let currentUserFilter = 'all';
// Prepare the year and month selection menus
function setupUI() {
const currentYear = now.getFullYear();
// Generate options for years from 2005 to today
let yearOpts = '';
for (let y = currentYear; y >= 2005; y--) {
yearOpts += `<option value="${y}">${y}</option>`;
}
// Generate options for the month range (January to December)
let monthOptsFrom = '';
let monthOptsTo = '';
for (let m = 1; m <= 12; m++) {
monthOptsFrom += `<option value="${m}" ${m === 1 ? 'selected' : ''}>${m}</option>`;
monthOptsTo += `<option value="${m}" ${m === 12 ? 'selected' : ''}>${m}</option>`;
}
// Set the page title and build the HTML interface
$('#firstHeading').text('GlobalCheckUserStats.js');
$('#mw-content-text').empty().append(`
<div style="border:1px solid #a2a9b1; padding:15px; background:#f8f9fa;">
<h3 style="margin-top:0;">Stats Range</h3>
<div>
From: <select id="y-f" style="width:70px;">${yearOpts}</select>
<select id="m-f" style="width:50px;">${monthOptsFrom}</select>
To: <select id="y-t" style="width:70px;">${yearOpts}</select>
<select id="m-t" style="width:50px;">${monthOptsTo}</select>
</div>
<div style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>Wiki Filter:</strong>
<label style="cursor:pointer;"><input type="radio" name="wiki-mode" id="btn-all" checked> All</label>
<label style="cursor:pointer;"><input type="radio" name="wiki-mode" id="btn-localcu"> Local CU Wikis</label>
<label style="cursor:pointer;"><input type="radio" name="wiki-mode" id="btn-except"> All except</label>
<label style="cursor:pointer;"><input type="radio" name="wiki-mode" id="btn-only"> Only these</label>
<span id="wiki-help-trigger" style="cursor:help; background:#36c; color:#fff; border-radius:50%; width:18px; height:18px; display:inline-block; text-align:center; font-weight:bold;" title="Show available DB names">?</span>
</div>
<div id="filter-input-container" style="display:none; margin-top:10px;">
<input id="wiki-filter" type="text" style="width:100%;" placeholder="Example: enwiki, dewiki, commonswiki...">
</div>
<div style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>User Filter:</strong>
<label style="cursor:pointer;"><input type="radio" name="user-mode" id="u-all" checked> All</label>
<label style="cursor:pointer;"><input type="radio" name="user-mode" id="u-local"> Local CheckUsers</label>
<label style="cursor:pointer;"><input type="radio" name="user-mode" id="u-steward"> Steward actions</label>
</div>
<div style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>View mode (Wikitext):</strong>
<label style="cursor:pointer;"><input type="radio" name="cu-view-mode" value="total"> Total only</label>
<label style="cursor:pointer;"><input type="radio" name="cu-view-mode" value="months" checked> Months</label>
<label style="cursor:pointer;"><input type="radio" name="cu-view-mode" value="quarters"> Q1-Q4</label>
<label style="cursor:pointer;"><input type="radio" name="cu-view-mode" value="years"> Years</label>
</div>
<div id="wiki-list-help" style="display:none; margin-top:10px; padding:10px; background:#fff; border:1px solid #a2a9b1; font-size:11px; max-height:120px; overflow-y:auto; font-family:monospace;">
<strong>Available database names:</strong><br>${Object.keys(globalWikiMap).sort().join(', ')}
</div>
<div style="margin-top:20px;">
<button id="start" class="mw-ui-button mw-ui-progressive">Run</button>
<button id="stop" class="mw-ui-button mw-ui-destructive" disabled>Stop</button>
</div>
<div id="status-msg" style="margin-top:10px; font-weight:bold; color:#0056b3;">Ready.</div>
<div style="margin-top:5px;">
<progress id="bar" value="0" max="${Object.keys(globalWikiMap).length}" style="width:100%"></progress>
</div>
<div id="output-containers" style="display:none; margin-top:10px;">
<div style="margin-bottom:10px;">
<strong>Wikitext:</strong>
<textarea id="out" style="width:100%; height:250px; font-family:monospace; font-size:12px;"></textarea>
</div>
<div>
<strong>CSV:</strong>
<textarea id="csv-out" style="width:100%; height:150px; font-family:monospace; font-size:12px;"></textarea>
</div>
</div>
</div>
`);
// Listen for filter changes and button clicks
$('#btn-all').on('click', () => {
currentFilterMode = 'all';
$('#filter-input-container').hide();
});
$('#btn-localcu').on('click', () => {
currentFilterMode = 'localcu';
$('#filter-input-container').hide();
});
$('#btn-except').on('click', () => {
currentFilterMode = 'exclude';
$('#filter-input-container').show();
$('#wiki-filter').focus();
});
$('#btn-only').on('click', () => {
currentFilterMode = 'include';
$('#filter-input-container').show();
$('#wiki-filter').focus();
});
// User type filters
$('#u-all').on('click', () => {
currentUserFilter = 'all';
});
$('#u-local').on('click', () => {
currentUserFilter = 'local';
});
$('#u-steward').on('click', () => {
currentUserFilter = 'steward';
});
// Help and Execution controls
$('#wiki-help-trigger').on('click', () => $('#wiki-list-help').toggle());
$('#start').on('click', () => {
runAudit(
$('#y-f').val(), $('#m-f').val(),
$('#y-t').val(), $('#m-t').val()
);
});
$('#stop').on('click', () => {
isRunning = false;
});
}
// Get the number of active users for a specific wiki
async function fetchWikiMetrics(db) {
try {
// Find the correct server URL for this database
const baseUrl = globalWikiMap[db];
const api = new mw.ForeignApi(baseUrl + '/w/api.php');
// Request site statistics from the API
const res = await robustCall(api, {
action: 'query',
meta: 'siteinfo',
siprop: 'statistics',
formatversion: 2
});
// Return the count of active users
return {
active: res.query.statistics.activeusers || 0
};
} catch (err) {
// If the wiki is unreachable, return zero as a fallback
return {
active: 0
};
}
}
// Checks if the user held global roles (Steward/Staff/Ombuds)
async function checkGlobalHistory(user, start, end) {
try {
const res = await robustCall(metaApi, {
action: 'query',
list: 'logevents',
letype: 'gblrights',
letitle: 'User:' + user,
lelimit: 'max',
formatversion: 2
});
const logs = res.query.logevents || [];
const auditStart = new Date(start);
const auditEnd = new Date(end);
let status = {
wasSteward: false,
wasStaff: false,
wasOmbuds: false
};
logs.forEach(log => {
const ts = new Date(log.timestamp);
const p = log.params || {};
// Handle both new (named) and old (indexed) API formats
const current = extractGroups(p.newGroups || p.add || p[1] || p["1"]);
const old = extractGroups(p.oldGroups || p.remove || p[2] || p["0"]);
if (ts >= auditStart && ts <= auditEnd) {
if (current.includes('steward') || old.includes('steward')) status.wasSteward = true;
if (current.includes('staff') || old.includes('staff')) status.wasStaff = true;
if (current.some(g => g.includes('ombud')) || old.some(g => g.includes('ombud'))) status.wasOmbuds = true;
}
});
// If no changes during the period, check the state immediately before it started
if (!status.wasSteward && !status.wasStaff && !status.wasOmbuds) {
const priorLogs = logs
.filter(log => new Date(log.timestamp) < auditStart)
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
if (priorLogs.length > 0) {
const p = priorLogs[0].params || {};
// Crucial: check both newGroups and legacy indexed params
const groupsBefore = extractGroups(p.newGroups || p.add || p[1] || p["1"]);
status.wasSteward = groupsBefore.includes('steward');
status.wasStaff = groupsBefore.includes('staff');
status.wasOmbuds = groupsBefore.some(g => g.includes('ombud'));
}
}
return status;
} catch (err) {
return {
wasSteward: false,
wasStaff: false,
wasOmbuds: false
};
}
}
// Gather user information and fetch their rights logs
async function fetchUserData(user, db, start, end) {
const auditStart = new Date(start);
const auditEnd = new Date(end);
// Get current global groups from Meta-Wiki if not already cached
if (!userCache[user]) {
try {
const globalRes = await robustCall(metaApi, {
action: 'query',
meta: 'globaluserinfo',
guiprop: 'groups',
guiuser: user,
formatversion: 2
});
userCache[user] = globalRes.query.globaluserinfo.groups || [];
} catch (err) {
userCache[user] = [];
}
}
// Canonical variables for current global status
const currentGlobalGroups = userCache[user];
const isGloballySteward = currentGlobalGroups.includes('steward');
const isGloballyStaff = currentGlobalGroups.includes('staff');
const isGloballyOmbuds = currentGlobalGroups.includes('ombuds') || currentGlobalGroups.includes('ombudsman');
// Check for global role history in cache or fetch new data
if (!historyCache[user]) {
historyCache[user] = await checkGlobalHistory(user, start, end);
}
const historyRes = historyCache[user];
let isCurrentLocal = false;
// Check current local CheckUser rights on the target wiki
try {
const wikiUrl = globalWikiMap[db];
const localApi = new mw.ForeignApi(wikiUrl + '/w/api.php');
// Request user group information from the local API
const localRes = await robustCall(localApi, {
action: 'query',
list: 'users',
ususers: user,
usprop: 'groups',
formatversion: 2
});
// Parse response and verify if 'checkuser' group is present
const localUserData = localRes.query.users[0];
const localGroups = (localUserData && localUserData.groups) || [];
isCurrentLocal = localGroups.includes('checkuser');
} catch (err) {
// Default to false if the local rights check fails
isCurrentLocal = false;
}
// Define the target string for CentralAuth rights logs
const target = 'User:' + user + '@' + db;
let logText = '',
isSelfAssign = false,
maxDurationMins = -1,
longestTimeStr = "",
assignCount = 0,
hasLocalRightsInPeriod = false;
// Retrieve all rights change logs from Meta-Wiki
let events = [],
continueToken = null,
finished = false;
while (!finished) {
let params = {
action: 'query',
list: 'logevents',
letype: 'rights',
letitle: target,
ledir: 'older',
lelimit: 'max',
formatversion: 2
};
// Handle API pagination with continue tokens
if (continueToken) Object.assign(params, continueToken);
// Execute the API call and merge the results
const res = await robustCall(metaApi, params);
const batch = res.query.logevents || [];
events = events.concat(batch);
// Check for more results to continue pagination
if (res.continue) {
continueToken = res.continue;
} else {
finished = true;
}
}
// Initialize state variables for log processing
let logEntries = [],
pendingRemoved = null,
lastPairedDate = null,
capturedExpiry = null;
// Iterate through collected events to process rights changes
for (let i = 0; i < events.length; i++) {
const e = events[i];
const p = e.params || {};
// Extract the performing user, stripping import prefixes if present
const actor = e.user && e.user.includes('>') ? e.user.split('>')[1] : e.user;
// Determine CheckUser status before and after the event using various param keys
const hadCU = extractGroups(p.oldgroups || p.oldGroups || p["0"]).includes('checkuser');
const hasCU = extractGroups(p.newgroups || p.newGroups || p["1"]).includes('checkuser');
// Extract expiry metadata if the user already has rights and they are being updated
if (hasCU && hadCU) {
const rawMeta = p.newmetadata || p.newMetadata;
if (rawMeta) {
const metaArray = Array.isArray(rawMeta) ? rawMeta : Object.values(rawMeta);
const cuMeta = metaArray.find(m => m && (m.group === 'checkuser' || m.group === 'check user'));
if (cuMeta && cuMeta.expiry && cuMeta.expiry !== 'infinity') capturedExpiry = new Date(cuMeta.expiry);
}
}
// Identify if CheckUser was added or removed in this event
let cuAdded = (hasCU && !hadCU),
cuRemoved = (hadCU && !hasCU);
if (cuAdded || cuRemoved) {
const eventDate = new Date(e.timestamp),
exactTime = e.timestamp.replace('T', ' ').replace('Z', '');
const isInPeriod = (eventDate >= auditStart && eventDate <= auditEnd);
if (isInPeriod) hasLocalRightsInPeriod = true;
// Process events relevant to the audit timeframe or pairing logic
if (isInPeriod || (eventDate > auditEnd) || (pendingRemoved && cuAdded)) {
let expiryDate = capturedExpiry;
capturedExpiry = null;
// Check for metadata again to ensure current expiry is captured
const rawMeta = p.newmetadata || p.newMetadata;
if (rawMeta) {
const metaArray = Array.isArray(rawMeta) ? rawMeta : Object.values(rawMeta);
const cuMeta = metaArray.find(m => m && (m.group === 'checkuser' || m.group === 'check user'));
if (cuMeta && cuMeta.expiry && cuMeta.expiry !== 'infinity') expiryDate = new Date(cuMeta.expiry);
}
const eventBySelf = (actor === user || !actor);
if (cuAdded) {
const removalInPeriod = pendingRemoved && (pendingRemoved.date >= auditStart && pendingRemoved.date <= auditEnd);
const expiryInPeriod = expiryDate && (expiryDate >= auditStart && expiryDate <= auditEnd);
// Pair adding event with its removal or expiry to calculate duration
if ((isInPeriod || removalInPeriod || expiryInPeriod) && (pendingRemoved || expiryDate)) {
const checkDate = pendingRemoved ? pendingRemoved.date : expiryDate;
const diffMs = Math.abs(checkDate - eventDate);
const totalSecs = Math.floor(diffMs / 1000);
// Intelligent rounding to handle short durations and edge cases
let roundedMins = Math.round(totalSecs / 60);
if (roundedMins === 0 && totalSecs > 0) roundedMins = 1;
// Convert total minutes into days, hours, and remaining minutes
let days = Math.floor(roundedMins / 1440);
let hours = Math.floor((roundedMins % 1440) / 60);
let mins = roundedMins % 60;
// Build a human-readable duration string
let dPart = days > 0 ? days + 'd ' : '';
let hPart = hours > 0 ? hours + 'h ' : '';
let mPart = (mins > 0 || (days === 0 && hours === 0)) ? mins + 'm' : '';
let durStr = totalSecs < 60 ? totalSecs + "s" : (dPart + hPart + mPart).trim();
// Capture the log comment for the ADDED event
let reason = e.comment ? ` <small>''(${e.comment})''</small>` : "";
// Determine if this session was a Steward self-assignment
if (pendingRemoved) {
if (eventBySelf && pendingRemoved.isSelf) isSelfAssign = true;
// Retrieve the removal comment if available
let remReason = pendingRemoved.comment ? ` <small>''(${pendingRemoved.comment})''</small>` : "";
// Calculate discrepancy between manual removal and scheduled expiry
let note = "";
if (expiryDate) {
// Calculate planned duration in seconds and format it
const pDiff = Math.abs(expiryDate - eventDate) / 1000;
let rP = Math.round(pDiff / 60);
let rd = Math.floor(rP / 1440),
rh = Math.floor((rP % 1440) / 60),
rm = rP % 60;
let pD = pDiff < 60 ? Math.round(pDiff) + "s" : `${rd > 0 ? rd + 'd ' : ''}${rh > 0 ? rh + 'h ' : ''}${(rm > 0 || (rd === 0 && rh === 0)) ? rm + 'm' : ''}`.trim();
// Check if rights were removed manually earlier or later than planned
let timeDiff = pendingRemoved.date - expiryDate;
if (timeDiff < -60000) {
note = ` - manual removal before scheduled expiry (set to ${pD})`;
} else if (timeDiff > 600000) {
note = ` - automatic removal not found (set to ${pD})`;
}
}
// Finalize the paired log entry for manual removals
logEntries.unshift(`* ADDED: ${exactTime} by ${actor}${reason} | REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${remReason} (Duration: ${durStr}${note})`);
} else if (expiryDate) {
// Handle automatic expiration scenarios
if (eventBySelf) isSelfAssign = true;
logEntries.unshift(`* ADDED: ${exactTime} by ${actor}${reason} | EXPIRED: ${expiryDate.toISOString().replace('T', ' ').substring(0, 19)} <small>''(Automatic)''</small> (Duration: ${durStr})`);
}
// Reset pending state and update audit metrics
pendingRemoved = null;
if (totalSecs / 60 > maxDurationMins) {
maxDurationMins = totalSecs / 60;
longestTimeStr = durStr.trim();
}
assignCount++;
lastPairedDate = eventDate;
} else if (isInPeriod && !pendingRemoved && !expiryDate) {
// Handle cases where rights are still active or removal log is missing
if (!(lastPairedDate && Math.abs(lastPairedDate - eventDate) < 86400000)) {
logEntries.unshift(`* ADDED: ${exactTime} by ${actor} | REMOVED: (Active/Not removed)`);
lastPairedDate = eventDate;
}
pendingRemoved = null;
}
} else if (cuRemoved) {
// Record standalone removal events for future pairing
if (pendingRemoved && pendingRemoved.date >= auditStart && pendingRemoved.date <= auditEnd) {
logEntries.unshift(`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}`);
}
// Store removal data to be paired with the next ADDED event
pendingRemoved = {
time: exactTime,
date: eventDate,
user: actor,
isSelf: eventBySelf,
comment: e.comment
};
}
}
}
}
// Verify if the user held rights at the start of the period by checking prior logs
if (!hasLocalRightsInPeriod && events.length > 0) {
const firstBefore = events.find(ev => new Date(ev.timestamp) < auditStart);
if (firstBefore) {
const groupsBefore = extractGroups(firstBefore.params.newgroups || firstBefore.params.add || firstBefore.params[1] || firstBefore.params["1"]);
if (groupsBefore.includes('checkuser')) hasLocalRightsInPeriod = true;
}
// If still not found, check the oldest available log entry for the initial state
if (!hasLocalRightsInPeriod) {
const chronologicallyFirst = events[events.length - 1];
const cp = chronologicallyFirst.params || {};
const oldGroups = extractGroups(cp.oldgroups || cp.oldGroups || cp.remove || cp[0] || cp["0"]);
if (oldGroups.includes('checkuser')) hasLocalRightsInPeriod = true;
}
}
// Finalize the log text assembly
if (pendingRemoved && pendingRemoved.date >= auditStart && pendingRemoved.date <= auditEnd) {
logEntries.unshift(`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}`);
}
if (logEntries.length) logText = '\n' + logEntries.join('\n');
// Logic for determining user roles and categories
let roles = [];
const isStewardInPeriod = isGloballySteward || historyRes.wasSteward;
const isTemporaryMission = (longestTimeStr && isSelfAssign && isStewardInPeriod);
// Process Global Staff and Ombudsman roles
if (isGloballyStaff) roles.push("Current Staff");
else if (historyRes.wasStaff) roles.push("Former Staff");
if (isGloballyOmbuds) roles.push("Current Ombudsman");
else if (historyRes.wasOmbuds) roles.push("Former Ombudsman");
// Steward logic: Distinguish between temporary actions and permanent roles
if (isTemporaryMission) {
const countLabel = assignCount > 1 ? `${assignCount}x, longest ` : "";
roles.push(`Steward action (Self-assign: ${countLabel}${longestTimeStr})`);
} else {
if (isGloballySteward) roles.push("Current Steward");
else if (historyRes.wasSteward) roles.push("Former Steward");
}
// Process Local CheckUser roles
if (isCurrentLocal) roles.push("Current Local CheckUser");
if (hasLocalRightsInPeriod && !isCurrentLocal && !isTemporaryMission) {
roles.push("Former Local CheckUser");
}
// Consolidate unique roles into a formatted label
let uniqueRoles = [...new Set(roles)];
let roleLabel = uniqueRoles.length > 0 ? uniqueRoles.join(' & ') : "Unknown role";
// Return final metadata object for project reporting
return {
role: roleLabel,
log: logText,
hasLocalRightsInPeriod: hasLocalRightsInPeriod,
isStewardAction: isTemporaryMission,
selfAssignCount: assignCount,
maxDurationMins: maxDurationMins
};
}
// Main execution loop with Cross-Tab concurrency protection
async function runAudit(yf, mf, yt, mt) {
$('#status-msg').text('Waiting for other tabs to finish...').css("color", "orange");
$('#start').prop('disabled', true);
// Utilize Web Locks API to prevent concurrent API requests from multiple tabs
await navigator.locks.request('global_cu_audit', async () => {
isRunning = true;
// Clear cache and result containers for a fresh scan
userCache = {};
historyCache = {};
results = {};
emptyWikis = [];
failedWikis = [];
scannedWikis = [];
$('#status-msg').text('Lock acquired. Initializing...').css("color", "#0056b3");
$('#stop').prop('disabled', false);
// Parse wiki filter input from the UI
const filterText = $('#wiki-filter').val().trim().toLowerCase();
const filterList = filterText ? filterText.split(',').map(s => s.trim()).filter(s => s !== "") : [];
// Determine the set of wikis to process based on user filters
const allAvailableWikis = Object.keys(globalWikiMap);
let wikisToScan = allAvailableWikis;
// Apply inclusion or exclusion logic based on current UI selection
if (currentFilterMode === 'include') {
wikisToScan = allAvailableWikis.filter(w => filterList.includes(w));
} else if (currentFilterMode === 'exclude') {
wikisToScan = allAvailableWikis.filter(w => !filterList.includes(w));
} else if (currentFilterMode === 'localcu') {
wikisToScan = allAvailableWikis.filter(w => localCUWikis.indexOf(w) !== -1);
}
// Validate that there are wikis to scan before proceeding
if (wikisToScan.length === 0) {
alert("No wikis selected for audit!");
isRunning = false;
$('#start').prop('disabled', false);
return;
}
// Initialize the progress bar UI
$('#bar').attr('max', wikisToScan.length).val(0);
// Define the standardized ISO timestamps for the MediaWiki API
const START = `${yf}-${String(mf).padStart(2, '0')}-01T00:00:00Z`;
const END = `${yt}-${String(mt).padStart(2, '0')}-${new Date(Date.UTC(yt, mt, 0)).getUTCDate().toString().padStart(2, '0')}T23:59:59Z`;
// Generate the chronological month columns for the report table
const monthCols = [];
let currY = parseInt(yf),
currM = parseInt(mf),
endY = parseInt(yt),
endM = parseInt(mt);
while (currY < endY || (currY === endY && currM <= endM)) {
monthCols.push({
key: `${currY}-${String(currM).padStart(2, '0')}`,
label: `${["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][currM - 1]} ${currY}`
});
currM++;
if (currM > 12) {
currM = 1;
currY++;
}
}
// Iterate through each wiki project to collect CheckUser activity
for (let i = 0; i < wikisToScan.length; i++) {
if (!isRunning) break; // Allow for manual stop
const db = wikisToScan[i];
scannedWikis.push(db);
$('#status-msg').text(`Scanning ${db} (${i + 1}/${wikisToScan.length})...`);
let successLocal = false,
continueToken = null;
// Phase 1: Retrieve CheckUser logs and aggregate monthly stats
while (!successLocal && isRunning) {
try {
const api = new mw.ForeignApi(globalWikiMap[db] + '/w/api.php');
let params = {
action: 'query',
list: 'checkuserlog',
culfrom: START,
culto: END,
culdir: 'newer',
cullimit: 'max',
formatversion: 2
};
if (continueToken) Object.assign(params, continueToken);
let res = await robustCall(api, params);
const entries = (res.query && res.query.checkuserlog && res.query.checkuserlog.entries) ? res.query.checkuserlog.entries : [];
if (entries.length) {
if (!results[db]) results[db] = {};
entries.forEach(e => {
const user = e.checkuser;
if (!results[db][user]) results[db][user] = {
total: 0,
months: {}
};
const mKey = e.timestamp.slice(0, 7);
results[db][user].total++;
results[db][user].months[mKey] = (results[db][user].months[mKey] || 0) + 1;
});
}
// Handle API pagination
if (res.continue && isRunning) {
continueToken = res.continue;
} else {
successLocal = true;
}
} catch (err) {
failedWikis.push(db);
successLocal = true;
}
}
// Phase 2: Capture current CheckUsers (to include users with 0 actions)
if (isRunning && !failedWikis.includes(db)) {
try {
const auApi = new mw.ForeignApi(globalWikiMap[db] + '/w/api.php');
const auRes = await robustCall(auApi, {
action: 'query',
list: 'allusers',
augroup: 'checkuser',
aulimit: 'max',
formatversion: 2
});
const currentCUs = auRes.query.allusers || [];
if (currentCUs.length > 0) {
if (!results[db]) results[db] = {};
currentCUs.forEach(u => {
if (!results[db][u.name]) results[db][u.name] = {
total: 0,
months: {}
};
});
}
} catch (e) {
/* Fallback for local API failures */
}
}
// Record wikis with no CheckUser presence found
if (!results[db] && !failedWikis.includes(db)) {
emptyWikis.push(db);
}
// Update UI progress
$('#bar').val(i + 1);
await sleep(DELAY_MS);
}
// Finalize results and compile the audit report
$('#status-msg').text(`Generating report...`);
// Calculate global activity totals for each user across all wikis
let userGlobalTotals = {};
Object.keys(results).forEach(db => {
Object.keys(results[db]).forEach(user => {
userGlobalTotals[user] = (userGlobalTotals[user] || 0) + results[db][user].total;
});
});
// Setup report timestamps
const reportDate = new Date().toISOString().split('T')[0];
const viewMode = $('input[name="cu-view-mode"]:checked').val();
const timeCols = [];
if (viewMode === 'months') {
monthCols.forEach(m => timeCols.push({
id: m.key,
label: m.label
}));
} else if (viewMode !== 'total') {
monthCols.forEach(m => {
let label;
if (viewMode === 'quarters') {
const q = Math.floor((parseInt(m.key.split('-')[1]) - 1) / 3) + 1;
label = `${m.key.split('-')[0]}-Q${q}`;
} else {
label = m.key.split('-')[0]; // Years
}
let col = timeCols.find(c => c.label === label);
if (!col) {
col = {
label: label,
months: []
};
timeCols.push(col);
}
col.months.push(m.key);
});
}
// Initialize Wikitext output with header information
let wikitext = `== Global CheckUser Stats (${mf}/${yf} - ${mt}/${yt}) ==\n`;
wikitext += `''Report generated on: ${getReportTimestamp()}<br />\nGenerated with [[testwiki:User:MrJaroslavik/GlobalCheckUserStats.js|GlobalCheckUserStats.js]]''\n`;
// Initialize CSV output with headers
let csvData = "Project,User,Role,SelfAssignCount,MaxDuration_mins,TotalActions\n";
// Document applied filters in the wikitext report
let filterSummary = [];
// Only add wiki filter if the list is not empty, or if localcu is selected
if (currentFilterMode === 'localcu') {
filterSummary.push(`Wikis with local CheckUsers`);
} else if (filterList.length > 0) {
if (currentFilterMode === 'include') {
filterSummary.push(`Only these wikis: ${filterList.join(', ')}`);
} else if (currentFilterMode === 'exclude') {
filterSummary.push(`All except wikis: ${filterList.join(', ')}`);
}
}
if (currentUserFilter === 'local') filterSummary.push(`Only users: Local CheckUsers`);
else if (currentUserFilter === 'steward') filterSummary.push(`Only users: Steward actions`);
if (filterSummary.length > 0) {
wikitext += `<br />\n''Used filters: ${filterSummary.join(' | ')}''\n`;
}
wikitext += `\n`;
// Start building the main results table
let rightsLogText = `\n== Rights Log (In Period) ==\n`;
const per1kExpl = "Calculated as: (Total Actions in selected period / Current Active Users) * 1,000";
wikitext += `{| class="wikitable sortable" style="font-size:90%; text-align:right;"\n! Wiki / User !! Category !! '''Total''' !! per 1k users<ref>${per1kExpl}</ref> ${timeCols.map(c => `!! ${c.label}`).join(' ')}\n`;
// Process each database in alphabetical order
const sortedDBs = Object.keys(results).sort();
for (const db of sortedDBs) {
const metrics = await fetchWikiMetrics(db);
const activeFormatted = metrics.active > 0 ? metrics.active.toLocaleString('en-US') : 'Unknown';
const interwiki = getInterwikiPrefix(db);
// Build wiki-specific header row
let headerRow = `|-\n! colspan="${4 + timeCols.length}" style="background:#eaecf0; text-align:center;" | [[${interwiki}:|${db}]] <small style="font-weight:normal; color:#54595d;">(Active Users as of ${reportDate}: ${activeFormatted})</small> — [[${interwiki}:Special:CheckUserLog|CheckUserLog]] · [[${interwiki}:Special:ListUsers/checkuser|ListUsers/checkuser]]\n`;
let projectRows = [],
wikiTotal = 0,
wikiMonthlyStats = {};
monthCols.forEach(col => wikiMonthlyStats[col.key] = 0);
let projectUsers = Object.keys(results[db] || {}).sort();
for (const user of projectUsers) {
const userData = results[db][user];
const meta = await fetchUserData(user, db, START, END);
// Filter logic: Only show users active in period or holding rights
if (userData.total === 0 && !meta.hasLocalRightsInPeriod) continue;
const isStewardAction = meta.isStewardAction;
const isLocalCU = meta.role.includes("Local CheckUser");
// Apply UI role filters
if (currentUserFilter === 'local' && !isLocalCU) continue;
if (currentUserFilter === 'steward' && !isStewardAction) continue;
// Update totals
wikiTotal += userData.total;
monthCols.forEach(col => wikiMonthlyStats[col.key] += (userData.months[col.key] || 0));
// Calculate per 1k active users metric
let per1k = metrics.active > 0 ? ((userData.total / metrics.active) * 1000).toFixed(1) : "0.0";
// Construct wikitext row with dynamic timeline
let row = `|-\n| style="text-align:left;" | ${user}@${db} || <small>${meta.role}</small> || '''${userData.total}''' || ${per1k}`;
timeCols.forEach(col => {
let val = 0;
if (viewMode === 'months') val = userData.months[col.id] || 0;
else col.months.forEach(m => val += (userData.months[m] || 0));
row += ` || ${val}`;
});
projectRows.push({
html: row + "\n",
log: meta.log ? `'''${user}@${db}''':${meta.log}\n\n` : ""
});
// Construct CSV row
let baseRole = meta.role.split(' (')[0];
let sAssignCount = meta.isStewardAction ? meta.selfAssignCount : "";
let sMaxMins = meta.isStewardAction ? Math.round(meta.maxDurationMins) : "";
csvData += `${db},${user},"${baseRole}",${sAssignCount},${sMaxMins},${userData.total}\n`;
}
// Skip project if no users pass the filters
if (projectRows.length === 0) {
if (!emptyWikis.includes(db)) emptyWikis.push(db);
continue;
}
// Construct project summary row
let wikiPer1k = metrics.active > 0 ? ((wikiTotal / metrics.active) * 1000).toFixed(1) : "0.0";
let totalRow = `|- style="background:#f8f9fa; font-weight:bold;"\n| style="text-align:left;" | TOTAL ${db} || — || ${wikiTotal} || ${wikiPer1k}`;
timeCols.forEach(col => {
let val = 0;
if (viewMode === 'months') val = wikiMonthlyStats[col.id] || 0;
else col.months.forEach(m => val += (wikiMonthlyStats[m] || 0));
totalRow += ` || ${val}`;
});
wikitext += headerRow + totalRow + "\n";
projectRows.forEach(r => {
wikitext += r.html;
if (r.log) rightsLogText += r.log;
});
}
// Close table and append metadata sections
wikitext += `|}\n\n== Projects with 0 actions ==\n<div style="font-size:85%; color:#54595d;">${emptyWikis.sort().join(', ')}</div>\n`;
if (failedWikis.length > 0) {
wikitext += `\n== API Errors / Skipped Projects ==\n<div style="font-size:85%; color:#d33;">${failedWikis.sort().join(', ')}</div>\n`;
}
wikitext += rightsLogText + `\n\n<references />\n`;
// Output results to UI and finalize state
$('#out').val(wikitext);
$('#csv-out').val(csvData);
$('#output-containers').show();
$('#status-msg').text(`Done.`).css("color", "green");
$('#start').prop('disabled', false);
$('#stop').prop('disabled', true);
});
}
// Initialize the UI and finish script setup
setupUI();
});
})();
luhmeihh5c63pbhzsirnw9qrq9j5gu5
739278
739276
2026-04-24T18:29:49Z
MrJaroslavik
44012
+ enter
739278
javascript
text/javascript
// GlobalCheckUserStats.js
// -------------------------------------------------------
// Features:
// - Global Stats: Scans logs across 800+ projects at once.
// - Smart Roles: Identifies Local CheckUsers, Stewards, Staff, and Ombuds - and distinguishes between Former/Current roles.
// - Deep Scan: Bypasses API limits to get full history (limit 500 entries).
// - Stable: Retries 3x on rate limits so no wiki is missed.
// - Error Logs: Lists failed projects (ex. because of 429) at the end of the report.
// - UI: Integrated at [[meta:Special:BlankPage/GlobalCheckUserStats]].
// - Output: sortable Wikitables and detailed rights change logs and CSV file
// - With help of Gemini 3
// -------------------------------------------------------
(function() {
'use strict';
// Run only on Special:BlankPage/GlobalCheckUserStats
const isBlank = mw.config.get('wgCanonicalSpecialPageName') === 'Blankpage';
const isGCUS = mw.config.get('wgTitle').includes('GlobalCheckUserStats');
if (!isBlank || !isGCUS) {
return;
}
// Helper function to pause execution (useful for preventing API limits)
const DELAY_MS = 500;
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
// Makes API calls with automatic retries for "429 Too Many Requests" errors
async function robustCall(api, params) {
let retries = 0;
const maxRetries = 3;
while (retries < maxRetries) {
try {
// Attempt to fetch data from the MediaWiki API
return await api.get(params);
} catch (err) {
// Check if the server is actively rejecting requests due to high load (HTTP 429)
// Note: Optional chaining (?.) is avoided for broad MediaWiki editor compatibility
if (err && err.status === 429) {
retries++;
// Read the 'Retry-After' header provided by the server, or default to a scaling wait time
const waitTime = parseInt(err.xhr && err.xhr.getResponseHeader('Retry-After')) || (30 * retries);
// Notify the user via the UI that the script is pausing (supports both UI container types)
$('#status-msg, #cu-loader').html(
`<span style="color:orange; font-weight:bold;">Server is busy! Pausing for ${waitTime} seconds...</span>`
).show();
// Pause execution for the requested duration before trying again
await sleep(waitTime * 1000);
} else {
// If it is a different error (network failure, 500 Internal Error, etc.), stop and throw
throw err;
}
}
}
// Fail completely if max retries are exceeded
throw new Error("Failed to reach API after multiple attempts due to server limits.");
}
// Returns a standardized UTC timestamp for report headers (YYYY-MM-DD HH:MM)
function getReportTimestamp() {
return new Date().toISOString().replace('T', ' ').substring(0, 16) + ' (UTC)';
}
// Save data here so we don't have to download it twice
let userCache = {};
let historyCache = {};
// This map of all wikis will be filled automatically
let globalWikiMap = {};
// Process various API response formats into a clean array of group names
const extractGroups = function(data) {
if (!data) return [];
var res = [];
if (Array.isArray(data)) {
res = data;
} else if (typeof data === 'object') {
res = Object.values(data);
} else if (typeof data === 'string') {
res = data.split(',').map(function(s) {
return s.trim();
});
}
return res.map(function(g) {
if (typeof g !== 'string') return g;
return g.replace(/\s+/g, '').toLowerCase();
});
};
// List of wikis where CheckUser extension is disabled - https://noc.wikimedia.org/conf/highlight.php?file=dblists/checkuser-disabled.dblist
const disabledCUWikis = [
'aawikibooks', 'abwiktionary', 'advisorywiki', 'akwiktionary', 'angwikiquote',
'angwikisource', 'astwikibooks', 'astwikiquote', 'aswikibooks', 'aswiktionary',
'avwiktionary', 'aywikibooks', 'bhwiktionary', 'biwikibooks', 'biwiktionary',
'bmwikibooks', 'bmwikiquote', 'bmwiktionary', 'bowiktionary', 'chwikibooks',
'chwiktionary', 'cnwikimedia', 'crwikiquote', 'crwiktionary', 'dzwiktionary',
'gawikibooks', 'gnwikibooks', 'gotwikibooks', 'guwikibooks', 'htwikisource',
'huwikinews', 'hzwiki', 'iewikibooks', 'iiwiki', 'internalwiki', 'kjwiki',
'kkwikiquote', 'knwikibooks', 'krwiki', 'krwikiquote', 'kswikibooks',
'kswikiquote', 'kwwikiquote', 'lbwikibooks', 'lbwikiquote', 'lnwikibooks',
'lvwikibooks', 'mhwiktionary', 'mnwikibooks', 'muswiki', 'nahwikibooks',
'nawikibooks', 'nawikiquote', 'ndswikibooks', 'ndswikiquote', 'piwiktionary',
'pswikibooks', 'quwikibooks', 'quwikiquote', 'rmwikibooks', 'rmwiktionary',
'rnwiktionary', 'scwiktionary', 'searchcomwiki', 'snwiktionary', 'spcomwiki',
'swwikibooks', 'thwikinews', 'tkwikibooks', 'tkwikiquote', 'towiktionary',
'transitionteamwiki', 'trwikinews', 'ttwikiquote', 'twwiktionary',
'ugwikibooks', 'ugwikiquote', 'vowikibooks', 'vowikiquote', 'wawikibooks',
'wikimania2005wiki', 'wikimania2006wiki', 'wikimania2007wiki',
'wikimania2009wiki', 'wikimania2015wiki', 'wowikiquote', 'xhwikibooks',
'xhwiktionary', 'yowikibooks', 'yowiktionary', 'zawikibooks', 'zawikiquote',
'zawiktionary', 'zh_min_nanwikibooks', 'zuwikibooks',
// Closed/Unavailable projects (added manually)
'aawiktionary', 'akwikibooks', 'amwikiquote', 'angwikibooks', 'bgwikinews',
'bowikibooks', 'chowiki', 'cowikibooks', 'cowikiquote', 'gawikiquote',
'howiki', 'ikwiktionary', 'kywikibooks', 'mhwiki', 'miwikibooks',
'mywikibooks', 'nawiki', 'nzwikimedia', 'pa_uswikimedia', 'qualitywiki',
'ruwikimedia', 'sdwikinews', 'sewikibooks', 'simplewikibooks', 'strategywiki',
'suwikibooks', 'tenwiki', 'usabilitywiki', 'uzwikibooks', 'wikimania2010wiki',
'wikimania2011wiki', 'wikimania2012wiki', 'wikimania2013wiki', 'wikimania2014wiki',
'wikimania2016wiki', 'wikimania2017wiki', 'wikimania2018wiki', 'zh_min_nanwikiquote'
];
// Projects known to have local CheckUsers
const localCUWikis = [
'arwiki', 'bnwiki', 'cawiki', 'cswiki', 'dawiki', 'nlwiki', 'enwikibooks',
'enwiki', 'enwikivoyage', 'enwiktionary', 'fiwiki', 'frwiki', 'dewiki',
'hewiki', 'huwiki', 'idwiki', 'itwiki', 'jawiki', 'kowiki', 'fawiki',
'plwiki', 'ptwiki', 'ruwiki', 'srwiki', 'simplewiki', 'slwiki', 'eswiki',
'svwiki', 'thwiki', 'trwiki', 'ukwiki', 'viwiki', 'commonswiki',
'specieswiki', 'metawiki', 'wikidatawiki'
];
// Fetch all available wikis and filter out restricted ones
function loadGlobalWikiMap() {
const api = new mw.Api();
return api.get({
action: 'sitematrix',
format: 'json',
smtype: 'language|special',
smlangprop: 'site',
smsiteprop: 'dbname|url',
formatversion: 2,
origin: '*'
}).then(function(data) {
const matrix = data.sitematrix;
const wikiMap = {};
Object.keys(matrix).forEach(function(key) {
const entry = matrix[key];
if (entry.site && Array.isArray(entry.site)) {
entry.site.forEach(function(site) {
if (site.private || site.fishbowl || disabledCUWikis.indexOf(site.dbname) !== -1) return;
wikiMap[site.dbname] = site.url;
});
}
});
if (matrix.specials && Array.isArray(matrix.specials)) {
matrix.specials.forEach(function(special) {
if (special.private || special.fishbowl || disabledCUWikis.indexOf(special.dbname) !== -1) return;
wikiMap[special.dbname] = special.url;
});
}
return wikiMap;
});
}
// Convert database names to interwiki prefixes for table links
function getInterwikiPrefix(db) {
const specialMap = {
'commonswiki': 'c', 'metawiki': 'm', 'wikidatawiki': 'd',
'wikifunctionswiki': 'f', 'mediawikiwiki': 'mw', 'specieswiki': 'species',
'sourceswiki': 'oldwikisource', 'foundationwiki': 'foundation',
'incubatorwiki': 'incubator', 'outreachwiki': 'outreach',
'betawikiversity': 'betawikiversity', 'be_x_oldwiki': 'be-tarask',
'zh_classicalwiki': 'lzh', 'zh_min_nanwiki': 'nan', 'zh_yuewiki': 'yue'
};
if (specialMap[db]) return specialMap[db];
const name = db.replace(/_/g, '-');
if (name.endsWith('wikisource')) return 's:' + name.slice(0, -10);
if (name.endsWith('wikiversity')) return 'v:' + name.slice(0, -11);
if (name.endsWith('wiktionary')) return 'wikt:' + name.slice(0, -10);
if (name.endsWith('wikivoyage')) return 'voy:' + name.slice(0, -10);
if (name.endsWith('wikibooks')) return 'b:' + name.slice(0, -9);
if (name.endsWith('wikiquote')) return 'q:' + name.slice(0, -9);
if (name.endsWith('wikinews')) return 'n:' + name.slice(0, -8);
if (name.endsWith('wikimedia')) return 'wm' + name.slice(0, -9);
if (name.endsWith('wiki')) return 'w:' + name.slice(0, -4);
return 'w:' + name;
}
// Load required modules and start the script
mw.loader.using(['mediawiki.api', 'mediawiki.ForeignApi']).then(async function() {
// Display initial status message to the user
$('#mw-content-text').html('<strong>GlobalCheckUserStats.js: Loading wiki list...</strong>');
// Download and cache the list of all Wikimedia wikis
globalWikiMap = await loadGlobalWikiMap();
// Prepare the Meta-Wiki API and data containers
const metaApi = new mw.ForeignApi('https://meta.wikimedia.org/w/api.php');
const now = new Date();
// Variables for storing audit results and script state
let results = {};
let emptyWikis = [];
let failedWikis = [];
let scannedWikis = [];
let isRunning = false;
// Current UI filter settings
let currentFilterMode = 'all';
let currentUserFilter = 'all';
// Prepare the year and month selection menus
function setupUI() {
const currentYear = now.getFullYear();
// Generate options for years from 2005 to today
let yearOpts = '';
for (let y = currentYear; y >= 2005; y--) {
yearOpts += `<option value="${y}">${y}</option>`;
}
// Generate options for the month range (January to December)
let monthOptsFrom = '';
let monthOptsTo = '';
for (let m = 1; m <= 12; m++) {
monthOptsFrom += `<option value="${m}" ${m === 1 ? 'selected' : ''}>${m}</option>`;
monthOptsTo += `<option value="${m}" ${m === 12 ? 'selected' : ''}>${m}</option>`;
}
// Set the page title and build the HTML interface
$('#firstHeading').text('GlobalCheckUserStats.js');
$('#mw-content-text').empty().append(`
<div style="border:1px solid #a2a9b1; padding:15px; background:#f8f9fa;">
<h3 style="margin-top:0;">Stats Range</h3>
<div>
From: <select id="y-f" style="width:70px;">${yearOpts}</select>
<select id="m-f" style="width:50px;">${monthOptsFrom}</select>
To: <select id="y-t" style="width:70px;">${yearOpts}</select>
<select id="m-t" style="width:50px;">${monthOptsTo}</select>
</div>
<div style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>Wiki Filter:</strong>
<label style="cursor:pointer;"><input type="radio" name="wiki-mode" id="btn-all" checked> All</label>
<label style="cursor:pointer;"><input type="radio" name="wiki-mode" id="btn-localcu"> Local CU Wikis</label>
<label style="cursor:pointer;"><input type="radio" name="wiki-mode" id="btn-except"> All except</label>
<label style="cursor:pointer;"><input type="radio" name="wiki-mode" id="btn-only"> Only these</label>
<span id="wiki-help-trigger" style="cursor:help; background:#36c; color:#fff; border-radius:50%; width:18px; height:18px; display:inline-block; text-align:center; font-weight:bold;" title="Show available DB names">?</span>
</div>
<div id="filter-input-container" style="display:none; margin-top:10px;">
<input id="wiki-filter" type="text" style="width:100%;" placeholder="Example: enwiki, dewiki, commonswiki...">
</div>
<div style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>User Filter:</strong>
<label style="cursor:pointer;"><input type="radio" name="user-mode" id="u-all" checked> All</label>
<label style="cursor:pointer;"><input type="radio" name="user-mode" id="u-local"> Local CheckUsers</label>
<label style="cursor:pointer;"><input type="radio" name="user-mode" id="u-steward"> Steward actions</label>
</div>
<div style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>View mode (Wikitext):</strong>
<label style="cursor:pointer;"><input type="radio" name="cu-view-mode" value="total"> Total only</label>
<label style="cursor:pointer;"><input type="radio" name="cu-view-mode" value="months" checked> Months</label>
<label style="cursor:pointer;"><input type="radio" name="cu-view-mode" value="quarters"> Q1-Q4</label>
<label style="cursor:pointer;"><input type="radio" name="cu-view-mode" value="years"> Years</label>
</div>
<div id="wiki-list-help" style="display:none; margin-top:10px; padding:10px; background:#fff; border:1px solid #a2a9b1; font-size:11px; max-height:120px; overflow-y:auto; font-family:monospace;">
<strong>Available database names:</strong><br>${Object.keys(globalWikiMap).sort().join(', ')}
</div>
<div style="margin-top:20px;">
<button id="start" class="mw-ui-button mw-ui-progressive">Run</button>
<button id="stop" class="mw-ui-button mw-ui-destructive" disabled>Stop</button>
</div>
<div id="status-msg" style="margin-top:10px; font-weight:bold; color:#0056b3;">Ready.</div>
<div style="margin-top:5px;">
<progress id="bar" value="0" max="${Object.keys(globalWikiMap).length}" style="width:100%"></progress>
</div>
<div id="output-containers" style="display:none; margin-top:10px;">
<div style="margin-bottom:10px;">
<strong>Wikitext:</strong>
<textarea id="out" style="width:100%; height:250px; font-family:monospace; font-size:12px;"></textarea>
</div>
<div>
<strong>CSV:</strong>
<textarea id="csv-out" style="width:100%; height:150px; font-family:monospace; font-size:12px;"></textarea>
</div>
</div>
</div>
`);
// Listen for filter changes and button clicks
$('#btn-all').on('click', () => {
currentFilterMode = 'all';
$('#filter-input-container').hide();
});
$('#btn-localcu').on('click', () => {
currentFilterMode = 'localcu';
$('#filter-input-container').hide();
});
$('#btn-except').on('click', () => {
currentFilterMode = 'exclude';
$('#filter-input-container').show();
$('#wiki-filter').focus();
});
$('#btn-only').on('click', () => {
currentFilterMode = 'include';
$('#filter-input-container').show();
$('#wiki-filter').focus();
});
// User type filters
$('#u-all').on('click', () => {
currentUserFilter = 'all';
});
$('#u-local').on('click', () => {
currentUserFilter = 'local';
});
$('#u-steward').on('click', () => {
currentUserFilter = 'steward';
});
// Help and Execution controls
$('#wiki-help-trigger').on('click', () => $('#wiki-list-help').toggle());
// Enter
$('#wiki-filter').on('keypress', function(e) {
if (e.which === 13) { // 13 je kód klávesy Enter
e.preventDefault();
$('#start').click();
}
});
$('#start').on('click', () => {
runAudit(
$('#y-f').val(), $('#m-f').val(),
$('#y-t').val(), $('#m-t').val()
);
});
$('#stop').on('click', () => {
isRunning = false;
});
}
// Get the number of active users for a specific wiki
async function fetchWikiMetrics(db) {
try {
// Find the correct server URL for this database
const baseUrl = globalWikiMap[db];
const api = new mw.ForeignApi(baseUrl + '/w/api.php');
// Request site statistics from the API
const res = await robustCall(api, {
action: 'query',
meta: 'siteinfo',
siprop: 'statistics',
formatversion: 2
});
// Return the count of active users
return {
active: res.query.statistics.activeusers || 0
};
} catch (err) {
// If the wiki is unreachable, return zero as a fallback
return {
active: 0
};
}
}
// Checks if the user held global roles (Steward/Staff/Ombuds)
async function checkGlobalHistory(user, start, end) {
try {
const res = await robustCall(metaApi, {
action: 'query',
list: 'logevents',
letype: 'gblrights',
letitle: 'User:' + user,
lelimit: 'max',
formatversion: 2
});
const logs = res.query.logevents || [];
const auditStart = new Date(start);
const auditEnd = new Date(end);
let status = {
wasSteward: false,
wasStaff: false,
wasOmbuds: false
};
logs.forEach(log => {
const ts = new Date(log.timestamp);
const p = log.params || {};
// Handle both new (named) and old (indexed) API formats
const current = extractGroups(p.newGroups || p.add || p[1] || p["1"]);
const old = extractGroups(p.oldGroups || p.remove || p[2] || p["0"]);
if (ts >= auditStart && ts <= auditEnd) {
if (current.includes('steward') || old.includes('steward')) status.wasSteward = true;
if (current.includes('staff') || old.includes('staff')) status.wasStaff = true;
if (current.some(g => g.includes('ombud')) || old.some(g => g.includes('ombud'))) status.wasOmbuds = true;
}
});
// If no changes during the period, check the state immediately before it started
if (!status.wasSteward && !status.wasStaff && !status.wasOmbuds) {
const priorLogs = logs
.filter(log => new Date(log.timestamp) < auditStart)
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
if (priorLogs.length > 0) {
const p = priorLogs[0].params || {};
// Crucial: check both newGroups and legacy indexed params
const groupsBefore = extractGroups(p.newGroups || p.add || p[1] || p["1"]);
status.wasSteward = groupsBefore.includes('steward');
status.wasStaff = groupsBefore.includes('staff');
status.wasOmbuds = groupsBefore.some(g => g.includes('ombud'));
}
}
return status;
} catch (err) {
return {
wasSteward: false,
wasStaff: false,
wasOmbuds: false
};
}
}
// Gather user information and fetch their rights logs
async function fetchUserData(user, db, start, end) {
const auditStart = new Date(start);
const auditEnd = new Date(end);
// Get current global groups from Meta-Wiki if not already cached
if (!userCache[user]) {
try {
const globalRes = await robustCall(metaApi, {
action: 'query',
meta: 'globaluserinfo',
guiprop: 'groups',
guiuser: user,
formatversion: 2
});
userCache[user] = globalRes.query.globaluserinfo.groups || [];
} catch (err) {
userCache[user] = [];
}
}
// Canonical variables for current global status
const currentGlobalGroups = userCache[user];
const isGloballySteward = currentGlobalGroups.includes('steward');
const isGloballyStaff = currentGlobalGroups.includes('staff');
const isGloballyOmbuds = currentGlobalGroups.includes('ombuds') || currentGlobalGroups.includes('ombudsman');
// Check for global role history in cache or fetch new data
if (!historyCache[user]) {
historyCache[user] = await checkGlobalHistory(user, start, end);
}
const historyRes = historyCache[user];
let isCurrentLocal = false;
// Check current local CheckUser rights on the target wiki
try {
const wikiUrl = globalWikiMap[db];
const localApi = new mw.ForeignApi(wikiUrl + '/w/api.php');
// Request user group information from the local API
const localRes = await robustCall(localApi, {
action: 'query',
list: 'users',
ususers: user,
usprop: 'groups',
formatversion: 2
});
// Parse response and verify if 'checkuser' group is present
const localUserData = localRes.query.users[0];
const localGroups = (localUserData && localUserData.groups) || [];
isCurrentLocal = localGroups.includes('checkuser');
} catch (err) {
// Default to false if the local rights check fails
isCurrentLocal = false;
}
// Define the target string for CentralAuth rights logs
const target = 'User:' + user + '@' + db;
let logText = '',
isSelfAssign = false,
maxDurationMins = -1,
longestTimeStr = "",
assignCount = 0,
hasLocalRightsInPeriod = false;
// Retrieve all rights change logs from Meta-Wiki
let events = [],
continueToken = null,
finished = false;
while (!finished) {
let params = {
action: 'query',
list: 'logevents',
letype: 'rights',
letitle: target,
ledir: 'older',
lelimit: 'max',
formatversion: 2
};
// Handle API pagination with continue tokens
if (continueToken) Object.assign(params, continueToken);
// Execute the API call and merge the results
const res = await robustCall(metaApi, params);
const batch = res.query.logevents || [];
events = events.concat(batch);
// Check for more results to continue pagination
if (res.continue) {
continueToken = res.continue;
} else {
finished = true;
}
}
// Initialize state variables for log processing
let logEntries = [],
pendingRemoved = null,
lastPairedDate = null,
capturedExpiry = null;
// Iterate through collected events to process rights changes
for (let i = 0; i < events.length; i++) {
const e = events[i];
const p = e.params || {};
// Extract the performing user, stripping import prefixes if present
const actor = e.user && e.user.includes('>') ? e.user.split('>')[1] : e.user;
// Determine CheckUser status before and after the event using various param keys
const hadCU = extractGroups(p.oldgroups || p.oldGroups || p["0"]).includes('checkuser');
const hasCU = extractGroups(p.newgroups || p.newGroups || p["1"]).includes('checkuser');
// Extract expiry metadata if the user already has rights and they are being updated
if (hasCU && hadCU) {
const rawMeta = p.newmetadata || p.newMetadata;
if (rawMeta) {
const metaArray = Array.isArray(rawMeta) ? rawMeta : Object.values(rawMeta);
const cuMeta = metaArray.find(m => m && (m.group === 'checkuser' || m.group === 'check user'));
if (cuMeta && cuMeta.expiry && cuMeta.expiry !== 'infinity') capturedExpiry = new Date(cuMeta.expiry);
}
}
// Identify if CheckUser was added or removed in this event
let cuAdded = (hasCU && !hadCU),
cuRemoved = (hadCU && !hasCU);
if (cuAdded || cuRemoved) {
const eventDate = new Date(e.timestamp),
exactTime = e.timestamp.replace('T', ' ').replace('Z', '');
const isInPeriod = (eventDate >= auditStart && eventDate <= auditEnd);
if (isInPeriod) hasLocalRightsInPeriod = true;
// Process events relevant to the audit timeframe or pairing logic
if (isInPeriod || (eventDate > auditEnd) || (pendingRemoved && cuAdded)) {
let expiryDate = capturedExpiry;
capturedExpiry = null;
// Check for metadata again to ensure current expiry is captured
const rawMeta = p.newmetadata || p.newMetadata;
if (rawMeta) {
const metaArray = Array.isArray(rawMeta) ? rawMeta : Object.values(rawMeta);
const cuMeta = metaArray.find(m => m && (m.group === 'checkuser' || m.group === 'check user'));
if (cuMeta && cuMeta.expiry && cuMeta.expiry !== 'infinity') expiryDate = new Date(cuMeta.expiry);
}
const eventBySelf = (actor === user || !actor);
if (cuAdded) {
const removalInPeriod = pendingRemoved && (pendingRemoved.date >= auditStart && pendingRemoved.date <= auditEnd);
const expiryInPeriod = expiryDate && (expiryDate >= auditStart && expiryDate <= auditEnd);
// Pair adding event with its removal or expiry to calculate duration
if ((isInPeriod || removalInPeriod || expiryInPeriod) && (pendingRemoved || expiryDate)) {
const checkDate = pendingRemoved ? pendingRemoved.date : expiryDate;
const diffMs = Math.abs(checkDate - eventDate);
const totalSecs = Math.floor(diffMs / 1000);
// Intelligent rounding to handle short durations and edge cases
let roundedMins = Math.round(totalSecs / 60);
if (roundedMins === 0 && totalSecs > 0) roundedMins = 1;
// Convert total minutes into days, hours, and remaining minutes
let days = Math.floor(roundedMins / 1440);
let hours = Math.floor((roundedMins % 1440) / 60);
let mins = roundedMins % 60;
// Build a human-readable duration string
let dPart = days > 0 ? days + 'd ' : '';
let hPart = hours > 0 ? hours + 'h ' : '';
let mPart = (mins > 0 || (days === 0 && hours === 0)) ? mins + 'm' : '';
let durStr = totalSecs < 60 ? totalSecs + "s" : (dPart + hPart + mPart).trim();
// Capture the log comment for the ADDED event
let reason = e.comment ? ` <small>''(${e.comment})''</small>` : "";
// Determine if this session was a Steward self-assignment
if (pendingRemoved) {
if (eventBySelf && pendingRemoved.isSelf) isSelfAssign = true;
// Retrieve the removal comment if available
let remReason = pendingRemoved.comment ? ` <small>''(${pendingRemoved.comment})''</small>` : "";
// Calculate discrepancy between manual removal and scheduled expiry
let note = "";
if (expiryDate) {
// Calculate planned duration in seconds and format it
const pDiff = Math.abs(expiryDate - eventDate) / 1000;
let rP = Math.round(pDiff / 60);
let rd = Math.floor(rP / 1440),
rh = Math.floor((rP % 1440) / 60),
rm = rP % 60;
let pD = pDiff < 60 ? Math.round(pDiff) + "s" : `${rd > 0 ? rd + 'd ' : ''}${rh > 0 ? rh + 'h ' : ''}${(rm > 0 || (rd === 0 && rh === 0)) ? rm + 'm' : ''}`.trim();
// Check if rights were removed manually earlier or later than planned
let timeDiff = pendingRemoved.date - expiryDate;
if (timeDiff < -60000) {
note = ` - manual removal before scheduled expiry (set to ${pD})`;
} else if (timeDiff > 600000) {
note = ` - automatic removal not found (set to ${pD})`;
}
}
// Finalize the paired log entry for manual removals
logEntries.unshift(`* ADDED: ${exactTime} by ${actor}${reason} | REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${remReason} (Duration: ${durStr}${note})`);
} else if (expiryDate) {
// Handle automatic expiration scenarios
if (eventBySelf) isSelfAssign = true;
logEntries.unshift(`* ADDED: ${exactTime} by ${actor}${reason} | EXPIRED: ${expiryDate.toISOString().replace('T', ' ').substring(0, 19)} <small>''(Automatic)''</small> (Duration: ${durStr})`);
}
// Reset pending state and update audit metrics
pendingRemoved = null;
if (totalSecs / 60 > maxDurationMins) {
maxDurationMins = totalSecs / 60;
longestTimeStr = durStr.trim();
}
assignCount++;
lastPairedDate = eventDate;
} else if (isInPeriod && !pendingRemoved && !expiryDate) {
// Handle cases where rights are still active or removal log is missing
if (!(lastPairedDate && Math.abs(lastPairedDate - eventDate) < 86400000)) {
logEntries.unshift(`* ADDED: ${exactTime} by ${actor} | REMOVED: (Active/Not removed)`);
lastPairedDate = eventDate;
}
pendingRemoved = null;
}
} else if (cuRemoved) {
// Record standalone removal events for future pairing
if (pendingRemoved && pendingRemoved.date >= auditStart && pendingRemoved.date <= auditEnd) {
logEntries.unshift(`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}`);
}
// Store removal data to be paired with the next ADDED event
pendingRemoved = {
time: exactTime,
date: eventDate,
user: actor,
isSelf: eventBySelf,
comment: e.comment
};
}
}
}
}
// Verify if the user held rights at the start of the period by checking prior logs
if (!hasLocalRightsInPeriod && events.length > 0) {
const firstBefore = events.find(ev => new Date(ev.timestamp) < auditStart);
if (firstBefore) {
const groupsBefore = extractGroups(firstBefore.params.newgroups || firstBefore.params.add || firstBefore.params[1] || firstBefore.params["1"]);
if (groupsBefore.includes('checkuser')) hasLocalRightsInPeriod = true;
}
// If still not found, check the oldest available log entry for the initial state
if (!hasLocalRightsInPeriod) {
const chronologicallyFirst = events[events.length - 1];
const cp = chronologicallyFirst.params || {};
const oldGroups = extractGroups(cp.oldgroups || cp.oldGroups || cp.remove || cp[0] || cp["0"]);
if (oldGroups.includes('checkuser')) hasLocalRightsInPeriod = true;
}
}
// Finalize the log text assembly
if (pendingRemoved && pendingRemoved.date >= auditStart && pendingRemoved.date <= auditEnd) {
logEntries.unshift(`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}`);
}
if (logEntries.length) logText = '\n' + logEntries.join('\n');
// Logic for determining user roles and categories
let roles = [];
const isStewardInPeriod = isGloballySteward || historyRes.wasSteward;
const isTemporaryMission = (longestTimeStr && isSelfAssign && isStewardInPeriod);
// Process Global Staff and Ombudsman roles
if (isGloballyStaff) roles.push("Current Staff");
else if (historyRes.wasStaff) roles.push("Former Staff");
if (isGloballyOmbuds) roles.push("Current Ombudsman");
else if (historyRes.wasOmbuds) roles.push("Former Ombudsman");
// Steward logic: Distinguish between temporary actions and permanent roles
if (isTemporaryMission) {
const countLabel = assignCount > 1 ? `${assignCount}x, longest ` : "";
roles.push(`Steward action (Self-assign: ${countLabel}${longestTimeStr})`);
} else {
if (isGloballySteward) roles.push("Current Steward");
else if (historyRes.wasSteward) roles.push("Former Steward");
}
// Process Local CheckUser roles
if (isCurrentLocal) roles.push("Current Local CheckUser");
if (hasLocalRightsInPeriod && !isCurrentLocal && !isTemporaryMission) {
roles.push("Former Local CheckUser");
}
// Consolidate unique roles into a formatted label
let uniqueRoles = [...new Set(roles)];
let roleLabel = uniqueRoles.length > 0 ? uniqueRoles.join(' & ') : "Unknown role";
// Return final metadata object for project reporting
return {
role: roleLabel,
log: logText,
hasLocalRightsInPeriod: hasLocalRightsInPeriod,
isStewardAction: isTemporaryMission,
selfAssignCount: assignCount,
maxDurationMins: maxDurationMins
};
}
// Main execution loop with Cross-Tab concurrency protection
async function runAudit(yf, mf, yt, mt) {
$('#status-msg').text('Waiting for other tabs to finish...').css("color", "orange");
$('#start').prop('disabled', true);
// Utilize Web Locks API to prevent concurrent API requests from multiple tabs
await navigator.locks.request('global_cu_audit', async () => {
isRunning = true;
// Clear cache and result containers for a fresh scan
userCache = {};
historyCache = {};
results = {};
emptyWikis = [];
failedWikis = [];
scannedWikis = [];
$('#status-msg').text('Lock acquired. Initializing...').css("color", "#0056b3");
$('#stop').prop('disabled', false);
// Parse wiki filter input from the UI
const filterText = $('#wiki-filter').val().trim().toLowerCase();
const filterList = filterText ? filterText.split(',').map(s => s.trim()).filter(s => s !== "") : [];
// Determine the set of wikis to process based on user filters
const allAvailableWikis = Object.keys(globalWikiMap);
let wikisToScan = allAvailableWikis;
// Apply inclusion or exclusion logic based on current UI selection
if (currentFilterMode === 'include') {
wikisToScan = allAvailableWikis.filter(w => filterList.includes(w));
} else if (currentFilterMode === 'exclude') {
wikisToScan = allAvailableWikis.filter(w => !filterList.includes(w));
} else if (currentFilterMode === 'localcu') {
wikisToScan = allAvailableWikis.filter(w => localCUWikis.indexOf(w) !== -1);
}
// Validate that there are wikis to scan before proceeding
if (wikisToScan.length === 0) {
alert("No wikis selected for audit!");
isRunning = false;
$('#start').prop('disabled', false);
return;
}
// Initialize the progress bar UI
$('#bar').attr('max', wikisToScan.length).val(0);
// Define the standardized ISO timestamps for the MediaWiki API
const START = `${yf}-${String(mf).padStart(2, '0')}-01T00:00:00Z`;
const END = `${yt}-${String(mt).padStart(2, '0')}-${new Date(Date.UTC(yt, mt, 0)).getUTCDate().toString().padStart(2, '0')}T23:59:59Z`;
// Generate the chronological month columns for the report table
const monthCols = [];
let currY = parseInt(yf),
currM = parseInt(mf),
endY = parseInt(yt),
endM = parseInt(mt);
while (currY < endY || (currY === endY && currM <= endM)) {
monthCols.push({
key: `${currY}-${String(currM).padStart(2, '0')}`,
label: `${["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][currM - 1]} ${currY}`
});
currM++;
if (currM > 12) {
currM = 1;
currY++;
}
}
// Iterate through each wiki project to collect CheckUser activity
for (let i = 0; i < wikisToScan.length; i++) {
if (!isRunning) break; // Allow for manual stop
const db = wikisToScan[i];
scannedWikis.push(db);
$('#status-msg').text(`Scanning ${db} (${i + 1}/${wikisToScan.length})...`);
let successLocal = false,
continueToken = null;
// Phase 1: Retrieve CheckUser logs and aggregate monthly stats
while (!successLocal && isRunning) {
try {
const api = new mw.ForeignApi(globalWikiMap[db] + '/w/api.php');
let params = {
action: 'query',
list: 'checkuserlog',
culfrom: START,
culto: END,
culdir: 'newer',
cullimit: 'max',
formatversion: 2
};
if (continueToken) Object.assign(params, continueToken);
let res = await robustCall(api, params);
const entries = (res.query && res.query.checkuserlog && res.query.checkuserlog.entries) ? res.query.checkuserlog.entries : [];
if (entries.length) {
if (!results[db]) results[db] = {};
entries.forEach(e => {
const user = e.checkuser;
if (!results[db][user]) results[db][user] = {
total: 0,
months: {}
};
const mKey = e.timestamp.slice(0, 7);
results[db][user].total++;
results[db][user].months[mKey] = (results[db][user].months[mKey] || 0) + 1;
});
}
// Handle API pagination
if (res.continue && isRunning) {
continueToken = res.continue;
} else {
successLocal = true;
}
} catch (err) {
failedWikis.push(db);
successLocal = true;
}
}
// Phase 2: Capture current CheckUsers (to include users with 0 actions)
if (isRunning && !failedWikis.includes(db)) {
try {
const auApi = new mw.ForeignApi(globalWikiMap[db] + '/w/api.php');
const auRes = await robustCall(auApi, {
action: 'query',
list: 'allusers',
augroup: 'checkuser',
aulimit: 'max',
formatversion: 2
});
const currentCUs = auRes.query.allusers || [];
if (currentCUs.length > 0) {
if (!results[db]) results[db] = {};
currentCUs.forEach(u => {
if (!results[db][u.name]) results[db][u.name] = {
total: 0,
months: {}
};
});
}
} catch (e) {
/* Fallback for local API failures */
}
}
// Record wikis with no CheckUser presence found
if (!results[db] && !failedWikis.includes(db)) {
emptyWikis.push(db);
}
// Update UI progress
$('#bar').val(i + 1);
await sleep(DELAY_MS);
}
// Finalize results and compile the audit report
$('#status-msg').text(`Generating report...`);
// Calculate global activity totals for each user across all wikis
let userGlobalTotals = {};
Object.keys(results).forEach(db => {
Object.keys(results[db]).forEach(user => {
userGlobalTotals[user] = (userGlobalTotals[user] || 0) + results[db][user].total;
});
});
// Setup report timestamps
const reportDate = new Date().toISOString().split('T')[0];
const viewMode = $('input[name="cu-view-mode"]:checked').val();
const timeCols = [];
if (viewMode === 'months') {
monthCols.forEach(m => timeCols.push({
id: m.key,
label: m.label
}));
} else if (viewMode !== 'total') {
monthCols.forEach(m => {
let label;
if (viewMode === 'quarters') {
const q = Math.floor((parseInt(m.key.split('-')[1]) - 1) / 3) + 1;
label = `${m.key.split('-')[0]}-Q${q}`;
} else {
label = m.key.split('-')[0]; // Years
}
let col = timeCols.find(c => c.label === label);
if (!col) {
col = {
label: label,
months: []
};
timeCols.push(col);
}
col.months.push(m.key);
});
}
// Initialize Wikitext output with header information
let wikitext = `== Global CheckUser Stats (${mf}/${yf} - ${mt}/${yt}) ==\n`;
wikitext += `''Report generated on: ${getReportTimestamp()}<br />\nGenerated with [[testwiki:User:MrJaroslavik/GlobalCheckUserStats.js|GlobalCheckUserStats.js]]''\n`;
// Initialize CSV output with headers
let csvData = "Project,User,Role,SelfAssignCount,MaxDuration_mins,TotalActions\n";
// Document applied filters in the wikitext report
let filterSummary = [];
// Only add wiki filter if the list is not empty, or if localcu is selected
if (currentFilterMode === 'localcu') {
filterSummary.push(`Wikis with local CheckUsers`);
} else if (filterList.length > 0) {
if (currentFilterMode === 'include') {
filterSummary.push(`Only these wikis: ${filterList.join(', ')}`);
} else if (currentFilterMode === 'exclude') {
filterSummary.push(`All except wikis: ${filterList.join(', ')}`);
}
}
if (currentUserFilter === 'local') filterSummary.push(`Only users: Local CheckUsers`);
else if (currentUserFilter === 'steward') filterSummary.push(`Only users: Steward actions`);
if (filterSummary.length > 0) {
wikitext += `<br />\n''Used filters: ${filterSummary.join(' | ')}''\n`;
}
wikitext += `\n`;
// Start building the main results table
let rightsLogText = `\n== Rights Log (In Period) ==\n`;
const per1kExpl = "Calculated as: (Total Actions in selected period / Current Active Users) * 1,000";
wikitext += `{| class="wikitable sortable" style="font-size:90%; text-align:right;"\n! Wiki / User !! Category !! '''Total''' !! per 1k users<ref>${per1kExpl}</ref> ${timeCols.map(c => `!! ${c.label}`).join(' ')}\n`;
// Process each database in alphabetical order
const sortedDBs = Object.keys(results).sort();
for (const db of sortedDBs) {
const metrics = await fetchWikiMetrics(db);
const activeFormatted = metrics.active > 0 ? metrics.active.toLocaleString('en-US') : 'Unknown';
const interwiki = getInterwikiPrefix(db);
// Build wiki-specific header row
let headerRow = `|-\n! colspan="${4 + timeCols.length}" style="background:#eaecf0; text-align:center;" | [[${interwiki}:|${db}]] <small style="font-weight:normal; color:#54595d;">(Active Users as of ${reportDate}: ${activeFormatted})</small> — [[${interwiki}:Special:CheckUserLog|CheckUserLog]] · [[${interwiki}:Special:ListUsers/checkuser|ListUsers/checkuser]]\n`;
let projectRows = [],
wikiTotal = 0,
wikiMonthlyStats = {};
monthCols.forEach(col => wikiMonthlyStats[col.key] = 0);
let projectUsers = Object.keys(results[db] || {}).sort();
for (const user of projectUsers) {
const userData = results[db][user];
const meta = await fetchUserData(user, db, START, END);
// Filter logic: Only show users active in period or holding rights
if (userData.total === 0 && !meta.hasLocalRightsInPeriod) continue;
const isStewardAction = meta.isStewardAction;
const isLocalCU = meta.role.includes("Local CheckUser");
// Apply UI role filters
if (currentUserFilter === 'local' && !isLocalCU) continue;
if (currentUserFilter === 'steward' && !isStewardAction) continue;
// Update totals
wikiTotal += userData.total;
monthCols.forEach(col => wikiMonthlyStats[col.key] += (userData.months[col.key] || 0));
// Calculate per 1k active users metric
let per1k = metrics.active > 0 ? ((userData.total / metrics.active) * 1000).toFixed(1) : "0.0";
// Construct wikitext row with dynamic timeline
let row = `|-\n| style="text-align:left;" | ${user}@${db} || <small>${meta.role}</small> || '''${userData.total}''' || ${per1k}`;
timeCols.forEach(col => {
let val = 0;
if (viewMode === 'months') val = userData.months[col.id] || 0;
else col.months.forEach(m => val += (userData.months[m] || 0));
row += ` || ${val}`;
});
projectRows.push({
html: row + "\n",
log: meta.log ? `'''${user}@${db}''':${meta.log}\n\n` : ""
});
// Construct CSV row
let baseRole = meta.role.split(' (')[0];
let sAssignCount = meta.isStewardAction ? meta.selfAssignCount : "";
let sMaxMins = meta.isStewardAction ? Math.round(meta.maxDurationMins) : "";
csvData += `${db},${user},"${baseRole}",${sAssignCount},${sMaxMins},${userData.total}\n`;
}
// Skip project if no users pass the filters
if (projectRows.length === 0) {
if (!emptyWikis.includes(db)) emptyWikis.push(db);
continue;
}
// Construct project summary row
let wikiPer1k = metrics.active > 0 ? ((wikiTotal / metrics.active) * 1000).toFixed(1) : "0.0";
let totalRow = `|- style="background:#f8f9fa; font-weight:bold;"\n| style="text-align:left;" | TOTAL ${db} || — || ${wikiTotal} || ${wikiPer1k}`;
timeCols.forEach(col => {
let val = 0;
if (viewMode === 'months') val = wikiMonthlyStats[col.id] || 0;
else col.months.forEach(m => val += (wikiMonthlyStats[m] || 0));
totalRow += ` || ${val}`;
});
wikitext += headerRow + totalRow + "\n";
projectRows.forEach(r => {
wikitext += r.html;
if (r.log) rightsLogText += r.log;
});
}
// Close table and append metadata sections
wikitext += `|}\n\n== Projects with 0 actions ==\n<div style="font-size:85%; color:#54595d;">${emptyWikis.sort().join(', ')}</div>\n`;
if (failedWikis.length > 0) {
wikitext += `\n== API Errors / Skipped Projects ==\n<div style="font-size:85%; color:#d33;">${failedWikis.sort().join(', ')}</div>\n`;
}
wikitext += rightsLogText + `\n\n<references />\n`;
// Output results to UI and finalize state
$('#out').val(wikitext);
$('#csv-out').val(csvData);
$('#output-containers').show();
$('#status-msg').text(`Done.`).css("color", "green");
$('#start').prop('disabled', false);
$('#stop').prop('disabled', true);
});
}
// Initialize the UI and finish script setup
setupUI();
});
})();
li8h92pxde447p1d5gdnxc0lfa81h0p
739283
739278
2026-04-24T18:57:35Z
MrJaroslavik
44012
enter
739283
javascript
text/javascript
// GlobalCheckUserStats.js
// -------------------------------------------------------
// Features:
// - Global Stats: Scans logs across 800+ projects at once.
// - Smart Roles: Identifies Local CheckUsers, Stewards, Staff, and Ombuds - and distinguishes between Former/Current roles.
// - Deep Scan: Bypasses API limits to get full history (limit 500 entries).
// - Stable: Retries 3x on rate limits so no wiki is missed.
// - Error Logs: Lists failed projects (ex. because of 429) at the end of the report.
// - UI: Integrated at [[meta:Special:BlankPage/GlobalCheckUserStats]].
// - Output: sortable Wikitables and detailed rights change logs and CSV file
// - With help of Gemini 3
// -------------------------------------------------------
(function() {
'use strict';
// Run only on Special:BlankPage/GlobalCheckUserStats
const isBlank = mw.config.get('wgCanonicalSpecialPageName') === 'Blankpage';
const isGCUS = mw.config.get('wgTitle').includes('GlobalCheckUserStats');
if (!isBlank || !isGCUS) {
return;
}
// Helper function to pause execution (useful for preventing API limits)
const DELAY_MS = 500;
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
// Makes API calls with automatic retries for "429 Too Many Requests" errors
async function robustCall(api, params) {
let retries = 0;
const maxRetries = 3;
while (retries < maxRetries) {
try {
// Attempt to fetch data from the MediaWiki API
return await api.get(params);
} catch (err) {
// Check if the server is actively rejecting requests due to high load (HTTP 429)
// Note: Optional chaining (?.) is avoided for broad MediaWiki editor compatibility
if (err && err.status === 429) {
retries++;
// Read the 'Retry-After' header provided by the server, or default to a scaling wait time
const waitTime = parseInt(err.xhr && err.xhr.getResponseHeader('Retry-After')) || (30 * retries);
// Notify the user via the UI that the script is pausing (supports both UI container types)
$('#status-msg, #cu-loader').html(
`<span style="color:orange; font-weight:bold;">Server is busy! Pausing for ${waitTime} seconds...</span>`
).show();
// Pause execution for the requested duration before trying again
await sleep(waitTime * 1000);
} else {
// If it is a different error (network failure, 500 Internal Error, etc.), stop and throw
throw err;
}
}
}
// Fail completely if max retries are exceeded
throw new Error("Failed to reach API after multiple attempts due to server limits.");
}
// Returns a standardized UTC timestamp for report headers (YYYY-MM-DD HH:MM)
function getReportTimestamp() {
return new Date().toISOString().replace('T', ' ').substring(0, 16) + ' (UTC)';
}
// Save data here so we don't have to download it twice
let userCache = {};
let historyCache = {};
// This map of all wikis will be filled automatically
let globalWikiMap = {};
// Process various API response formats into a clean array of group names
const extractGroups = function(data) {
if (!data) return [];
var res = [];
if (Array.isArray(data)) {
res = data;
} else if (typeof data === 'object') {
res = Object.values(data);
} else if (typeof data === 'string') {
res = data.split(',').map(function(s) {
return s.trim();
});
}
return res.map(function(g) {
if (typeof g !== 'string') return g;
return g.replace(/\s+/g, '').toLowerCase();
});
};
// List of wikis where CheckUser extension is disabled - https://noc.wikimedia.org/conf/highlight.php?file=dblists/checkuser-disabled.dblist
const disabledCUWikis = [
'aawikibooks', 'abwiktionary', 'advisorywiki', 'akwiktionary', 'angwikiquote',
'angwikisource', 'astwikibooks', 'astwikiquote', 'aswikibooks', 'aswiktionary',
'avwiktionary', 'aywikibooks', 'bhwiktionary', 'biwikibooks', 'biwiktionary',
'bmwikibooks', 'bmwikiquote', 'bmwiktionary', 'bowiktionary', 'chwikibooks',
'chwiktionary', 'cnwikimedia', 'crwikiquote', 'crwiktionary', 'dzwiktionary',
'gawikibooks', 'gnwikibooks', 'gotwikibooks', 'guwikibooks', 'htwikisource',
'huwikinews', 'hzwiki', 'iewikibooks', 'iiwiki', 'internalwiki', 'kjwiki',
'kkwikiquote', 'knwikibooks', 'krwiki', 'krwikiquote', 'kswikibooks',
'kswikiquote', 'kwwikiquote', 'lbwikibooks', 'lbwikiquote', 'lnwikibooks',
'lvwikibooks', 'mhwiktionary', 'mnwikibooks', 'muswiki', 'nahwikibooks',
'nawikibooks', 'nawikiquote', 'ndswikibooks', 'ndswikiquote', 'piwiktionary',
'pswikibooks', 'quwikibooks', 'quwikiquote', 'rmwikibooks', 'rmwiktionary',
'rnwiktionary', 'scwiktionary', 'searchcomwiki', 'snwiktionary', 'spcomwiki',
'swwikibooks', 'thwikinews', 'tkwikibooks', 'tkwikiquote', 'towiktionary',
'transitionteamwiki', 'trwikinews', 'ttwikiquote', 'twwiktionary',
'ugwikibooks', 'ugwikiquote', 'vowikibooks', 'vowikiquote', 'wawikibooks',
'wikimania2005wiki', 'wikimania2006wiki', 'wikimania2007wiki',
'wikimania2009wiki', 'wikimania2015wiki', 'wowikiquote', 'xhwikibooks',
'xhwiktionary', 'yowikibooks', 'yowiktionary', 'zawikibooks', 'zawikiquote',
'zawiktionary', 'zh_min_nanwikibooks', 'zuwikibooks',
// Closed/Unavailable projects (added manually)
'aawiktionary', 'akwikibooks', 'amwikiquote', 'angwikibooks', 'bgwikinews',
'bowikibooks', 'chowiki', 'cowikibooks', 'cowikiquote', 'gawikiquote',
'howiki', 'ikwiktionary', 'kywikibooks', 'mhwiki', 'miwikibooks',
'mywikibooks', 'nawiki', 'nzwikimedia', 'pa_uswikimedia', 'qualitywiki',
'ruwikimedia', 'sdwikinews', 'sewikibooks', 'simplewikibooks', 'strategywiki',
'suwikibooks', 'tenwiki', 'usabilitywiki', 'uzwikibooks', 'wikimania2010wiki',
'wikimania2011wiki', 'wikimania2012wiki', 'wikimania2013wiki', 'wikimania2014wiki',
'wikimania2016wiki', 'wikimania2017wiki', 'wikimania2018wiki', 'zh_min_nanwikiquote'
];
// Projects known to have local CheckUsers
const localCUWikis = [
'arwiki', 'bnwiki', 'cawiki', 'cswiki', 'dawiki', 'nlwiki', 'enwikibooks',
'enwiki', 'enwikivoyage', 'enwiktionary', 'fiwiki', 'frwiki', 'dewiki',
'hewiki', 'huwiki', 'idwiki', 'itwiki', 'jawiki', 'kowiki', 'fawiki',
'plwiki', 'ptwiki', 'ruwiki', 'srwiki', 'simplewiki', 'slwiki', 'eswiki',
'svwiki', 'thwiki', 'trwiki', 'ukwiki', 'viwiki', 'commonswiki',
'specieswiki', 'metawiki', 'wikidatawiki'
];
// Fetch all available wikis and filter out restricted ones
function loadGlobalWikiMap() {
const api = new mw.Api();
return api.get({
action: 'sitematrix',
format: 'json',
smtype: 'language|special',
smlangprop: 'site',
smsiteprop: 'dbname|url',
formatversion: 2,
origin: '*'
}).then(function(data) {
const matrix = data.sitematrix;
const wikiMap = {};
Object.keys(matrix).forEach(function(key) {
const entry = matrix[key];
if (entry.site && Array.isArray(entry.site)) {
entry.site.forEach(function(site) {
if (site.private || site.fishbowl || disabledCUWikis.indexOf(site.dbname) !== -1) return;
wikiMap[site.dbname] = site.url;
});
}
});
if (matrix.specials && Array.isArray(matrix.specials)) {
matrix.specials.forEach(function(special) {
if (special.private || special.fishbowl || disabledCUWikis.indexOf(special.dbname) !== -1) return;
wikiMap[special.dbname] = special.url;
});
}
return wikiMap;
});
}
// Convert database names to interwiki prefixes for table links
function getInterwikiPrefix(db) {
const specialMap = {
'commonswiki': 'c', 'metawiki': 'm', 'wikidatawiki': 'd',
'wikifunctionswiki': 'f', 'mediawikiwiki': 'mw', 'specieswiki': 'species',
'sourceswiki': 'oldwikisource', 'foundationwiki': 'foundation',
'incubatorwiki': 'incubator', 'outreachwiki': 'outreach',
'betawikiversity': 'betawikiversity', 'be_x_oldwiki': 'be-tarask',
'zh_classicalwiki': 'lzh', 'zh_min_nanwiki': 'nan', 'zh_yuewiki': 'yue'
};
if (specialMap[db]) return specialMap[db];
const name = db.replace(/_/g, '-');
if (name.endsWith('wikisource')) return 's:' + name.slice(0, -10);
if (name.endsWith('wikiversity')) return 'v:' + name.slice(0, -11);
if (name.endsWith('wiktionary')) return 'wikt:' + name.slice(0, -10);
if (name.endsWith('wikivoyage')) return 'voy:' + name.slice(0, -10);
if (name.endsWith('wikibooks')) return 'b:' + name.slice(0, -9);
if (name.endsWith('wikiquote')) return 'q:' + name.slice(0, -9);
if (name.endsWith('wikinews')) return 'n:' + name.slice(0, -8);
if (name.endsWith('wikimedia')) return 'wm' + name.slice(0, -9);
if (name.endsWith('wiki')) return 'w:' + name.slice(0, -4);
return 'w:' + name;
}
// Load required modules and start the script
mw.loader.using(['mediawiki.api', 'mediawiki.ForeignApi']).then(async function() {
// Display initial status message to the user
$('#mw-content-text').html('<strong>GlobalCheckUserStats.js: Loading wiki list...</strong>');
// Download and cache the list of all Wikimedia wikis
globalWikiMap = await loadGlobalWikiMap();
// Prepare the Meta-Wiki API and data containers
const metaApi = new mw.ForeignApi('https://meta.wikimedia.org/w/api.php');
const now = new Date();
// Variables for storing audit results and script state
let results = {};
let emptyWikis = [];
let failedWikis = [];
let scannedWikis = [];
let isRunning = false;
// Current UI filter settings
let currentFilterMode = 'all';
let currentUserFilter = 'all';
// Prepare the year and month selection menus
function setupUI() {
const currentYear = now.getFullYear();
// Generate options for years from 2005 to today
let yearOpts = '';
for (let y = currentYear; y >= 2005; y--) {
yearOpts += `<option value="${y}">${y}</option>`;
}
// Generate options for the month range (January to December)
let monthOptsFrom = '';
let monthOptsTo = '';
for (let m = 1; m <= 12; m++) {
monthOptsFrom += `<option value="${m}" ${m === 1 ? 'selected' : ''}>${m}</option>`;
monthOptsTo += `<option value="${m}" ${m === 12 ? 'selected' : ''}>${m}</option>`;
}
// Set the page title and build the HTML interface
$('#firstHeading').text('GlobalCheckUserStats.js');
$('#mw-content-text').empty().append(`
<div style="border:1px solid #a2a9b1; padding:15px; background:#f8f9fa;">
<h3 style="margin-top:0;">Stats Range</h3>
<div>
From: <select id="y-f" style="width:70px;">${yearOpts}</select>
<select id="m-f" style="width:50px;">${monthOptsFrom}</select>
To: <select id="y-t" style="width:70px;">${yearOpts}</select>
<select id="m-t" style="width:50px;">${monthOptsTo}</select>
</div>
<div style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>Wiki Filter:</strong>
<label style="cursor:pointer;"><input type="radio" name="wiki-mode" id="btn-all" checked> All</label>
<label style="cursor:pointer;"><input type="radio" name="wiki-mode" id="btn-localcu"> Local CU Wikis</label>
<label style="cursor:pointer;"><input type="radio" name="wiki-mode" id="btn-except"> All except</label>
<label style="cursor:pointer;"><input type="radio" name="wiki-mode" id="btn-only"> Only these</label>
<span id="wiki-help-trigger" style="cursor:help; background:#36c; color:#fff; border-radius:50%; width:18px; height:18px; display:inline-block; text-align:center; font-weight:bold;" title="Show available DB names">?</span>
</div>
<div id="filter-input-container" style="display:none; margin-top:10px;">
<input id="wiki-filter" type="text" style="width:100%;" placeholder="Example: enwiki, dewiki, commonswiki...">
</div>
<div style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>User Filter:</strong>
<label style="cursor:pointer;"><input type="radio" name="user-mode" id="u-all" checked> All</label>
<label style="cursor:pointer;"><input type="radio" name="user-mode" id="u-local"> Local CheckUsers</label>
<label style="cursor:pointer;"><input type="radio" name="user-mode" id="u-steward"> Steward actions</label>
</div>
<div style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>View mode (Wikitext):</strong>
<label style="cursor:pointer;"><input type="radio" name="cu-view-mode" value="total"> Total only</label>
<label style="cursor:pointer;"><input type="radio" name="cu-view-mode" value="months" checked> Months</label>
<label style="cursor:pointer;"><input type="radio" name="cu-view-mode" value="quarters"> Q1-Q4</label>
<label style="cursor:pointer;"><input type="radio" name="cu-view-mode" value="years"> Years</label>
</div>
<div id="wiki-list-help" style="display:none; margin-top:10px; padding:10px; background:#fff; border:1px solid #a2a9b1; font-size:11px; max-height:120px; overflow-y:auto; font-family:monospace;">
<strong>Available database names:</strong><br>${Object.keys(globalWikiMap).sort().join(', ')}
</div>
<div style="margin-top:20px;">
<button id="start" class="mw-ui-button mw-ui-progressive">Run</button>
<button id="stop" class="mw-ui-button mw-ui-destructive" disabled>Stop</button>
</div>
<div id="status-msg" style="margin-top:10px; font-weight:bold; color:#0056b3;">Ready.</div>
<div style="margin-top:5px;">
<progress id="bar" value="0" max="${Object.keys(globalWikiMap).length}" style="width:100%"></progress>
</div>
<div id="output-containers" style="display:none; margin-top:10px;">
<div style="margin-bottom:10px;">
<strong>Wikitext:</strong>
<textarea id="out" style="width:100%; height:250px; font-family:monospace; font-size:12px;"></textarea>
</div>
<div>
<strong>CSV:</strong>
<textarea id="csv-out" style="width:100%; height:150px; font-family:monospace; font-size:12px;"></textarea>
</div>
</div>
</div>
`);
// Listen for filter changes and button clicks
$('#btn-all').on('click', () => {
currentFilterMode = 'all';
$('#filter-input-container').hide();
});
$('#btn-localcu').on('click', () => {
currentFilterMode = 'localcu';
$('#filter-input-container').hide();
});
$('#btn-except').on('click', () => {
currentFilterMode = 'exclude';
$('#filter-input-container').show();
$('#wiki-filter').focus();
});
$('#btn-only').on('click', () => {
currentFilterMode = 'include';
$('#filter-input-container').show();
$('#wiki-filter').focus();
});
// User type filters
$('#u-all').on('click', () => {
currentUserFilter = 'all';
});
$('#u-local').on('click', () => {
currentUserFilter = 'local';
});
$('#u-steward').on('click', () => {
currentUserFilter = 'steward';
});
// Help and Execution controls
$('#wiki-help-trigger').on('click', () => $('#wiki-list-help').toggle());
// Enter
$('#wiki-filter').on('keypress', function(e) {
if (e.which === 13) { // 13 je kód klávesy Enter
e.preventDefault();
$('#start').click();
}
});
$('#start').on('click', () => {
runAudit(
$('#y-f').val(), $('#m-f').val(),
$('#y-t').val(), $('#m-t').val()
);
});
// Enter
$(document).on('keypress', '#wiki-filter', function(e) {
if (e.which === 13) {
e.preventDefault();
$('#start').click();
}
});
$('#stop').on('click', () => {
isRunning = false;
});
}
// Get the number of active users for a specific wiki
async function fetchWikiMetrics(db) {
try {
// Find the correct server URL for this database
const baseUrl = globalWikiMap[db];
const api = new mw.ForeignApi(baseUrl + '/w/api.php');
// Request site statistics from the API
const res = await robustCall(api, {
action: 'query',
meta: 'siteinfo',
siprop: 'statistics',
formatversion: 2
});
// Return the count of active users
return {
active: res.query.statistics.activeusers || 0
};
} catch (err) {
// If the wiki is unreachable, return zero as a fallback
return {
active: 0
};
}
}
// Checks if the user held global roles (Steward/Staff/Ombuds)
async function checkGlobalHistory(user, start, end) {
try {
const res = await robustCall(metaApi, {
action: 'query',
list: 'logevents',
letype: 'gblrights',
letitle: 'User:' + user,
lelimit: 'max',
formatversion: 2
});
const logs = res.query.logevents || [];
const auditStart = new Date(start);
const auditEnd = new Date(end);
let status = {
wasSteward: false,
wasStaff: false,
wasOmbuds: false
};
logs.forEach(log => {
const ts = new Date(log.timestamp);
const p = log.params || {};
// Handle both new (named) and old (indexed) API formats
const current = extractGroups(p.newGroups || p.add || p[1] || p["1"]);
const old = extractGroups(p.oldGroups || p.remove || p[2] || p["0"]);
if (ts >= auditStart && ts <= auditEnd) {
if (current.includes('steward') || old.includes('steward')) status.wasSteward = true;
if (current.includes('staff') || old.includes('staff')) status.wasStaff = true;
if (current.some(g => g.includes('ombud')) || old.some(g => g.includes('ombud'))) status.wasOmbuds = true;
}
});
// If no changes during the period, check the state immediately before it started
if (!status.wasSteward && !status.wasStaff && !status.wasOmbuds) {
const priorLogs = logs
.filter(log => new Date(log.timestamp) < auditStart)
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
if (priorLogs.length > 0) {
const p = priorLogs[0].params || {};
// Crucial: check both newGroups and legacy indexed params
const groupsBefore = extractGroups(p.newGroups || p.add || p[1] || p["1"]);
status.wasSteward = groupsBefore.includes('steward');
status.wasStaff = groupsBefore.includes('staff');
status.wasOmbuds = groupsBefore.some(g => g.includes('ombud'));
}
}
return status;
} catch (err) {
return {
wasSteward: false,
wasStaff: false,
wasOmbuds: false
};
}
}
// Gather user information and fetch their rights logs
async function fetchUserData(user, db, start, end) {
const auditStart = new Date(start);
const auditEnd = new Date(end);
// Get current global groups from Meta-Wiki if not already cached
if (!userCache[user]) {
try {
const globalRes = await robustCall(metaApi, {
action: 'query',
meta: 'globaluserinfo',
guiprop: 'groups',
guiuser: user,
formatversion: 2
});
userCache[user] = globalRes.query.globaluserinfo.groups || [];
} catch (err) {
userCache[user] = [];
}
}
// Canonical variables for current global status
const currentGlobalGroups = userCache[user];
const isGloballySteward = currentGlobalGroups.includes('steward');
const isGloballyStaff = currentGlobalGroups.includes('staff');
const isGloballyOmbuds = currentGlobalGroups.includes('ombuds') || currentGlobalGroups.includes('ombudsman');
// Check for global role history in cache or fetch new data
if (!historyCache[user]) {
historyCache[user] = await checkGlobalHistory(user, start, end);
}
const historyRes = historyCache[user];
let isCurrentLocal = false;
// Check current local CheckUser rights on the target wiki
try {
const wikiUrl = globalWikiMap[db];
const localApi = new mw.ForeignApi(wikiUrl + '/w/api.php');
// Request user group information from the local API
const localRes = await robustCall(localApi, {
action: 'query',
list: 'users',
ususers: user,
usprop: 'groups',
formatversion: 2
});
// Parse response and verify if 'checkuser' group is present
const localUserData = localRes.query.users[0];
const localGroups = (localUserData && localUserData.groups) || [];
isCurrentLocal = localGroups.includes('checkuser');
} catch (err) {
// Default to false if the local rights check fails
isCurrentLocal = false;
}
// Define the target string for CentralAuth rights logs
const target = 'User:' + user + '@' + db;
let logText = '',
isSelfAssign = false,
maxDurationMins = -1,
longestTimeStr = "",
assignCount = 0,
hasLocalRightsInPeriod = false;
// Retrieve all rights change logs from Meta-Wiki
let events = [],
continueToken = null,
finished = false;
while (!finished) {
let params = {
action: 'query',
list: 'logevents',
letype: 'rights',
letitle: target,
ledir: 'older',
lelimit: 'max',
formatversion: 2
};
// Handle API pagination with continue tokens
if (continueToken) Object.assign(params, continueToken);
// Execute the API call and merge the results
const res = await robustCall(metaApi, params);
const batch = res.query.logevents || [];
events = events.concat(batch);
// Check for more results to continue pagination
if (res.continue) {
continueToken = res.continue;
} else {
finished = true;
}
}
// Initialize state variables for log processing
let logEntries = [],
pendingRemoved = null,
lastPairedDate = null,
capturedExpiry = null;
// Iterate through collected events to process rights changes
for (let i = 0; i < events.length; i++) {
const e = events[i];
const p = e.params || {};
// Extract the performing user, stripping import prefixes if present
const actor = e.user && e.user.includes('>') ? e.user.split('>')[1] : e.user;
// Determine CheckUser status before and after the event using various param keys
const hadCU = extractGroups(p.oldgroups || p.oldGroups || p["0"]).includes('checkuser');
const hasCU = extractGroups(p.newgroups || p.newGroups || p["1"]).includes('checkuser');
// Extract expiry metadata if the user already has rights and they are being updated
if (hasCU && hadCU) {
const rawMeta = p.newmetadata || p.newMetadata;
if (rawMeta) {
const metaArray = Array.isArray(rawMeta) ? rawMeta : Object.values(rawMeta);
const cuMeta = metaArray.find(m => m && (m.group === 'checkuser' || m.group === 'check user'));
if (cuMeta && cuMeta.expiry && cuMeta.expiry !== 'infinity') capturedExpiry = new Date(cuMeta.expiry);
}
}
// Identify if CheckUser was added or removed in this event
let cuAdded = (hasCU && !hadCU),
cuRemoved = (hadCU && !hasCU);
if (cuAdded || cuRemoved) {
const eventDate = new Date(e.timestamp),
exactTime = e.timestamp.replace('T', ' ').replace('Z', '');
const isInPeriod = (eventDate >= auditStart && eventDate <= auditEnd);
if (isInPeriod) hasLocalRightsInPeriod = true;
// Process events relevant to the audit timeframe or pairing logic
if (isInPeriod || (eventDate > auditEnd) || (pendingRemoved && cuAdded)) {
let expiryDate = capturedExpiry;
capturedExpiry = null;
// Check for metadata again to ensure current expiry is captured
const rawMeta = p.newmetadata || p.newMetadata;
if (rawMeta) {
const metaArray = Array.isArray(rawMeta) ? rawMeta : Object.values(rawMeta);
const cuMeta = metaArray.find(m => m && (m.group === 'checkuser' || m.group === 'check user'));
if (cuMeta && cuMeta.expiry && cuMeta.expiry !== 'infinity') expiryDate = new Date(cuMeta.expiry);
}
const eventBySelf = (actor === user || !actor);
if (cuAdded) {
const removalInPeriod = pendingRemoved && (pendingRemoved.date >= auditStart && pendingRemoved.date <= auditEnd);
const expiryInPeriod = expiryDate && (expiryDate >= auditStart && expiryDate <= auditEnd);
// Pair adding event with its removal or expiry to calculate duration
if ((isInPeriod || removalInPeriod || expiryInPeriod) && (pendingRemoved || expiryDate)) {
const checkDate = pendingRemoved ? pendingRemoved.date : expiryDate;
const diffMs = Math.abs(checkDate - eventDate);
const totalSecs = Math.floor(diffMs / 1000);
// Intelligent rounding to handle short durations and edge cases
let roundedMins = Math.round(totalSecs / 60);
if (roundedMins === 0 && totalSecs > 0) roundedMins = 1;
// Convert total minutes into days, hours, and remaining minutes
let days = Math.floor(roundedMins / 1440);
let hours = Math.floor((roundedMins % 1440) / 60);
let mins = roundedMins % 60;
// Build a human-readable duration string
let dPart = days > 0 ? days + 'd ' : '';
let hPart = hours > 0 ? hours + 'h ' : '';
let mPart = (mins > 0 || (days === 0 && hours === 0)) ? mins + 'm' : '';
let durStr = totalSecs < 60 ? totalSecs + "s" : (dPart + hPart + mPart).trim();
// Capture the log comment for the ADDED event
let reason = e.comment ? ` <small>''(${e.comment})''</small>` : "";
// Determine if this session was a Steward self-assignment
if (pendingRemoved) {
if (eventBySelf && pendingRemoved.isSelf) isSelfAssign = true;
// Retrieve the removal comment if available
let remReason = pendingRemoved.comment ? ` <small>''(${pendingRemoved.comment})''</small>` : "";
// Calculate discrepancy between manual removal and scheduled expiry
let note = "";
if (expiryDate) {
// Calculate planned duration in seconds and format it
const pDiff = Math.abs(expiryDate - eventDate) / 1000;
let rP = Math.round(pDiff / 60);
let rd = Math.floor(rP / 1440),
rh = Math.floor((rP % 1440) / 60),
rm = rP % 60;
let pD = pDiff < 60 ? Math.round(pDiff) + "s" : `${rd > 0 ? rd + 'd ' : ''}${rh > 0 ? rh + 'h ' : ''}${(rm > 0 || (rd === 0 && rh === 0)) ? rm + 'm' : ''}`.trim();
// Check if rights were removed manually earlier or later than planned
let timeDiff = pendingRemoved.date - expiryDate;
if (timeDiff < -60000) {
note = ` - manual removal before scheduled expiry (set to ${pD})`;
} else if (timeDiff > 600000) {
note = ` - automatic removal not found (set to ${pD})`;
}
}
// Finalize the paired log entry for manual removals
logEntries.unshift(`* ADDED: ${exactTime} by ${actor}${reason} | REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${remReason} (Duration: ${durStr}${note})`);
} else if (expiryDate) {
// Handle automatic expiration scenarios
if (eventBySelf) isSelfAssign = true;
logEntries.unshift(`* ADDED: ${exactTime} by ${actor}${reason} | EXPIRED: ${expiryDate.toISOString().replace('T', ' ').substring(0, 19)} <small>''(Automatic)''</small> (Duration: ${durStr})`);
}
// Reset pending state and update audit metrics
pendingRemoved = null;
if (totalSecs / 60 > maxDurationMins) {
maxDurationMins = totalSecs / 60;
longestTimeStr = durStr.trim();
}
assignCount++;
lastPairedDate = eventDate;
} else if (isInPeriod && !pendingRemoved && !expiryDate) {
// Handle cases where rights are still active or removal log is missing
if (!(lastPairedDate && Math.abs(lastPairedDate - eventDate) < 86400000)) {
logEntries.unshift(`* ADDED: ${exactTime} by ${actor} | REMOVED: (Active/Not removed)`);
lastPairedDate = eventDate;
}
pendingRemoved = null;
}
} else if (cuRemoved) {
// Record standalone removal events for future pairing
if (pendingRemoved && pendingRemoved.date >= auditStart && pendingRemoved.date <= auditEnd) {
logEntries.unshift(`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}`);
}
// Store removal data to be paired with the next ADDED event
pendingRemoved = {
time: exactTime,
date: eventDate,
user: actor,
isSelf: eventBySelf,
comment: e.comment
};
}
}
}
}
// Verify if the user held rights at the start of the period by checking prior logs
if (!hasLocalRightsInPeriod && events.length > 0) {
const firstBefore = events.find(ev => new Date(ev.timestamp) < auditStart);
if (firstBefore) {
const groupsBefore = extractGroups(firstBefore.params.newgroups || firstBefore.params.add || firstBefore.params[1] || firstBefore.params["1"]);
if (groupsBefore.includes('checkuser')) hasLocalRightsInPeriod = true;
}
// If still not found, check the oldest available log entry for the initial state
if (!hasLocalRightsInPeriod) {
const chronologicallyFirst = events[events.length - 1];
const cp = chronologicallyFirst.params || {};
const oldGroups = extractGroups(cp.oldgroups || cp.oldGroups || cp.remove || cp[0] || cp["0"]);
if (oldGroups.includes('checkuser')) hasLocalRightsInPeriod = true;
}
}
// Finalize the log text assembly
if (pendingRemoved && pendingRemoved.date >= auditStart && pendingRemoved.date <= auditEnd) {
logEntries.unshift(`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}`);
}
if (logEntries.length) logText = '\n' + logEntries.join('\n');
// Logic for determining user roles and categories
let roles = [];
const isStewardInPeriod = isGloballySteward || historyRes.wasSteward;
const isTemporaryMission = (longestTimeStr && isSelfAssign && isStewardInPeriod);
// Process Global Staff and Ombudsman roles
if (isGloballyStaff) roles.push("Current Staff");
else if (historyRes.wasStaff) roles.push("Former Staff");
if (isGloballyOmbuds) roles.push("Current Ombudsman");
else if (historyRes.wasOmbuds) roles.push("Former Ombudsman");
// Steward logic: Distinguish between temporary actions and permanent roles
if (isTemporaryMission) {
const countLabel = assignCount > 1 ? `${assignCount}x, longest ` : "";
roles.push(`Steward action (Self-assign: ${countLabel}${longestTimeStr})`);
} else {
if (isGloballySteward) roles.push("Current Steward");
else if (historyRes.wasSteward) roles.push("Former Steward");
}
// Process Local CheckUser roles
if (isCurrentLocal) roles.push("Current Local CheckUser");
if (hasLocalRightsInPeriod && !isCurrentLocal && !isTemporaryMission) {
roles.push("Former Local CheckUser");
}
// Consolidate unique roles into a formatted label
let uniqueRoles = [...new Set(roles)];
let roleLabel = uniqueRoles.length > 0 ? uniqueRoles.join(' & ') : "Unknown role";
// Return final metadata object for project reporting
return {
role: roleLabel,
log: logText,
hasLocalRightsInPeriod: hasLocalRightsInPeriod,
isStewardAction: isTemporaryMission,
selfAssignCount: assignCount,
maxDurationMins: maxDurationMins
};
}
// Main execution loop with Cross-Tab concurrency protection
async function runAudit(yf, mf, yt, mt) {
$('#status-msg').text('Waiting for other tabs to finish...').css("color", "orange");
$('#start').prop('disabled', true);
// Utilize Web Locks API to prevent concurrent API requests from multiple tabs
await navigator.locks.request('global_cu_audit', async () => {
isRunning = true;
// Clear cache and result containers for a fresh scan
userCache = {};
historyCache = {};
results = {};
emptyWikis = [];
failedWikis = [];
scannedWikis = [];
$('#status-msg').text('Lock acquired. Initializing...').css("color", "#0056b3");
$('#stop').prop('disabled', false);
// Parse wiki filter input from the UI
const filterText = $('#wiki-filter').val().trim().toLowerCase();
const filterList = filterText ? filterText.split(',').map(s => s.trim()).filter(s => s !== "") : [];
// Determine the set of wikis to process based on user filters
const allAvailableWikis = Object.keys(globalWikiMap);
let wikisToScan = allAvailableWikis;
// Apply inclusion or exclusion logic based on current UI selection
if (currentFilterMode === 'include') {
wikisToScan = allAvailableWikis.filter(w => filterList.includes(w));
} else if (currentFilterMode === 'exclude') {
wikisToScan = allAvailableWikis.filter(w => !filterList.includes(w));
} else if (currentFilterMode === 'localcu') {
wikisToScan = allAvailableWikis.filter(w => localCUWikis.indexOf(w) !== -1);
}
// Validate that there are wikis to scan before proceeding
if (wikisToScan.length === 0) {
alert("No wikis selected for audit!");
isRunning = false;
$('#start').prop('disabled', false);
return;
}
// Initialize the progress bar UI
$('#bar').attr('max', wikisToScan.length).val(0);
// Define the standardized ISO timestamps for the MediaWiki API
const START = `${yf}-${String(mf).padStart(2, '0')}-01T00:00:00Z`;
const END = `${yt}-${String(mt).padStart(2, '0')}-${new Date(Date.UTC(yt, mt, 0)).getUTCDate().toString().padStart(2, '0')}T23:59:59Z`;
// Generate the chronological month columns for the report table
const monthCols = [];
let currY = parseInt(yf),
currM = parseInt(mf),
endY = parseInt(yt),
endM = parseInt(mt);
while (currY < endY || (currY === endY && currM <= endM)) {
monthCols.push({
key: `${currY}-${String(currM).padStart(2, '0')}`,
label: `${["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][currM - 1]} ${currY}`
});
currM++;
if (currM > 12) {
currM = 1;
currY++;
}
}
// Iterate through each wiki project to collect CheckUser activity
for (let i = 0; i < wikisToScan.length; i++) {
if (!isRunning) break; // Allow for manual stop
const db = wikisToScan[i];
scannedWikis.push(db);
$('#status-msg').text(`Scanning ${db} (${i + 1}/${wikisToScan.length})...`);
let successLocal = false,
continueToken = null;
// Phase 1: Retrieve CheckUser logs and aggregate monthly stats
while (!successLocal && isRunning) {
try {
const api = new mw.ForeignApi(globalWikiMap[db] + '/w/api.php');
let params = {
action: 'query',
list: 'checkuserlog',
culfrom: START,
culto: END,
culdir: 'newer',
cullimit: 'max',
formatversion: 2
};
if (continueToken) Object.assign(params, continueToken);
let res = await robustCall(api, params);
const entries = (res.query && res.query.checkuserlog && res.query.checkuserlog.entries) ? res.query.checkuserlog.entries : [];
if (entries.length) {
if (!results[db]) results[db] = {};
entries.forEach(e => {
const user = e.checkuser;
if (!results[db][user]) results[db][user] = {
total: 0,
months: {}
};
const mKey = e.timestamp.slice(0, 7);
results[db][user].total++;
results[db][user].months[mKey] = (results[db][user].months[mKey] || 0) + 1;
});
}
// Handle API pagination
if (res.continue && isRunning) {
continueToken = res.continue;
} else {
successLocal = true;
}
} catch (err) {
failedWikis.push(db);
successLocal = true;
}
}
// Phase 2: Capture current CheckUsers (to include users with 0 actions)
if (isRunning && !failedWikis.includes(db)) {
try {
const auApi = new mw.ForeignApi(globalWikiMap[db] + '/w/api.php');
const auRes = await robustCall(auApi, {
action: 'query',
list: 'allusers',
augroup: 'checkuser',
aulimit: 'max',
formatversion: 2
});
const currentCUs = auRes.query.allusers || [];
if (currentCUs.length > 0) {
if (!results[db]) results[db] = {};
currentCUs.forEach(u => {
if (!results[db][u.name]) results[db][u.name] = {
total: 0,
months: {}
};
});
}
} catch (e) {
/* Fallback for local API failures */
}
}
// Record wikis with no CheckUser presence found
if (!results[db] && !failedWikis.includes(db)) {
emptyWikis.push(db);
}
// Update UI progress
$('#bar').val(i + 1);
await sleep(DELAY_MS);
}
// Finalize results and compile the audit report
$('#status-msg').text(`Generating report...`);
// Calculate global activity totals for each user across all wikis
let userGlobalTotals = {};
Object.keys(results).forEach(db => {
Object.keys(results[db]).forEach(user => {
userGlobalTotals[user] = (userGlobalTotals[user] || 0) + results[db][user].total;
});
});
// Setup report timestamps
const reportDate = new Date().toISOString().split('T')[0];
const viewMode = $('input[name="cu-view-mode"]:checked').val();
const timeCols = [];
if (viewMode === 'months') {
monthCols.forEach(m => timeCols.push({
id: m.key,
label: m.label
}));
} else if (viewMode !== 'total') {
monthCols.forEach(m => {
let label;
if (viewMode === 'quarters') {
const q = Math.floor((parseInt(m.key.split('-')[1]) - 1) / 3) + 1;
label = `${m.key.split('-')[0]}-Q${q}`;
} else {
label = m.key.split('-')[0]; // Years
}
let col = timeCols.find(c => c.label === label);
if (!col) {
col = {
label: label,
months: []
};
timeCols.push(col);
}
col.months.push(m.key);
});
}
// Initialize Wikitext output with header information
let wikitext = `== Global CheckUser Stats (${mf}/${yf} - ${mt}/${yt}) ==\n`;
wikitext += `''Report generated on: ${getReportTimestamp()}<br />\nGenerated with [[testwiki:User:MrJaroslavik/GlobalCheckUserStats.js|GlobalCheckUserStats.js]]''\n`;
// Initialize CSV output with headers
let csvData = "Project,User,Role,SelfAssignCount,MaxDuration_mins,TotalActions\n";
// Document applied filters in the wikitext report
let filterSummary = [];
// Only add wiki filter if the list is not empty, or if localcu is selected
if (currentFilterMode === 'localcu') {
filterSummary.push(`Wikis with local CheckUsers`);
} else if (filterList.length > 0) {
if (currentFilterMode === 'include') {
filterSummary.push(`Only these wikis: ${filterList.join(', ')}`);
} else if (currentFilterMode === 'exclude') {
filterSummary.push(`All except wikis: ${filterList.join(', ')}`);
}
}
if (currentUserFilter === 'local') filterSummary.push(`Only users: Local CheckUsers`);
else if (currentUserFilter === 'steward') filterSummary.push(`Only users: Steward actions`);
if (filterSummary.length > 0) {
wikitext += `<br />\n''Used filters: ${filterSummary.join(' | ')}''\n`;
}
wikitext += `\n`;
// Start building the main results table
let rightsLogText = `\n== Rights Log (In Period) ==\n`;
const per1kExpl = "Calculated as: (Total Actions in selected period / Current Active Users) * 1,000";
wikitext += `{| class="wikitable sortable" style="font-size:90%; text-align:right;"\n! Wiki / User !! Category !! '''Total''' !! per 1k users<ref>${per1kExpl}</ref> ${timeCols.map(c => `!! ${c.label}`).join(' ')}\n`;
// Process each database in alphabetical order
const sortedDBs = Object.keys(results).sort();
for (const db of sortedDBs) {
const metrics = await fetchWikiMetrics(db);
const activeFormatted = metrics.active > 0 ? metrics.active.toLocaleString('en-US') : 'Unknown';
const interwiki = getInterwikiPrefix(db);
// Build wiki-specific header row
let headerRow = `|-\n! colspan="${4 + timeCols.length}" style="background:#eaecf0; text-align:center;" | [[${interwiki}:|${db}]] <small style="font-weight:normal; color:#54595d;">(Active Users as of ${reportDate}: ${activeFormatted})</small> — [[${interwiki}:Special:CheckUserLog|CheckUserLog]] · [[${interwiki}:Special:ListUsers/checkuser|ListUsers/checkuser]]\n`;
let projectRows = [],
wikiTotal = 0,
wikiMonthlyStats = {};
monthCols.forEach(col => wikiMonthlyStats[col.key] = 0);
let projectUsers = Object.keys(results[db] || {}).sort();
for (const user of projectUsers) {
const userData = results[db][user];
const meta = await fetchUserData(user, db, START, END);
// Filter logic: Only show users active in period or holding rights
if (userData.total === 0 && !meta.hasLocalRightsInPeriod) continue;
const isStewardAction = meta.isStewardAction;
const isLocalCU = meta.role.includes("Local CheckUser");
// Apply UI role filters
if (currentUserFilter === 'local' && !isLocalCU) continue;
if (currentUserFilter === 'steward' && !isStewardAction) continue;
// Update totals
wikiTotal += userData.total;
monthCols.forEach(col => wikiMonthlyStats[col.key] += (userData.months[col.key] || 0));
// Calculate per 1k active users metric
let per1k = metrics.active > 0 ? ((userData.total / metrics.active) * 1000).toFixed(1) : "0.0";
// Construct wikitext row with dynamic timeline
let row = `|-\n| style="text-align:left;" | ${user}@${db} || <small>${meta.role}</small> || '''${userData.total}''' || ${per1k}`;
timeCols.forEach(col => {
let val = 0;
if (viewMode === 'months') val = userData.months[col.id] || 0;
else col.months.forEach(m => val += (userData.months[m] || 0));
row += ` || ${val}`;
});
projectRows.push({
html: row + "\n",
log: meta.log ? `'''${user}@${db}''':${meta.log}\n\n` : ""
});
// Construct CSV row
let baseRole = meta.role.split(' (')[0];
let sAssignCount = meta.isStewardAction ? meta.selfAssignCount : "";
let sMaxMins = meta.isStewardAction ? Math.round(meta.maxDurationMins) : "";
csvData += `${db},${user},"${baseRole}",${sAssignCount},${sMaxMins},${userData.total}\n`;
}
// Skip project if no users pass the filters
if (projectRows.length === 0) {
if (!emptyWikis.includes(db)) emptyWikis.push(db);
continue;
}
// Construct project summary row
let wikiPer1k = metrics.active > 0 ? ((wikiTotal / metrics.active) * 1000).toFixed(1) : "0.0";
let totalRow = `|- style="background:#f8f9fa; font-weight:bold;"\n| style="text-align:left;" | TOTAL ${db} || — || ${wikiTotal} || ${wikiPer1k}`;
timeCols.forEach(col => {
let val = 0;
if (viewMode === 'months') val = wikiMonthlyStats[col.id] || 0;
else col.months.forEach(m => val += (wikiMonthlyStats[m] || 0));
totalRow += ` || ${val}`;
});
wikitext += headerRow + totalRow + "\n";
projectRows.forEach(r => {
wikitext += r.html;
if (r.log) rightsLogText += r.log;
});
}
// Close table and append metadata sections
wikitext += `|}\n\n== Projects with 0 actions ==\n<div style="font-size:85%; color:#54595d;">${emptyWikis.sort().join(', ')}</div>\n`;
if (failedWikis.length > 0) {
wikitext += `\n== API Errors / Skipped Projects ==\n<div style="font-size:85%; color:#d33;">${failedWikis.sort().join(', ')}</div>\n`;
}
wikitext += rightsLogText + `\n\n<references />\n`;
// Output results to UI and finalize state
$('#out').val(wikitext);
$('#csv-out').val(csvData);
$('#output-containers').show();
$('#status-msg').text(`Done.`).css("color", "green");
$('#start').prop('disabled', false);
$('#stop').prop('disabled', true);
});
}
// Initialize the UI and finish script setup
setupUI();
});
})();
8w98d7tfwlxqd55t54c51fy1ih1vjm9
739285
739283
2026-04-24T19:00:24Z
MrJaroslavik
44012
rv
739285
javascript
text/javascript
// GlobalCheckUserStats.js
// -------------------------------------------------------
// Features:
// - Global Stats: Scans logs across 800+ projects at once.
// - Smart Roles: Identifies Local CheckUsers, Stewards, Staff, and Ombuds - and distinguishes between Former/Current roles.
// - Deep Scan: Bypasses API limits to get full history (limit 500 entries).
// - Stable: Retries 3x on rate limits so no wiki is missed.
// - Error Logs: Lists failed projects (ex. because of 429) at the end of the report.
// - UI: Integrated at [[meta:Special:BlankPage/GlobalCheckUserStats]].
// - Output: sortable Wikitables and detailed rights change logs and CSV file
// - With help of Gemini 3
// -------------------------------------------------------
(function() {
'use strict';
// Run only on Special:BlankPage/GlobalCheckUserStats
const isBlank = mw.config.get('wgCanonicalSpecialPageName') === 'Blankpage';
const isGCUS = mw.config.get('wgTitle').includes('GlobalCheckUserStats');
if (!isBlank || !isGCUS) {
return;
}
// Helper function to pause execution (useful for preventing API limits)
const DELAY_MS = 500;
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
// Makes API calls with automatic retries for "429 Too Many Requests" errors
async function robustCall(api, params) {
let retries = 0;
const maxRetries = 3;
while (retries < maxRetries) {
try {
// Attempt to fetch data from the MediaWiki API
return await api.get(params);
} catch (err) {
// Check if the server is actively rejecting requests due to high load (HTTP 429)
// Note: Optional chaining (?.) is avoided for broad MediaWiki editor compatibility
if (err && err.status === 429) {
retries++;
// Read the 'Retry-After' header provided by the server, or default to a scaling wait time
const waitTime = parseInt(err.xhr && err.xhr.getResponseHeader('Retry-After')) || (30 * retries);
// Notify the user via the UI that the script is pausing (supports both UI container types)
$('#status-msg, #cu-loader').html(
`<span style="color:orange; font-weight:bold;">Server is busy! Pausing for ${waitTime} seconds...</span>`
).show();
// Pause execution for the requested duration before trying again
await sleep(waitTime * 1000);
} else {
// If it is a different error (network failure, 500 Internal Error, etc.), stop and throw
throw err;
}
}
}
// Fail completely if max retries are exceeded
throw new Error("Failed to reach API after multiple attempts due to server limits.");
}
// Returns a standardized UTC timestamp for report headers (YYYY-MM-DD HH:MM)
function getReportTimestamp() {
return new Date().toISOString().replace('T', ' ').substring(0, 16) + ' (UTC)';
}
// Save data here so we don't have to download it twice
let userCache = {};
let historyCache = {};
// This map of all wikis will be filled automatically
let globalWikiMap = {};
// Process various API response formats into a clean array of group names
const extractGroups = function(data) {
if (!data) return [];
var res = [];
if (Array.isArray(data)) {
res = data;
} else if (typeof data === 'object') {
res = Object.values(data);
} else if (typeof data === 'string') {
res = data.split(',').map(function(s) {
return s.trim();
});
}
return res.map(function(g) {
if (typeof g !== 'string') return g;
return g.replace(/\s+/g, '').toLowerCase();
});
};
// List of wikis where CheckUser extension is disabled - https://noc.wikimedia.org/conf/highlight.php?file=dblists/checkuser-disabled.dblist
const disabledCUWikis = [
'aawikibooks', 'abwiktionary', 'advisorywiki', 'akwiktionary', 'angwikiquote',
'angwikisource', 'astwikibooks', 'astwikiquote', 'aswikibooks', 'aswiktionary',
'avwiktionary', 'aywikibooks', 'bhwiktionary', 'biwikibooks', 'biwiktionary',
'bmwikibooks', 'bmwikiquote', 'bmwiktionary', 'bowiktionary', 'chwikibooks',
'chwiktionary', 'cnwikimedia', 'crwikiquote', 'crwiktionary', 'dzwiktionary',
'gawikibooks', 'gnwikibooks', 'gotwikibooks', 'guwikibooks', 'htwikisource',
'huwikinews', 'hzwiki', 'iewikibooks', 'iiwiki', 'internalwiki', 'kjwiki',
'kkwikiquote', 'knwikibooks', 'krwiki', 'krwikiquote', 'kswikibooks',
'kswikiquote', 'kwwikiquote', 'lbwikibooks', 'lbwikiquote', 'lnwikibooks',
'lvwikibooks', 'mhwiktionary', 'mnwikibooks', 'muswiki', 'nahwikibooks',
'nawikibooks', 'nawikiquote', 'ndswikibooks', 'ndswikiquote', 'piwiktionary',
'pswikibooks', 'quwikibooks', 'quwikiquote', 'rmwikibooks', 'rmwiktionary',
'rnwiktionary', 'scwiktionary', 'searchcomwiki', 'snwiktionary', 'spcomwiki',
'swwikibooks', 'thwikinews', 'tkwikibooks', 'tkwikiquote', 'towiktionary',
'transitionteamwiki', 'trwikinews', 'ttwikiquote', 'twwiktionary',
'ugwikibooks', 'ugwikiquote', 'vowikibooks', 'vowikiquote', 'wawikibooks',
'wikimania2005wiki', 'wikimania2006wiki', 'wikimania2007wiki',
'wikimania2009wiki', 'wikimania2015wiki', 'wowikiquote', 'xhwikibooks',
'xhwiktionary', 'yowikibooks', 'yowiktionary', 'zawikibooks', 'zawikiquote',
'zawiktionary', 'zh_min_nanwikibooks', 'zuwikibooks',
// Closed/Unavailable projects (added manually)
'aawiktionary', 'akwikibooks', 'amwikiquote', 'angwikibooks', 'bgwikinews',
'bowikibooks', 'chowiki', 'cowikibooks', 'cowikiquote', 'gawikiquote',
'howiki', 'ikwiktionary', 'kywikibooks', 'mhwiki', 'miwikibooks',
'mywikibooks', 'nawiki', 'nzwikimedia', 'pa_uswikimedia', 'qualitywiki',
'ruwikimedia', 'sdwikinews', 'sewikibooks', 'simplewikibooks', 'strategywiki',
'suwikibooks', 'tenwiki', 'usabilitywiki', 'uzwikibooks', 'wikimania2010wiki',
'wikimania2011wiki', 'wikimania2012wiki', 'wikimania2013wiki', 'wikimania2014wiki',
'wikimania2016wiki', 'wikimania2017wiki', 'wikimania2018wiki', 'zh_min_nanwikiquote'
];
// Projects known to have local CheckUsers
const localCUWikis = [
'arwiki', 'bnwiki', 'cawiki', 'cswiki', 'dawiki', 'nlwiki', 'enwikibooks',
'enwiki', 'enwikivoyage', 'enwiktionary', 'fiwiki', 'frwiki', 'dewiki',
'hewiki', 'huwiki', 'idwiki', 'itwiki', 'jawiki', 'kowiki', 'fawiki',
'plwiki', 'ptwiki', 'ruwiki', 'srwiki', 'simplewiki', 'slwiki', 'eswiki',
'svwiki', 'thwiki', 'trwiki', 'ukwiki', 'viwiki', 'commonswiki',
'specieswiki', 'metawiki', 'wikidatawiki'
];
// Fetch all available wikis and filter out restricted ones
function loadGlobalWikiMap() {
const api = new mw.Api();
return api.get({
action: 'sitematrix',
format: 'json',
smtype: 'language|special',
smlangprop: 'site',
smsiteprop: 'dbname|url',
formatversion: 2,
origin: '*'
}).then(function(data) {
const matrix = data.sitematrix;
const wikiMap = {};
Object.keys(matrix).forEach(function(key) {
const entry = matrix[key];
if (entry.site && Array.isArray(entry.site)) {
entry.site.forEach(function(site) {
if (site.private || site.fishbowl || disabledCUWikis.indexOf(site.dbname) !== -1) return;
wikiMap[site.dbname] = site.url;
});
}
});
if (matrix.specials && Array.isArray(matrix.specials)) {
matrix.specials.forEach(function(special) {
if (special.private || special.fishbowl || disabledCUWikis.indexOf(special.dbname) !== -1) return;
wikiMap[special.dbname] = special.url;
});
}
return wikiMap;
});
}
// Convert database names to interwiki prefixes for table links
function getInterwikiPrefix(db) {
const specialMap = {
'commonswiki': 'c', 'metawiki': 'm', 'wikidatawiki': 'd',
'wikifunctionswiki': 'f', 'mediawikiwiki': 'mw', 'specieswiki': 'species',
'sourceswiki': 'oldwikisource', 'foundationwiki': 'foundation',
'incubatorwiki': 'incubator', 'outreachwiki': 'outreach',
'betawikiversity': 'betawikiversity', 'be_x_oldwiki': 'be-tarask',
'zh_classicalwiki': 'lzh', 'zh_min_nanwiki': 'nan', 'zh_yuewiki': 'yue'
};
if (specialMap[db]) return specialMap[db];
const name = db.replace(/_/g, '-');
if (name.endsWith('wikisource')) return 's:' + name.slice(0, -10);
if (name.endsWith('wikiversity')) return 'v:' + name.slice(0, -11);
if (name.endsWith('wiktionary')) return 'wikt:' + name.slice(0, -10);
if (name.endsWith('wikivoyage')) return 'voy:' + name.slice(0, -10);
if (name.endsWith('wikibooks')) return 'b:' + name.slice(0, -9);
if (name.endsWith('wikiquote')) return 'q:' + name.slice(0, -9);
if (name.endsWith('wikinews')) return 'n:' + name.slice(0, -8);
if (name.endsWith('wikimedia')) return 'wm' + name.slice(0, -9);
if (name.endsWith('wiki')) return 'w:' + name.slice(0, -4);
return 'w:' + name;
}
// Load required modules and start the script
mw.loader.using(['mediawiki.api', 'mediawiki.ForeignApi']).then(async function() {
// Display initial status message to the user
$('#mw-content-text').html('<strong>GlobalCheckUserStats.js: Loading wiki list...</strong>');
// Download and cache the list of all Wikimedia wikis
globalWikiMap = await loadGlobalWikiMap();
// Prepare the Meta-Wiki API and data containers
const metaApi = new mw.ForeignApi('https://meta.wikimedia.org/w/api.php');
const now = new Date();
// Variables for storing audit results and script state
let results = {};
let emptyWikis = [];
let failedWikis = [];
let scannedWikis = [];
let isRunning = false;
// Current UI filter settings
let currentFilterMode = 'all';
let currentUserFilter = 'all';
// Prepare the year and month selection menus
function setupUI() {
const currentYear = now.getFullYear();
// Generate options for years from 2005 to today
let yearOpts = '';
for (let y = currentYear; y >= 2005; y--) {
yearOpts += `<option value="${y}">${y}</option>`;
}
// Generate options for the month range (January to December)
let monthOptsFrom = '';
let monthOptsTo = '';
for (let m = 1; m <= 12; m++) {
monthOptsFrom += `<option value="${m}" ${m === 1 ? 'selected' : ''}>${m}</option>`;
monthOptsTo += `<option value="${m}" ${m === 12 ? 'selected' : ''}>${m}</option>`;
}
// Set the page title and build the HTML interface
$('#firstHeading').text('GlobalCheckUserStats.js');
$('#mw-content-text').empty().append(`
<div style="border:1px solid #a2a9b1; padding:15px; background:#f8f9fa;">
<h3 style="margin-top:0;">Stats Range</h3>
<div>
From: <select id="y-f" style="width:70px;">${yearOpts}</select>
<select id="m-f" style="width:50px;">${monthOptsFrom}</select>
To: <select id="y-t" style="width:70px;">${yearOpts}</select>
<select id="m-t" style="width:50px;">${monthOptsTo}</select>
</div>
<div style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>Wiki Filter:</strong>
<label style="cursor:pointer;"><input type="radio" name="wiki-mode" id="btn-all" checked> All</label>
<label style="cursor:pointer;"><input type="radio" name="wiki-mode" id="btn-localcu"> Local CU Wikis</label>
<label style="cursor:pointer;"><input type="radio" name="wiki-mode" id="btn-except"> All except</label>
<label style="cursor:pointer;"><input type="radio" name="wiki-mode" id="btn-only"> Only these</label>
<span id="wiki-help-trigger" style="cursor:help; background:#36c; color:#fff; border-radius:50%; width:18px; height:18px; display:inline-block; text-align:center; font-weight:bold;" title="Show available DB names">?</span>
</div>
<div id="filter-input-container" style="display:none; margin-top:10px;">
<input id="wiki-filter" type="text" style="width:100%;" placeholder="Example: enwiki, dewiki, commonswiki...">
</div>
<div style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>User Filter:</strong>
<label style="cursor:pointer;"><input type="radio" name="user-mode" id="u-all" checked> All</label>
<label style="cursor:pointer;"><input type="radio" name="user-mode" id="u-local"> Local CheckUsers</label>
<label style="cursor:pointer;"><input type="radio" name="user-mode" id="u-steward"> Steward actions</label>
</div>
<div style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>View mode (Wikitext):</strong>
<label style="cursor:pointer;"><input type="radio" name="cu-view-mode" value="total"> Total only</label>
<label style="cursor:pointer;"><input type="radio" name="cu-view-mode" value="months" checked> Months</label>
<label style="cursor:pointer;"><input type="radio" name="cu-view-mode" value="quarters"> Q1-Q4</label>
<label style="cursor:pointer;"><input type="radio" name="cu-view-mode" value="years"> Years</label>
</div>
<div id="wiki-list-help" style="display:none; margin-top:10px; padding:10px; background:#fff; border:1px solid #a2a9b1; font-size:11px; max-height:120px; overflow-y:auto; font-family:monospace;">
<strong>Available database names:</strong><br>${Object.keys(globalWikiMap).sort().join(', ')}
</div>
<div style="margin-top:20px;">
<button id="start" class="mw-ui-button mw-ui-progressive">Run</button>
<button id="stop" class="mw-ui-button mw-ui-destructive" disabled>Stop</button>
</div>
<div id="status-msg" style="margin-top:10px; font-weight:bold; color:#0056b3;">Ready.</div>
<div style="margin-top:5px;">
<progress id="bar" value="0" max="${Object.keys(globalWikiMap).length}" style="width:100%"></progress>
</div>
<div id="output-containers" style="display:none; margin-top:10px;">
<div style="margin-bottom:10px;">
<strong>Wikitext:</strong>
<textarea id="out" style="width:100%; height:250px; font-family:monospace; font-size:12px;"></textarea>
</div>
<div>
<strong>CSV:</strong>
<textarea id="csv-out" style="width:100%; height:150px; font-family:monospace; font-size:12px;"></textarea>
</div>
</div>
</div>
`);
// Listen for filter changes and button clicks
$('#btn-all').on('click', () => {
currentFilterMode = 'all';
$('#filter-input-container').hide();
});
$('#btn-localcu').on('click', () => {
currentFilterMode = 'localcu';
$('#filter-input-container').hide();
});
$('#btn-except').on('click', () => {
currentFilterMode = 'exclude';
$('#filter-input-container').show();
$('#wiki-filter').focus();
});
$('#btn-only').on('click', () => {
currentFilterMode = 'include';
$('#filter-input-container').show();
$('#wiki-filter').focus();
});
// User type filters
$('#u-all').on('click', () => {
currentUserFilter = 'all';
});
$('#u-local').on('click', () => {
currentUserFilter = 'local';
});
$('#u-steward').on('click', () => {
currentUserFilter = 'steward';
});
// Help and Execution controls
$('#wiki-help-trigger').on('click', () => $('#wiki-list-help').toggle());
$('#start').on('click', () => {
runAudit(
$('#y-f').val(), $('#m-f').val(),
$('#y-t').val(), $('#m-t').val()
);
});
$('#stop').on('click', () => {
isRunning = false;
});
}
// Get the number of active users for a specific wiki
async function fetchWikiMetrics(db) {
try {
// Find the correct server URL for this database
const baseUrl = globalWikiMap[db];
const api = new mw.ForeignApi(baseUrl + '/w/api.php');
// Request site statistics from the API
const res = await robustCall(api, {
action: 'query',
meta: 'siteinfo',
siprop: 'statistics',
formatversion: 2
});
// Return the count of active users
return {
active: res.query.statistics.activeusers || 0
};
} catch (err) {
// If the wiki is unreachable, return zero as a fallback
return {
active: 0
};
}
}
// Checks if the user held global roles (Steward/Staff/Ombuds)
async function checkGlobalHistory(user, start, end) {
try {
const res = await robustCall(metaApi, {
action: 'query',
list: 'logevents',
letype: 'gblrights',
letitle: 'User:' + user,
lelimit: 'max',
formatversion: 2
});
const logs = res.query.logevents || [];
const auditStart = new Date(start);
const auditEnd = new Date(end);
let status = {
wasSteward: false,
wasStaff: false,
wasOmbuds: false
};
logs.forEach(log => {
const ts = new Date(log.timestamp);
const p = log.params || {};
// Handle both new (named) and old (indexed) API formats
const current = extractGroups(p.newGroups || p.add || p[1] || p["1"]);
const old = extractGroups(p.oldGroups || p.remove || p[2] || p["0"]);
if (ts >= auditStart && ts <= auditEnd) {
if (current.includes('steward') || old.includes('steward')) status.wasSteward = true;
if (current.includes('staff') || old.includes('staff')) status.wasStaff = true;
if (current.some(g => g.includes('ombud')) || old.some(g => g.includes('ombud'))) status.wasOmbuds = true;
}
});
// If no changes during the period, check the state immediately before it started
if (!status.wasSteward && !status.wasStaff && !status.wasOmbuds) {
const priorLogs = logs
.filter(log => new Date(log.timestamp) < auditStart)
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
if (priorLogs.length > 0) {
const p = priorLogs[0].params || {};
// Crucial: check both newGroups and legacy indexed params
const groupsBefore = extractGroups(p.newGroups || p.add || p[1] || p["1"]);
status.wasSteward = groupsBefore.includes('steward');
status.wasStaff = groupsBefore.includes('staff');
status.wasOmbuds = groupsBefore.some(g => g.includes('ombud'));
}
}
return status;
} catch (err) {
return {
wasSteward: false,
wasStaff: false,
wasOmbuds: false
};
}
}
// Gather user information and fetch their rights logs
async function fetchUserData(user, db, start, end) {
const auditStart = new Date(start);
const auditEnd = new Date(end);
// Get current global groups from Meta-Wiki if not already cached
if (!userCache[user]) {
try {
const globalRes = await robustCall(metaApi, {
action: 'query',
meta: 'globaluserinfo',
guiprop: 'groups',
guiuser: user,
formatversion: 2
});
userCache[user] = globalRes.query.globaluserinfo.groups || [];
} catch (err) {
userCache[user] = [];
}
}
// Canonical variables for current global status
const currentGlobalGroups = userCache[user];
const isGloballySteward = currentGlobalGroups.includes('steward');
const isGloballyStaff = currentGlobalGroups.includes('staff');
const isGloballyOmbuds = currentGlobalGroups.includes('ombuds') || currentGlobalGroups.includes('ombudsman');
// Check for global role history in cache or fetch new data
if (!historyCache[user]) {
historyCache[user] = await checkGlobalHistory(user, start, end);
}
const historyRes = historyCache[user];
let isCurrentLocal = false;
// Check current local CheckUser rights on the target wiki
try {
const wikiUrl = globalWikiMap[db];
const localApi = new mw.ForeignApi(wikiUrl + '/w/api.php');
// Request user group information from the local API
const localRes = await robustCall(localApi, {
action: 'query',
list: 'users',
ususers: user,
usprop: 'groups',
formatversion: 2
});
// Parse response and verify if 'checkuser' group is present
const localUserData = localRes.query.users[0];
const localGroups = (localUserData && localUserData.groups) || [];
isCurrentLocal = localGroups.includes('checkuser');
} catch (err) {
// Default to false if the local rights check fails
isCurrentLocal = false;
}
// Define the target string for CentralAuth rights logs
const target = 'User:' + user + '@' + db;
let logText = '',
isSelfAssign = false,
maxDurationMins = -1,
longestTimeStr = "",
assignCount = 0,
hasLocalRightsInPeriod = false;
// Retrieve all rights change logs from Meta-Wiki
let events = [],
continueToken = null,
finished = false;
while (!finished) {
let params = {
action: 'query',
list: 'logevents',
letype: 'rights',
letitle: target,
ledir: 'older',
lelimit: 'max',
formatversion: 2
};
// Handle API pagination with continue tokens
if (continueToken) Object.assign(params, continueToken);
// Execute the API call and merge the results
const res = await robustCall(metaApi, params);
const batch = res.query.logevents || [];
events = events.concat(batch);
// Check for more results to continue pagination
if (res.continue) {
continueToken = res.continue;
} else {
finished = true;
}
}
// Initialize state variables for log processing
let logEntries = [],
pendingRemoved = null,
lastPairedDate = null,
capturedExpiry = null;
// Iterate through collected events to process rights changes
for (let i = 0; i < events.length; i++) {
const e = events[i];
const p = e.params || {};
// Extract the performing user, stripping import prefixes if present
const actor = e.user && e.user.includes('>') ? e.user.split('>')[1] : e.user;
// Determine CheckUser status before and after the event using various param keys
const hadCU = extractGroups(p.oldgroups || p.oldGroups || p["0"]).includes('checkuser');
const hasCU = extractGroups(p.newgroups || p.newGroups || p["1"]).includes('checkuser');
// Extract expiry metadata if the user already has rights and they are being updated
if (hasCU && hadCU) {
const rawMeta = p.newmetadata || p.newMetadata;
if (rawMeta) {
const metaArray = Array.isArray(rawMeta) ? rawMeta : Object.values(rawMeta);
const cuMeta = metaArray.find(m => m && (m.group === 'checkuser' || m.group === 'check user'));
if (cuMeta && cuMeta.expiry && cuMeta.expiry !== 'infinity') capturedExpiry = new Date(cuMeta.expiry);
}
}
// Identify if CheckUser was added or removed in this event
let cuAdded = (hasCU && !hadCU),
cuRemoved = (hadCU && !hasCU);
if (cuAdded || cuRemoved) {
const eventDate = new Date(e.timestamp),
exactTime = e.timestamp.replace('T', ' ').replace('Z', '');
const isInPeriod = (eventDate >= auditStart && eventDate <= auditEnd);
if (isInPeriod) hasLocalRightsInPeriod = true;
// Process events relevant to the audit timeframe or pairing logic
if (isInPeriod || (eventDate > auditEnd) || (pendingRemoved && cuAdded)) {
let expiryDate = capturedExpiry;
capturedExpiry = null;
// Check for metadata again to ensure current expiry is captured
const rawMeta = p.newmetadata || p.newMetadata;
if (rawMeta) {
const metaArray = Array.isArray(rawMeta) ? rawMeta : Object.values(rawMeta);
const cuMeta = metaArray.find(m => m && (m.group === 'checkuser' || m.group === 'check user'));
if (cuMeta && cuMeta.expiry && cuMeta.expiry !== 'infinity') expiryDate = new Date(cuMeta.expiry);
}
const eventBySelf = (actor === user || !actor);
if (cuAdded) {
const removalInPeriod = pendingRemoved && (pendingRemoved.date >= auditStart && pendingRemoved.date <= auditEnd);
const expiryInPeriod = expiryDate && (expiryDate >= auditStart && expiryDate <= auditEnd);
// Pair adding event with its removal or expiry to calculate duration
if ((isInPeriod || removalInPeriod || expiryInPeriod) && (pendingRemoved || expiryDate)) {
const checkDate = pendingRemoved ? pendingRemoved.date : expiryDate;
const diffMs = Math.abs(checkDate - eventDate);
const totalSecs = Math.floor(diffMs / 1000);
// Intelligent rounding to handle short durations and edge cases
let roundedMins = Math.round(totalSecs / 60);
if (roundedMins === 0 && totalSecs > 0) roundedMins = 1;
// Convert total minutes into days, hours, and remaining minutes
let days = Math.floor(roundedMins / 1440);
let hours = Math.floor((roundedMins % 1440) / 60);
let mins = roundedMins % 60;
// Build a human-readable duration string
let dPart = days > 0 ? days + 'd ' : '';
let hPart = hours > 0 ? hours + 'h ' : '';
let mPart = (mins > 0 || (days === 0 && hours === 0)) ? mins + 'm' : '';
let durStr = totalSecs < 60 ? totalSecs + "s" : (dPart + hPart + mPart).trim();
// Capture the log comment for the ADDED event
let reason = e.comment ? ` <small>''(${e.comment})''</small>` : "";
// Determine if this session was a Steward self-assignment
if (pendingRemoved) {
if (eventBySelf && pendingRemoved.isSelf) isSelfAssign = true;
// Retrieve the removal comment if available
let remReason = pendingRemoved.comment ? ` <small>''(${pendingRemoved.comment})''</small>` : "";
// Calculate discrepancy between manual removal and scheduled expiry
let note = "";
if (expiryDate) {
// Calculate planned duration in seconds and format it
const pDiff = Math.abs(expiryDate - eventDate) / 1000;
let rP = Math.round(pDiff / 60);
let rd = Math.floor(rP / 1440),
rh = Math.floor((rP % 1440) / 60),
rm = rP % 60;
let pD = pDiff < 60 ? Math.round(pDiff) + "s" : `${rd > 0 ? rd + 'd ' : ''}${rh > 0 ? rh + 'h ' : ''}${(rm > 0 || (rd === 0 && rh === 0)) ? rm + 'm' : ''}`.trim();
// Check if rights were removed manually earlier or later than planned
let timeDiff = pendingRemoved.date - expiryDate;
if (timeDiff < -60000) {
note = ` - manual removal before scheduled expiry (set to ${pD})`;
} else if (timeDiff > 600000) {
note = ` - automatic removal not found (set to ${pD})`;
}
}
// Finalize the paired log entry for manual removals
logEntries.unshift(`* ADDED: ${exactTime} by ${actor}${reason} | REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${remReason} (Duration: ${durStr}${note})`);
} else if (expiryDate) {
// Handle automatic expiration scenarios
if (eventBySelf) isSelfAssign = true;
logEntries.unshift(`* ADDED: ${exactTime} by ${actor}${reason} | EXPIRED: ${expiryDate.toISOString().replace('T', ' ').substring(0, 19)} <small>''(Automatic)''</small> (Duration: ${durStr})`);
}
// Reset pending state and update audit metrics
pendingRemoved = null;
if (totalSecs / 60 > maxDurationMins) {
maxDurationMins = totalSecs / 60;
longestTimeStr = durStr.trim();
}
assignCount++;
lastPairedDate = eventDate;
} else if (isInPeriod && !pendingRemoved && !expiryDate) {
// Handle cases where rights are still active or removal log is missing
if (!(lastPairedDate && Math.abs(lastPairedDate - eventDate) < 86400000)) {
logEntries.unshift(`* ADDED: ${exactTime} by ${actor} | REMOVED: (Active/Not removed)`);
lastPairedDate = eventDate;
}
pendingRemoved = null;
}
} else if (cuRemoved) {
// Record standalone removal events for future pairing
if (pendingRemoved && pendingRemoved.date >= auditStart && pendingRemoved.date <= auditEnd) {
logEntries.unshift(`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}`);
}
// Store removal data to be paired with the next ADDED event
pendingRemoved = {
time: exactTime,
date: eventDate,
user: actor,
isSelf: eventBySelf,
comment: e.comment
};
}
}
}
}
// Verify if the user held rights at the start of the period by checking prior logs
if (!hasLocalRightsInPeriod && events.length > 0) {
const firstBefore = events.find(ev => new Date(ev.timestamp) < auditStart);
if (firstBefore) {
const groupsBefore = extractGroups(firstBefore.params.newgroups || firstBefore.params.add || firstBefore.params[1] || firstBefore.params["1"]);
if (groupsBefore.includes('checkuser')) hasLocalRightsInPeriod = true;
}
// If still not found, check the oldest available log entry for the initial state
if (!hasLocalRightsInPeriod) {
const chronologicallyFirst = events[events.length - 1];
const cp = chronologicallyFirst.params || {};
const oldGroups = extractGroups(cp.oldgroups || cp.oldGroups || cp.remove || cp[0] || cp["0"]);
if (oldGroups.includes('checkuser')) hasLocalRightsInPeriod = true;
}
}
// Finalize the log text assembly
if (pendingRemoved && pendingRemoved.date >= auditStart && pendingRemoved.date <= auditEnd) {
logEntries.unshift(`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}`);
}
if (logEntries.length) logText = '\n' + logEntries.join('\n');
// Logic for determining user roles and categories
let roles = [];
const isStewardInPeriod = isGloballySteward || historyRes.wasSteward;
const isTemporaryMission = (longestTimeStr && isSelfAssign && isStewardInPeriod);
// Process Global Staff and Ombudsman roles
if (isGloballyStaff) roles.push("Current Staff");
else if (historyRes.wasStaff) roles.push("Former Staff");
if (isGloballyOmbuds) roles.push("Current Ombudsman");
else if (historyRes.wasOmbuds) roles.push("Former Ombudsman");
// Steward logic: Distinguish between temporary actions and permanent roles
if (isTemporaryMission) {
const countLabel = assignCount > 1 ? `${assignCount}x, longest ` : "";
roles.push(`Steward action (Self-assign: ${countLabel}${longestTimeStr})`);
} else {
if (isGloballySteward) roles.push("Current Steward");
else if (historyRes.wasSteward) roles.push("Former Steward");
}
// Process Local CheckUser roles
if (isCurrentLocal) roles.push("Current Local CheckUser");
if (hasLocalRightsInPeriod && !isCurrentLocal && !isTemporaryMission) {
roles.push("Former Local CheckUser");
}
// Consolidate unique roles into a formatted label
let uniqueRoles = [...new Set(roles)];
let roleLabel = uniqueRoles.length > 0 ? uniqueRoles.join(' & ') : "Unknown role";
// Return final metadata object for project reporting
return {
role: roleLabel,
log: logText,
hasLocalRightsInPeriod: hasLocalRightsInPeriod,
isStewardAction: isTemporaryMission,
selfAssignCount: assignCount,
maxDurationMins: maxDurationMins
};
}
// Main execution loop with Cross-Tab concurrency protection
async function runAudit(yf, mf, yt, mt) {
$('#status-msg').text('Waiting for other tabs to finish...').css("color", "orange");
$('#start').prop('disabled', true);
// Utilize Web Locks API to prevent concurrent API requests from multiple tabs
await navigator.locks.request('global_cu_audit', async () => {
isRunning = true;
// Clear cache and result containers for a fresh scan
userCache = {};
historyCache = {};
results = {};
emptyWikis = [];
failedWikis = [];
scannedWikis = [];
$('#status-msg').text('Lock acquired. Initializing...').css("color", "#0056b3");
$('#stop').prop('disabled', false);
// Parse wiki filter input from the UI
const filterText = $('#wiki-filter').val().trim().toLowerCase();
const filterList = filterText ? filterText.split(',').map(s => s.trim()).filter(s => s !== "") : [];
// Determine the set of wikis to process based on user filters
const allAvailableWikis = Object.keys(globalWikiMap);
let wikisToScan = allAvailableWikis;
// Apply inclusion or exclusion logic based on current UI selection
if (currentFilterMode === 'include') {
wikisToScan = allAvailableWikis.filter(w => filterList.includes(w));
} else if (currentFilterMode === 'exclude') {
wikisToScan = allAvailableWikis.filter(w => !filterList.includes(w));
} else if (currentFilterMode === 'localcu') {
wikisToScan = allAvailableWikis.filter(w => localCUWikis.indexOf(w) !== -1);
}
// Validate that there are wikis to scan before proceeding
if (wikisToScan.length === 0) {
alert("No wikis selected for audit!");
isRunning = false;
$('#start').prop('disabled', false);
return;
}
// Initialize the progress bar UI
$('#bar').attr('max', wikisToScan.length).val(0);
// Define the standardized ISO timestamps for the MediaWiki API
const START = `${yf}-${String(mf).padStart(2, '0')}-01T00:00:00Z`;
const END = `${yt}-${String(mt).padStart(2, '0')}-${new Date(Date.UTC(yt, mt, 0)).getUTCDate().toString().padStart(2, '0')}T23:59:59Z`;
// Generate the chronological month columns for the report table
const monthCols = [];
let currY = parseInt(yf),
currM = parseInt(mf),
endY = parseInt(yt),
endM = parseInt(mt);
while (currY < endY || (currY === endY && currM <= endM)) {
monthCols.push({
key: `${currY}-${String(currM).padStart(2, '0')}`,
label: `${["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][currM - 1]} ${currY}`
});
currM++;
if (currM > 12) {
currM = 1;
currY++;
}
}
// Iterate through each wiki project to collect CheckUser activity
for (let i = 0; i < wikisToScan.length; i++) {
if (!isRunning) break; // Allow for manual stop
const db = wikisToScan[i];
scannedWikis.push(db);
$('#status-msg').text(`Scanning ${db} (${i + 1}/${wikisToScan.length})...`);
let successLocal = false,
continueToken = null;
// Phase 1: Retrieve CheckUser logs and aggregate monthly stats
while (!successLocal && isRunning) {
try {
const api = new mw.ForeignApi(globalWikiMap[db] + '/w/api.php');
let params = {
action: 'query',
list: 'checkuserlog',
culfrom: START,
culto: END,
culdir: 'newer',
cullimit: 'max',
formatversion: 2
};
if (continueToken) Object.assign(params, continueToken);
let res = await robustCall(api, params);
const entries = (res.query && res.query.checkuserlog && res.query.checkuserlog.entries) ? res.query.checkuserlog.entries : [];
if (entries.length) {
if (!results[db]) results[db] = {};
entries.forEach(e => {
const user = e.checkuser;
if (!results[db][user]) results[db][user] = {
total: 0,
months: {}
};
const mKey = e.timestamp.slice(0, 7);
results[db][user].total++;
results[db][user].months[mKey] = (results[db][user].months[mKey] || 0) + 1;
});
}
// Handle API pagination
if (res.continue && isRunning) {
continueToken = res.continue;
} else {
successLocal = true;
}
} catch (err) {
failedWikis.push(db);
successLocal = true;
}
}
// Phase 2: Capture current CheckUsers (to include users with 0 actions)
if (isRunning && !failedWikis.includes(db)) {
try {
const auApi = new mw.ForeignApi(globalWikiMap[db] + '/w/api.php');
const auRes = await robustCall(auApi, {
action: 'query',
list: 'allusers',
augroup: 'checkuser',
aulimit: 'max',
formatversion: 2
});
const currentCUs = auRes.query.allusers || [];
if (currentCUs.length > 0) {
if (!results[db]) results[db] = {};
currentCUs.forEach(u => {
if (!results[db][u.name]) results[db][u.name] = {
total: 0,
months: {}
};
});
}
} catch (e) {
/* Fallback for local API failures */
}
}
// Record wikis with no CheckUser presence found
if (!results[db] && !failedWikis.includes(db)) {
emptyWikis.push(db);
}
// Update UI progress
$('#bar').val(i + 1);
await sleep(DELAY_MS);
}
// Finalize results and compile the audit report
$('#status-msg').text(`Generating report...`);
// Calculate global activity totals for each user across all wikis
let userGlobalTotals = {};
Object.keys(results).forEach(db => {
Object.keys(results[db]).forEach(user => {
userGlobalTotals[user] = (userGlobalTotals[user] || 0) + results[db][user].total;
});
});
// Setup report timestamps
const reportDate = new Date().toISOString().split('T')[0];
const viewMode = $('input[name="cu-view-mode"]:checked').val();
const timeCols = [];
if (viewMode === 'months') {
monthCols.forEach(m => timeCols.push({
id: m.key,
label: m.label
}));
} else if (viewMode !== 'total') {
monthCols.forEach(m => {
let label;
if (viewMode === 'quarters') {
const q = Math.floor((parseInt(m.key.split('-')[1]) - 1) / 3) + 1;
label = `${m.key.split('-')[0]}-Q${q}`;
} else {
label = m.key.split('-')[0]; // Years
}
let col = timeCols.find(c => c.label === label);
if (!col) {
col = {
label: label,
months: []
};
timeCols.push(col);
}
col.months.push(m.key);
});
}
// Initialize Wikitext output with header information
let wikitext = `== Global CheckUser Stats (${mf}/${yf} - ${mt}/${yt}) ==\n`;
wikitext += `''Report generated on: ${getReportTimestamp()}<br />\nGenerated with [[testwiki:User:MrJaroslavik/GlobalCheckUserStats.js|GlobalCheckUserStats.js]]''\n`;
// Initialize CSV output with headers
let csvData = "Project,User,Role,SelfAssignCount,MaxDuration_mins,TotalActions\n";
// Document applied filters in the wikitext report
let filterSummary = [];
// Only add wiki filter if the list is not empty, or if localcu is selected
if (currentFilterMode === 'localcu') {
filterSummary.push(`Wikis with local CheckUsers`);
} else if (filterList.length > 0) {
if (currentFilterMode === 'include') {
filterSummary.push(`Only these wikis: ${filterList.join(', ')}`);
} else if (currentFilterMode === 'exclude') {
filterSummary.push(`All except wikis: ${filterList.join(', ')}`);
}
}
if (currentUserFilter === 'local') filterSummary.push(`Only users: Local CheckUsers`);
else if (currentUserFilter === 'steward') filterSummary.push(`Only users: Steward actions`);
if (filterSummary.length > 0) {
wikitext += `<br />\n''Used filters: ${filterSummary.join(' | ')}''\n`;
}
wikitext += `\n`;
// Start building the main results table
let rightsLogText = `\n== Rights Log (In Period) ==\n`;
const per1kExpl = "Calculated as: (Total Actions in selected period / Current Active Users) * 1,000";
wikitext += `{| class="wikitable sortable" style="font-size:90%; text-align:right;"\n! Wiki / User !! Category !! '''Total''' !! per 1k users<ref>${per1kExpl}</ref> ${timeCols.map(c => `!! ${c.label}`).join(' ')}\n`;
// Process each database in alphabetical order
const sortedDBs = Object.keys(results).sort();
for (const db of sortedDBs) {
const metrics = await fetchWikiMetrics(db);
const activeFormatted = metrics.active > 0 ? metrics.active.toLocaleString('en-US') : 'Unknown';
const interwiki = getInterwikiPrefix(db);
// Build wiki-specific header row
let headerRow = `|-\n! colspan="${4 + timeCols.length}" style="background:#eaecf0; text-align:center;" | [[${interwiki}:|${db}]] <small style="font-weight:normal; color:#54595d;">(Active Users as of ${reportDate}: ${activeFormatted})</small> — [[${interwiki}:Special:CheckUserLog|CheckUserLog]] · [[${interwiki}:Special:ListUsers/checkuser|ListUsers/checkuser]]\n`;
let projectRows = [],
wikiTotal = 0,
wikiMonthlyStats = {};
monthCols.forEach(col => wikiMonthlyStats[col.key] = 0);
let projectUsers = Object.keys(results[db] || {}).sort();
for (const user of projectUsers) {
const userData = results[db][user];
const meta = await fetchUserData(user, db, START, END);
// Filter logic: Only show users active in period or holding rights
if (userData.total === 0 && !meta.hasLocalRightsInPeriod) continue;
const isStewardAction = meta.isStewardAction;
const isLocalCU = meta.role.includes("Local CheckUser");
// Apply UI role filters
if (currentUserFilter === 'local' && !isLocalCU) continue;
if (currentUserFilter === 'steward' && !isStewardAction) continue;
// Update totals
wikiTotal += userData.total;
monthCols.forEach(col => wikiMonthlyStats[col.key] += (userData.months[col.key] || 0));
// Calculate per 1k active users metric
let per1k = metrics.active > 0 ? ((userData.total / metrics.active) * 1000).toFixed(1) : "0.0";
// Construct wikitext row with dynamic timeline
let row = `|-\n| style="text-align:left;" | ${user}@${db} || <small>${meta.role}</small> || '''${userData.total}''' || ${per1k}`;
timeCols.forEach(col => {
let val = 0;
if (viewMode === 'months') val = userData.months[col.id] || 0;
else col.months.forEach(m => val += (userData.months[m] || 0));
row += ` || ${val}`;
});
projectRows.push({
html: row + "\n",
log: meta.log ? `'''${user}@${db}''':${meta.log}\n\n` : ""
});
// Construct CSV row
let baseRole = meta.role.split(' (')[0];
let sAssignCount = meta.isStewardAction ? meta.selfAssignCount : "";
let sMaxMins = meta.isStewardAction ? Math.round(meta.maxDurationMins) : "";
csvData += `${db},${user},"${baseRole}",${sAssignCount},${sMaxMins},${userData.total}\n`;
}
// Skip project if no users pass the filters
if (projectRows.length === 0) {
if (!emptyWikis.includes(db)) emptyWikis.push(db);
continue;
}
// Construct project summary row
let wikiPer1k = metrics.active > 0 ? ((wikiTotal / metrics.active) * 1000).toFixed(1) : "0.0";
let totalRow = `|- style="background:#f8f9fa; font-weight:bold;"\n| style="text-align:left;" | TOTAL ${db} || — || ${wikiTotal} || ${wikiPer1k}`;
timeCols.forEach(col => {
let val = 0;
if (viewMode === 'months') val = wikiMonthlyStats[col.id] || 0;
else col.months.forEach(m => val += (wikiMonthlyStats[m] || 0));
totalRow += ` || ${val}`;
});
wikitext += headerRow + totalRow + "\n";
projectRows.forEach(r => {
wikitext += r.html;
if (r.log) rightsLogText += r.log;
});
}
// Close table and append metadata sections
wikitext += `|}\n\n== Projects with 0 actions ==\n<div style="font-size:85%; color:#54595d;">${emptyWikis.sort().join(', ')}</div>\n`;
if (failedWikis.length > 0) {
wikitext += `\n== API Errors / Skipped Projects ==\n<div style="font-size:85%; color:#d33;">${failedWikis.sort().join(', ')}</div>\n`;
}
wikitext += rightsLogText + `\n\n<references />\n`;
// Output results to UI and finalize state
$('#out').val(wikitext);
$('#csv-out').val(csvData);
$('#output-containers').show();
$('#status-msg').text(`Done.`).css("color", "green");
$('#start').prop('disabled', false);
$('#stop').prop('disabled', true);
});
}
// Initialize the UI and finish script setup
setupUI();
});
})();
luhmeihh5c63pbhzsirnw9qrq9j5gu5
739308
739285
2026-04-25T09:58:10Z
MrJaroslavik
44012
e
739308
javascript
text/javascript
// GlobalCheckUserStats.js
// -------------------------------------------------------
// Features:
// - Scans logs across 800+ projects at once. With filters "All except these wikis", "Only these wikis" and "Only wikis with local CheckUsers".
// - Identifies Local CheckUsers, Stewards, Staff, and Ombuds - and distinguishes between Former/Current roles.
// - Bypasses API limits to get full history (limit 500 entries).
// - If run in multiple tabs, it waits for the previous scan to finish.
// - Lists projects with 0 actions and also failed projects (ex. because of 429) at the end of the report.
// - Integrated UI at [[meta:Special:BlankPage/GlobalCheckUserStats]].
// - Outputs sortable Wikitable with detailed rights change logs and CSV file.
// - With help of Gemini 3.
// -------------------------------------------------------
(function() {
'use strict';
// Run only on Special:BlankPage/GlobalCheckUserStats
const isBlank = mw.config.get('wgCanonicalSpecialPageName') === 'Blankpage';
const isGCUS = mw.config.get('wgTitle').includes('GlobalCheckUserStats');
if (!isBlank || !isGCUS) {
return;
}
// Helper function to pause execution (useful for preventing API limits)
const DELAY_MS = 500;
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
// Makes API calls with automatic retries for "429 Too Many Requests" errors
async function robustCall(api, params) {
let retries = 0;
const maxRetries = 3;
while (retries < maxRetries) {
try {
// Attempt to fetch data from the MediaWiki API
return await api.get(params);
} catch (err) {
// Check if the server is actively rejecting requests due to high load (HTTP 429)
// Note: Optional chaining (?.) is avoided for broad MediaWiki editor compatibility
if (err && err.status === 429) {
retries++;
// Read the 'Retry-After' header provided by the server, or default to a scaling wait time
const waitTime = parseInt(err.xhr && err.xhr.getResponseHeader('Retry-After')) || (30 * retries);
// Notify the user via the UI that the script is pausing (supports both UI container types)
$('#status-msg, #cu-loader').html(
`<span style="color:orange; font-weight:bold;">Server is busy! Pausing for ${waitTime} seconds...</span>`
).show();
// Pause execution for the requested duration before trying again
await sleep(waitTime * 1000);
} else {
// If it is a different error (network failure, 500 Internal Error, etc.), stop and throw
throw err;
}
}
}
// Fail completely if max retries are exceeded
throw new Error("Failed to reach API after multiple attempts due to server limits.");
}
// Returns a standardized UTC timestamp for report headers (YYYY-MM-DD HH:MM)
function getReportTimestamp() {
return new Date().toISOString().replace('T', ' ').substring(0, 16) + ' (UTC)';
}
// Save data here so we don't have to download it twice
let userCache = {};
let historyCache = {};
// This map of all wikis will be filled automatically
let globalWikiMap = {};
// Process various API response formats into a clean array of group names
const extractGroups = function(data) {
if (!data) return [];
var res = [];
if (Array.isArray(data)) {
res = data;
} else if (typeof data === 'object') {
res = Object.values(data);
} else if (typeof data === 'string') {
res = data.split(',').map(function(s) {
return s.trim();
});
}
return res.map(function(g) {
if (typeof g !== 'string') return g;
return g.replace(/\s+/g, '').toLowerCase();
});
};
// List of wikis where CheckUser extension is disabled - https://noc.wikimedia.org/conf/highlight.php?file=dblists/checkuser-disabled.dblist
const disabledCUWikis = [
'aawikibooks', 'abwiktionary', 'advisorywiki', 'akwiktionary', 'angwikiquote',
'angwikisource', 'astwikibooks', 'astwikiquote', 'aswikibooks', 'aswiktionary',
'avwiktionary', 'aywikibooks', 'bhwiktionary', 'biwikibooks', 'biwiktionary',
'bmwikibooks', 'bmwikiquote', 'bmwiktionary', 'bowiktionary', 'chwikibooks',
'chwiktionary', 'cnwikimedia', 'crwikiquote', 'crwiktionary', 'dzwiktionary',
'gawikibooks', 'gnwikibooks', 'gotwikibooks', 'guwikibooks', 'htwikisource',
'huwikinews', 'hzwiki', 'iewikibooks', 'iiwiki', 'internalwiki', 'kjwiki',
'kkwikiquote', 'knwikibooks', 'krwiki', 'krwikiquote', 'kswikibooks',
'kswikiquote', 'kwwikiquote', 'lbwikibooks', 'lbwikiquote', 'lnwikibooks',
'lvwikibooks', 'mhwiktionary', 'mnwikibooks', 'muswiki', 'nahwikibooks',
'nawikibooks', 'nawikiquote', 'ndswikibooks', 'ndswikiquote', 'piwiktionary',
'pswikibooks', 'quwikibooks', 'quwikiquote', 'rmwikibooks', 'rmwiktionary',
'rnwiktionary', 'scwiktionary', 'searchcomwiki', 'snwiktionary', 'spcomwiki',
'swwikibooks', 'thwikinews', 'tkwikibooks', 'tkwikiquote', 'towiktionary',
'transitionteamwiki', 'trwikinews', 'ttwikiquote', 'twwiktionary',
'ugwikibooks', 'ugwikiquote', 'vowikibooks', 'vowikiquote', 'wawikibooks',
'wikimania2005wiki', 'wikimania2006wiki', 'wikimania2007wiki',
'wikimania2009wiki', 'wikimania2015wiki', 'wowikiquote', 'xhwikibooks',
'xhwiktionary', 'yowikibooks', 'yowiktionary', 'zawikibooks', 'zawikiquote',
'zawiktionary', 'zh_min_nanwikibooks', 'zuwikibooks',
// Closed/Unavailable projects (added manually)
'aawiktionary', 'akwikibooks', 'amwikiquote', 'angwikibooks', 'bgwikinews',
'bowikibooks', 'chowiki', 'cowikibooks', 'cowikiquote', 'gawikiquote',
'howiki', 'ikwiktionary', 'kywikibooks', 'mhwiki', 'miwikibooks',
'mywikibooks', 'nawiki', 'nzwikimedia', 'pa_uswikimedia', 'qualitywiki',
'ruwikimedia', 'sdwikinews', 'sewikibooks', 'simplewikibooks', 'strategywiki',
'suwikibooks', 'tenwiki', 'usabilitywiki', 'uzwikibooks', 'wikimania2010wiki',
'wikimania2011wiki', 'wikimania2012wiki', 'wikimania2013wiki', 'wikimania2014wiki',
'wikimania2016wiki', 'wikimania2017wiki', 'wikimania2018wiki', 'zh_min_nanwikiquote'
];
// Projects known to have local CheckUsers
const localCUWikis = [
'arwiki', 'bnwiki', 'cawiki', 'cswiki', 'dawiki', 'nlwiki', 'enwikibooks',
'enwiki', 'enwikivoyage', 'enwiktionary', 'fiwiki', 'frwiki', 'dewiki',
'hewiki', 'huwiki', 'idwiki', 'itwiki', 'jawiki', 'kowiki', 'fawiki',
'plwiki', 'ptwiki', 'ruwiki', 'srwiki', 'simplewiki', 'slwiki', 'eswiki',
'svwiki', 'thwiki', 'trwiki', 'ukwiki', 'viwiki', 'commonswiki',
'specieswiki', 'metawiki', 'wikidatawiki'
];
// Fetch all available wikis and filter out restricted ones
function loadGlobalWikiMap() {
const api = new mw.Api();
return api.get({
action: 'sitematrix',
format: 'json',
smtype: 'language|special',
smlangprop: 'site',
smsiteprop: 'dbname|url',
formatversion: 2,
origin: '*'
}).then(function(data) {
const matrix = data.sitematrix;
const wikiMap = {};
Object.keys(matrix).forEach(function(key) {
const entry = matrix[key];
if (entry.site && Array.isArray(entry.site)) {
entry.site.forEach(function(site) {
if (site.private || site.fishbowl || disabledCUWikis.indexOf(site.dbname) !== -1) return;
wikiMap[site.dbname] = site.url;
});
}
});
if (matrix.specials && Array.isArray(matrix.specials)) {
matrix.specials.forEach(function(special) {
if (special.private || special.fishbowl || disabledCUWikis.indexOf(special.dbname) !== -1) return;
wikiMap[special.dbname] = special.url;
});
}
return wikiMap;
});
}
// Convert database names to interwiki prefixes for table links
function getInterwikiPrefix(db) {
const specialMap = {
'commonswiki': 'c', 'metawiki': 'm', 'wikidatawiki': 'd',
'wikifunctionswiki': 'f', 'mediawikiwiki': 'mw', 'specieswiki': 'species',
'sourceswiki': 'oldwikisource', 'foundationwiki': 'foundation',
'incubatorwiki': 'incubator', 'outreachwiki': 'outreach',
'betawikiversity': 'betawikiversity', 'be_x_oldwiki': 'be-tarask',
'zh_classicalwiki': 'lzh', 'zh_min_nanwiki': 'nan', 'zh_yuewiki': 'yue'
};
if (specialMap[db]) return specialMap[db];
const name = db.replace(/_/g, '-');
if (name.endsWith('wikisource')) return 's:' + name.slice(0, -10);
if (name.endsWith('wikiversity')) return 'v:' + name.slice(0, -11);
if (name.endsWith('wiktionary')) return 'wikt:' + name.slice(0, -10);
if (name.endsWith('wikivoyage')) return 'voy:' + name.slice(0, -10);
if (name.endsWith('wikibooks')) return 'b:' + name.slice(0, -9);
if (name.endsWith('wikiquote')) return 'q:' + name.slice(0, -9);
if (name.endsWith('wikinews')) return 'n:' + name.slice(0, -8);
if (name.endsWith('wikimedia')) return 'wm' + name.slice(0, -9);
if (name.endsWith('wiki')) return 'w:' + name.slice(0, -4);
return 'w:' + name;
}
// Load required modules and start the script
mw.loader.using(['mediawiki.api', 'mediawiki.ForeignApi']).then(async function() {
// Display initial status message to the user
$('#mw-content-text').html('<strong>GlobalCheckUserStats.js: Loading wiki list...</strong>');
// Download and cache the list of all Wikimedia wikis
globalWikiMap = await loadGlobalWikiMap();
// Prepare the Meta-Wiki API and data containers
const metaApi = new mw.ForeignApi('https://meta.wikimedia.org/w/api.php');
const now = new Date();
// Variables for storing audit results and script state
let results = {};
let emptyWikis = [];
let failedWikis = [];
let scannedWikis = [];
let isRunning = false;
// Current UI filter settings
let currentFilterMode = 'all';
let currentUserFilter = 'all';
// Prepare the year and month selection menus
function setupUI() {
const currentYear = now.getFullYear();
// Generate options for years from 2005 to today
let yearOpts = '';
for (let y = currentYear; y >= 2005; y--) {
yearOpts += `<option value="${y}">${y}</option>`;
}
// Generate options for the month range (January to December)
let monthOptsFrom = '';
let monthOptsTo = '';
for (let m = 1; m <= 12; m++) {
monthOptsFrom += `<option value="${m}" ${m === 1 ? 'selected' : ''}>${m}</option>`;
monthOptsTo += `<option value="${m}" ${m === 12 ? 'selected' : ''}>${m}</option>`;
}
// Set the page title and build the HTML interface
$('#firstHeading').text('GlobalCheckUserStats.js');
$('#mw-content-text').empty().append(`
<div style="border:1px solid #a2a9b1; padding:15px; background:#f8f9fa;">
<h3 style="margin-top:0;">Stats Range</h3>
<div>
From: <select id="y-f" style="width:70px;">${yearOpts}</select>
<select id="m-f" style="width:50px;">${monthOptsFrom}</select>
To: <select id="y-t" style="width:70px;">${yearOpts}</select>
<select id="m-t" style="width:50px;">${monthOptsTo}</select>
</div>
<div style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>Wiki Filter:</strong>
<label style="cursor:pointer;"><input type="radio" name="wiki-mode" id="btn-all" checked> All</label>
<label style="cursor:pointer;"><input type="radio" name="wiki-mode" id="btn-localcu"> Only wikis with local CheckUsers</label>
<label style="cursor:pointer;"><input type="radio" name="wiki-mode" id="btn-except"> All except these wikis</label>
<label style="cursor:pointer;"><input type="radio" name="wiki-mode" id="btn-only"> Only these wikis</label>
<span id="wiki-help-trigger" style="cursor:help; background:#36c; color:#fff; border-radius:50%; width:18px; height:18px; display:inline-block; text-align:center; font-weight:bold;" title="Show available DB names">?</span>
</div>
<div id="filter-input-container" style="display:none; margin-top:10px;">
<input id="wiki-filter" type="text" style="width:100%;" placeholder="Example: enwiki, dewiki, commonswiki...">
</div>
<div style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>User Filter:</strong>
<label style="cursor:pointer;"><input type="radio" name="user-mode" id="u-all" checked> All</label>
<label style="cursor:pointer;"><input type="radio" name="user-mode" id="u-local"> Local CheckUsers</label>
<label style="cursor:pointer;"><input type="radio" name="user-mode" id="u-steward"> Steward actions</label>
</div>
<div style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>Wikitext output:</strong>
<label style="cursor:pointer;"><input type="radio" name="cu-view-mode" value="total"> Total only</label>
<label style="cursor:pointer;"><input type="radio" name="cu-view-mode" value="months" checked> Months</label>
<label style="cursor:pointer;"><input type="radio" name="cu-view-mode" value="quarters"> Q1-Q4</label>
<label style="cursor:pointer;"><input type="radio" name="cu-view-mode" value="years"> Years</label>
</div>
<div id="wiki-list-help" style="display:none; margin-top:10px; padding:10px; background:#fff; border:1px solid #a2a9b1; font-size:11px; max-height:120px; overflow-y:auto; font-family:monospace;">
<strong>Available database names:</strong><br>${Object.keys(globalWikiMap).sort().join(', ')}
</div>
<div style="margin-top:20px;">
<button id="start" class="mw-ui-button mw-ui-progressive">Run</button>
<button id="stop" class="mw-ui-button mw-ui-destructive" disabled>Stop</button>
</div>
<div id="status-msg" style="margin-top:10px; font-weight:bold; color:#0056b3;">Ready.</div>
<div style="margin-top:5px;">
<progress id="bar" value="0" max="${Object.keys(globalWikiMap).length}" style="width:100%"></progress>
</div>
<div id="output-containers" style="display:none; margin-top:10px;">
<div style="margin-bottom:10px;">
<strong>Wikitext:</strong>
<textarea id="out" style="width:100%; height:250px; font-family:monospace; font-size:12px;"></textarea>
</div>
<div>
<strong>CSV:</strong>
<textarea id="csv-out" style="width:100%; height:150px; font-family:monospace; font-size:12px;"></textarea>
</div>
</div>
</div>
`);
// Listen for filter changes and button clicks
$('#btn-all').on('click', () => {
currentFilterMode = 'all';
$('#filter-input-container').hide();
});
$('#btn-localcu').on('click', () => {
currentFilterMode = 'localcu';
$('#filter-input-container').hide();
});
$('#btn-except').on('click', () => {
currentFilterMode = 'exclude';
$('#filter-input-container').show();
$('#wiki-filter').focus();
});
$('#btn-only').on('click', () => {
currentFilterMode = 'include';
$('#filter-input-container').show();
$('#wiki-filter').focus();
});
// User type filters
$('#u-all').on('click', () => {
currentUserFilter = 'all';
});
$('#u-local').on('click', () => {
currentUserFilter = 'local';
});
$('#u-steward').on('click', () => {
currentUserFilter = 'steward';
});
// Help and Execution controls
$('#wiki-help-trigger').on('click', () => $('#wiki-list-help').toggle());
$('#start').on('click', () => {
runAudit(
$('#y-f').val(), $('#m-f').val(),
$('#y-t').val(), $('#m-t').val()
);
});
$('#stop').on('click', () => {
isRunning = false;
});
}
// Get the number of active users for a specific wiki
async function fetchWikiMetrics(db) {
try {
// Find the correct server URL for this database
const baseUrl = globalWikiMap[db];
const api = new mw.ForeignApi(baseUrl + '/w/api.php');
// Request site statistics from the API
const res = await robustCall(api, {
action: 'query',
meta: 'siteinfo',
siprop: 'statistics',
formatversion: 2
});
// Return the count of active users
return {
active: res.query.statistics.activeusers || 0
};
} catch (err) {
// If the wiki is unreachable, return zero as a fallback
return {
active: 0
};
}
}
// Checks if the user held global roles (Steward/Staff/Ombuds)
async function checkGlobalHistory(user, start, end) {
try {
const res = await robustCall(metaApi, {
action: 'query',
list: 'logevents',
letype: 'gblrights',
letitle: 'User:' + user,
lelimit: 'max',
formatversion: 2
});
const logs = res.query.logevents || [];
const auditStart = new Date(start);
const auditEnd = new Date(end);
let status = {
wasSteward: false,
wasStaff: false,
wasOmbuds: false
};
logs.forEach(log => {
const ts = new Date(log.timestamp);
const p = log.params || {};
// Handle both new (named) and old (indexed) API formats
const current = extractGroups(p.newGroups || p.add || p[1] || p["1"]);
const old = extractGroups(p.oldGroups || p.remove || p[2] || p["0"]);
if (ts >= auditStart && ts <= auditEnd) {
if (current.includes('steward') || old.includes('steward')) status.wasSteward = true;
if (current.includes('staff') || old.includes('staff')) status.wasStaff = true;
if (current.some(g => g.includes('ombud')) || old.some(g => g.includes('ombud'))) status.wasOmbuds = true;
}
});
// If no changes during the period, check the state immediately before it started
if (!status.wasSteward && !status.wasStaff && !status.wasOmbuds) {
const priorLogs = logs
.filter(log => new Date(log.timestamp) < auditStart)
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
if (priorLogs.length > 0) {
const p = priorLogs[0].params || {};
// Crucial: check both newGroups and legacy indexed params
const groupsBefore = extractGroups(p.newGroups || p.add || p[1] || p["1"]);
status.wasSteward = groupsBefore.includes('steward');
status.wasStaff = groupsBefore.includes('staff');
status.wasOmbuds = groupsBefore.some(g => g.includes('ombud'));
}
}
return status;
} catch (err) {
return {
wasSteward: false,
wasStaff: false,
wasOmbuds: false
};
}
}
// Gather user information and fetch their rights logs
async function fetchUserData(user, db, start, end) {
const auditStart = new Date(start);
const auditEnd = new Date(end);
// Get current global groups from Meta-Wiki if not already cached
if (!userCache[user]) {
try {
const globalRes = await robustCall(metaApi, {
action: 'query',
meta: 'globaluserinfo',
guiprop: 'groups',
guiuser: user,
formatversion: 2
});
userCache[user] = globalRes.query.globaluserinfo.groups || [];
} catch (err) {
userCache[user] = [];
}
}
// Canonical variables for current global status
const currentGlobalGroups = userCache[user];
const isGloballySteward = currentGlobalGroups.includes('steward');
const isGloballyStaff = currentGlobalGroups.includes('staff');
const isGloballyOmbuds = currentGlobalGroups.includes('ombuds') || currentGlobalGroups.includes('ombudsman');
// Check for global role history in cache or fetch new data
if (!historyCache[user]) {
historyCache[user] = await checkGlobalHistory(user, start, end);
}
const historyRes = historyCache[user];
let isCurrentLocal = false;
// Check current local CheckUser rights on the target wiki
try {
const wikiUrl = globalWikiMap[db];
const localApi = new mw.ForeignApi(wikiUrl + '/w/api.php');
// Request user group information from the local API
const localRes = await robustCall(localApi, {
action: 'query',
list: 'users',
ususers: user,
usprop: 'groups',
formatversion: 2
});
// Parse response and verify if 'checkuser' group is present
const localUserData = localRes.query.users[0];
const localGroups = (localUserData && localUserData.groups) || [];
isCurrentLocal = localGroups.includes('checkuser');
} catch (err) {
// Default to false if the local rights check fails
isCurrentLocal = false;
}
// Define the target string for CentralAuth rights logs
const target = 'User:' + user + '@' + db;
let logText = '',
isSelfAssign = false,
maxDurationMins = -1,
longestTimeStr = "",
assignCount = 0,
hasLocalRightsInPeriod = false;
// Retrieve all rights change logs from Meta-Wiki
let events = [],
continueToken = null,
finished = false;
while (!finished) {
let params = {
action: 'query',
list: 'logevents',
letype: 'rights',
letitle: target,
ledir: 'older',
lelimit: 'max',
formatversion: 2
};
// Handle API pagination with continue tokens
if (continueToken) Object.assign(params, continueToken);
// Execute the API call and merge the results
const res = await robustCall(metaApi, params);
const batch = res.query.logevents || [];
events = events.concat(batch);
// Check for more results to continue pagination
if (res.continue) {
continueToken = res.continue;
} else {
finished = true;
}
}
// Initialize state variables for log processing
let logEntries = [],
pendingRemoved = null,
lastPairedDate = null,
capturedExpiry = null;
// Iterate through collected events to process rights changes
for (let i = 0; i < events.length; i++) {
const e = events[i];
const p = e.params || {};
// Extract the performing user, stripping import prefixes if present
const actor = e.user && e.user.includes('>') ? e.user.split('>')[1] : e.user;
// Determine CheckUser status before and after the event using various param keys
const hadCU = extractGroups(p.oldgroups || p.oldGroups || p["0"]).includes('checkuser');
const hasCU = extractGroups(p.newgroups || p.newGroups || p["1"]).includes('checkuser');
// Extract expiry metadata if the user already has rights and they are being updated
if (hasCU && hadCU) {
const rawMeta = p.newmetadata || p.newMetadata;
if (rawMeta) {
const metaArray = Array.isArray(rawMeta) ? rawMeta : Object.values(rawMeta);
const cuMeta = metaArray.find(m => m && (m.group === 'checkuser' || m.group === 'check user'));
if (cuMeta && cuMeta.expiry && cuMeta.expiry !== 'infinity') capturedExpiry = new Date(cuMeta.expiry);
}
}
// Identify if CheckUser was added or removed in this event
let cuAdded = (hasCU && !hadCU),
cuRemoved = (hadCU && !hasCU);
if (cuAdded || cuRemoved) {
const eventDate = new Date(e.timestamp),
exactTime = e.timestamp.replace('T', ' ').replace('Z', '');
const isInPeriod = (eventDate >= auditStart && eventDate <= auditEnd);
if (isInPeriod) hasLocalRightsInPeriod = true;
// Process events relevant to the audit timeframe or pairing logic
if (isInPeriod || (eventDate > auditEnd) || (pendingRemoved && cuAdded)) {
let expiryDate = capturedExpiry;
capturedExpiry = null;
// Check for metadata again to ensure current expiry is captured
const rawMeta = p.newmetadata || p.newMetadata;
if (rawMeta) {
const metaArray = Array.isArray(rawMeta) ? rawMeta : Object.values(rawMeta);
const cuMeta = metaArray.find(m => m && (m.group === 'checkuser' || m.group === 'check user'));
if (cuMeta && cuMeta.expiry && cuMeta.expiry !== 'infinity') expiryDate = new Date(cuMeta.expiry);
}
const eventBySelf = (actor === user || !actor);
if (cuAdded) {
const removalInPeriod = pendingRemoved && (pendingRemoved.date >= auditStart && pendingRemoved.date <= auditEnd);
const expiryInPeriod = expiryDate && (expiryDate >= auditStart && expiryDate <= auditEnd);
// Pair adding event with its removal or expiry to calculate duration
if ((isInPeriod || removalInPeriod || expiryInPeriod) && (pendingRemoved || expiryDate)) {
const checkDate = pendingRemoved ? pendingRemoved.date : expiryDate;
const diffMs = Math.abs(checkDate - eventDate);
const totalSecs = Math.floor(diffMs / 1000);
// Intelligent rounding to handle short durations and edge cases
let roundedMins = Math.round(totalSecs / 60);
if (roundedMins === 0 && totalSecs > 0) roundedMins = 1;
// Convert total minutes into days, hours, and remaining minutes
let days = Math.floor(roundedMins / 1440);
let hours = Math.floor((roundedMins % 1440) / 60);
let mins = roundedMins % 60;
// Build a human-readable duration string
let dPart = days > 0 ? days + 'd ' : '';
let hPart = hours > 0 ? hours + 'h ' : '';
let mPart = (mins > 0 || (days === 0 && hours === 0)) ? mins + 'm' : '';
let durStr = totalSecs < 60 ? totalSecs + "s" : (dPart + hPart + mPart).trim();
// Capture the log comment for the ADDED event
let reason = e.comment ? ` <small>''(${e.comment})''</small>` : "";
// Determine if this session was a Steward self-assignment
if (pendingRemoved) {
if (eventBySelf && pendingRemoved.isSelf) isSelfAssign = true;
// Retrieve the removal comment if available
let remReason = pendingRemoved.comment ? ` <small>''(${pendingRemoved.comment})''</small>` : "";
// Calculate discrepancy between manual removal and scheduled expiry
let note = "";
if (expiryDate) {
// Calculate planned duration in seconds and format it
const pDiff = Math.abs(expiryDate - eventDate) / 1000;
let rP = Math.round(pDiff / 60);
let rd = Math.floor(rP / 1440),
rh = Math.floor((rP % 1440) / 60),
rm = rP % 60;
let pD = pDiff < 60 ? Math.round(pDiff) + "s" : `${rd > 0 ? rd + 'd ' : ''}${rh > 0 ? rh + 'h ' : ''}${(rm > 0 || (rd === 0 && rh === 0)) ? rm + 'm' : ''}`.trim();
// Check if rights were removed manually earlier or later than planned
let timeDiff = pendingRemoved.date - expiryDate;
if (timeDiff < -60000) {
note = ` - manual removal before scheduled expiry (set to ${pD})`;
} else if (timeDiff > 600000) {
note = ` - automatic removal not found (set to ${pD})`;
}
}
// Finalize the paired log entry for manual removals
logEntries.unshift(`* ADDED: ${exactTime} by ${actor}${reason} | REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${remReason} (Duration: ${durStr}${note})`);
} else if (expiryDate) {
// Handle automatic expiration scenarios
if (eventBySelf) isSelfAssign = true;
logEntries.unshift(`* ADDED: ${exactTime} by ${actor}${reason} | EXPIRED: ${expiryDate.toISOString().replace('T', ' ').substring(0, 19)} <small>''(Automatic)''</small> (Duration: ${durStr})`);
}
// Reset pending state and update audit metrics
pendingRemoved = null;
if (totalSecs / 60 > maxDurationMins) {
maxDurationMins = totalSecs / 60;
longestTimeStr = durStr.trim();
}
assignCount++;
lastPairedDate = eventDate;
} else if (isInPeriod && !pendingRemoved && !expiryDate) {
// Handle cases where rights are still active or removal log is missing
if (!(lastPairedDate && Math.abs(lastPairedDate - eventDate) < 86400000)) {
logEntries.unshift(`* ADDED: ${exactTime} by ${actor} | REMOVED: (Active/Not removed)`);
lastPairedDate = eventDate;
}
pendingRemoved = null;
}
} else if (cuRemoved) {
// Record standalone removal events for future pairing
if (pendingRemoved && pendingRemoved.date >= auditStart && pendingRemoved.date <= auditEnd) {
logEntries.unshift(`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}`);
}
// Store removal data to be paired with the next ADDED event
pendingRemoved = {
time: exactTime,
date: eventDate,
user: actor,
isSelf: eventBySelf,
comment: e.comment
};
}
}
}
}
// Verify if the user held rights at the start of the period by checking prior logs
if (!hasLocalRightsInPeriod && events.length > 0) {
const firstBefore = events.find(ev => new Date(ev.timestamp) < auditStart);
if (firstBefore) {
const groupsBefore = extractGroups(firstBefore.params.newgroups || firstBefore.params.add || firstBefore.params[1] || firstBefore.params["1"]);
if (groupsBefore.includes('checkuser')) hasLocalRightsInPeriod = true;
}
// If still not found, check the oldest available log entry for the initial state
if (!hasLocalRightsInPeriod) {
const chronologicallyFirst = events[events.length - 1];
const cp = chronologicallyFirst.params || {};
const oldGroups = extractGroups(cp.oldgroups || cp.oldGroups || cp.remove || cp[0] || cp["0"]);
if (oldGroups.includes('checkuser')) hasLocalRightsInPeriod = true;
}
}
// Finalize the log text assembly
if (pendingRemoved && pendingRemoved.date >= auditStart && pendingRemoved.date <= auditEnd) {
logEntries.unshift(`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}`);
}
if (logEntries.length) logText = '\n' + logEntries.join('\n');
// Logic for determining user roles and categories
let roles = [];
const isStewardInPeriod = isGloballySteward || historyRes.wasSteward;
const isTemporaryMission = (longestTimeStr && isSelfAssign && isStewardInPeriod);
// Process Global Staff and Ombudsman roles
if (isGloballyStaff) roles.push("Current Staff");
else if (historyRes.wasStaff) roles.push("Former Staff");
if (isGloballyOmbuds) roles.push("Current Ombudsman");
else if (historyRes.wasOmbuds) roles.push("Former Ombudsman");
// Steward logic: Distinguish between temporary actions and permanent roles
if (isTemporaryMission) {
const countLabel = assignCount > 1 ? `${assignCount}x, longest ` : "";
roles.push(`Steward action (Self-assign: ${countLabel}${longestTimeStr})`);
} else {
if (isGloballySteward) roles.push("Current Steward");
else if (historyRes.wasSteward) roles.push("Former Steward");
}
// Determine local CheckUser status (Current and Former)
if (isCurrentLocal) roles.push("Current Local CheckUser");
if (hasLocalRightsInPeriod && !isCurrentLocal && !isTemporaryMission) {
roles.push("Former Local CheckUser");
}
// Consolidate unique roles into a formatted label
let uniqueRoles = [...new Set(roles)];
let roleLabel = uniqueRoles.length > 0 ? uniqueRoles.join(' & ') : "Unknown role";
// Return final metadata object for project reporting
return {
role: roleLabel,
log: logText,
hasLocalRightsInPeriod: hasLocalRightsInPeriod,
isStewardAction: isTemporaryMission,
selfAssignCount: assignCount,
maxDurationMins: maxDurationMins
};
}
// Main execution loop with Cross-Tab concurrency protection
async function runAudit(yf, mf, yt, mt) {
$('#status-msg').text('Waiting for other tabs to finish...').css("color", "orange");
$('#start').prop('disabled', true);
// Utilize Web Locks API to prevent concurrent API requests from multiple tabs
await navigator.locks.request('global_cu_audit', async () => {
isRunning = true;
// Clear cache and result containers for a fresh scan
userCache = {};
historyCache = {};
results = {};
emptyWikis = [];
failedWikis = [];
scannedWikis = [];
$('#status-msg').text('Lock acquired. Initializing...').css("color", "#0056b3");
$('#stop').prop('disabled', false);
// Parse wiki filter input from the UI
const filterText = $('#wiki-filter').val().trim().toLowerCase();
const filterList = filterText ? filterText.split(',').map(s => s.trim()).filter(s => s !== "") : [];
// Determine the set of wikis to process based on user filters
const allAvailableWikis = Object.keys(globalWikiMap);
let wikisToScan = allAvailableWikis;
// Apply inclusion or exclusion logic based on current UI selection
if (currentFilterMode === 'include') {
wikisToScan = allAvailableWikis.filter(w => filterList.includes(w));
} else if (currentFilterMode === 'exclude') {
wikisToScan = allAvailableWikis.filter(w => !filterList.includes(w));
} else if (currentFilterMode === 'localcu') {
wikisToScan = allAvailableWikis.filter(w => localCUWikis.indexOf(w) !== -1);
}
// Validate that there are wikis to scan before proceeding
if (wikisToScan.length === 0) {
alert("No wikis selected for audit!");
isRunning = false;
$('#start').prop('disabled', false);
return;
}
// Initialize the progress bar UI
$('#bar').attr('max', wikisToScan.length).val(0);
// Define the standardized ISO timestamps for the MediaWiki API
const START = `${yf}-${String(mf).padStart(2, '0')}-01T00:00:00Z`;
const END = `${yt}-${String(mt).padStart(2, '0')}-${new Date(Date.UTC(yt, mt, 0)).getUTCDate().toString().padStart(2, '0')}T23:59:59Z`;
// Generate the chronological month columns for the report table
const monthCols = [];
let currY = parseInt(yf),
currM = parseInt(mf),
endY = parseInt(yt),
endM = parseInt(mt);
while (currY < endY || (currY === endY && currM <= endM)) {
monthCols.push({
key: `${currY}-${String(currM).padStart(2, '0')}`,
label: `${["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][currM - 1]} ${currY}`
});
currM++;
if (currM > 12) {
currM = 1;
currY++;
}
}
// Iterate through each wiki project to collect CheckUser activity
for (let i = 0; i < wikisToScan.length; i++) {
if (!isRunning) break; // Allow for manual stop
const db = wikisToScan[i];
scannedWikis.push(db);
$('#status-msg').text(`Scanning ${db} (${i + 1}/${wikisToScan.length})...`);
let successLocal = false,
continueToken = null;
// Phase 1: Retrieve CheckUser logs and aggregate monthly stats
while (!successLocal && isRunning) {
try {
const api = new mw.ForeignApi(globalWikiMap[db] + '/w/api.php');
let params = {
action: 'query',
list: 'checkuserlog',
culfrom: START,
culto: END,
culdir: 'newer',
cullimit: 'max',
formatversion: 2
};
if (continueToken) Object.assign(params, continueToken);
let res = await robustCall(api, params);
const entries = (res.query && res.query.checkuserlog && res.query.checkuserlog.entries) ? res.query.checkuserlog.entries : [];
if (entries.length) {
if (!results[db]) results[db] = {};
entries.forEach(e => {
const user = e.checkuser;
if (!results[db][user]) results[db][user] = {
total: 0,
months: {}
};
const mKey = e.timestamp.slice(0, 7);
results[db][user].total++;
results[db][user].months[mKey] = (results[db][user].months[mKey] || 0) + 1;
});
}
// Handle API pagination
if (res.continue && isRunning) {
continueToken = res.continue;
} else {
successLocal = true;
}
} catch (err) {
failedWikis.push(db);
successLocal = true;
}
}
// Phase 2: Capture current CheckUsers (to include users with 0 actions)
if (isRunning && !failedWikis.includes(db)) {
try {
const auApi = new mw.ForeignApi(globalWikiMap[db] + '/w/api.php');
const auRes = await robustCall(auApi, {
action: 'query',
list: 'allusers',
augroup: 'checkuser',
aulimit: 'max',
formatversion: 2
});
const currentCUs = auRes.query.allusers || [];
if (currentCUs.length > 0) {
if (!results[db]) results[db] = {};
currentCUs.forEach(u => {
if (!results[db][u.name]) results[db][u.name] = {
total: 0,
months: {}
};
});
}
} catch (e) {
/* Fallback for local API failures */
}
}
// Record wikis with no CheckUser presence found
if (!results[db] && !failedWikis.includes(db)) {
emptyWikis.push(db);
}
// Update UI progress
$('#bar').val(i + 1);
await sleep(DELAY_MS);
}
// Finalize results and compile the audit report
$('#status-msg').text(`Generating report...`);
// Calculate global activity totals for each user across all wikis
let userGlobalTotals = {};
Object.keys(results).forEach(db => {
Object.keys(results[db]).forEach(user => {
userGlobalTotals[user] = (userGlobalTotals[user] || 0) + results[db][user].total;
});
});
// Setup report timestamps
const reportDate = new Date().toISOString().split('T')[0];
const viewMode = $('input[name="cu-view-mode"]:checked').val();
const timeCols = [];
if (viewMode === 'months') {
monthCols.forEach(m => timeCols.push({
id: m.key,
label: m.label
}));
} else if (viewMode !== 'total') {
monthCols.forEach(m => {
let label;
if (viewMode === 'quarters') {
const q = Math.floor((parseInt(m.key.split('-')[1]) - 1) / 3) + 1;
label = `${m.key.split('-')[0]}-Q${q}`;
} else {
label = m.key.split('-')[0]; // Years
}
let col = timeCols.find(c => c.label === label);
if (!col) {
col = {
label: label,
months: []
};
timeCols.push(col);
}
col.months.push(m.key);
});
}
// Initialize Wikitext output with header information
let wikitext = `== Global CheckUser Stats (${mf}/${yf} - ${mt}/${yt}) ==\n`;
wikitext += `''Report generated on: ${getReportTimestamp()}<br />\nGenerated with [[testwiki:User:MrJaroslavik/GlobalCheckUserStats.js|GlobalCheckUserStats.js]]''\n`;
// Initialize CSV output with headers
let csvData = "Project,User,Role,SelfAssignCount,MaxDuration_mins,TotalActions\n";
// Document applied filters in the wikitext report
let filterSummary = [];
// Only add wiki filter if the list is not empty, or if localcu is selected
if (currentFilterMode === 'localcu') {
filterSummary.push(`Wikis with local CheckUsers`);
} else if (filterList.length > 0) {
if (currentFilterMode === 'include') {
filterSummary.push(`Only these wikis: ${filterList.join(', ')}`);
} else if (currentFilterMode === 'exclude') {
filterSummary.push(`All except wikis: ${filterList.join(', ')}`);
}
}
if (currentUserFilter === 'local') filterSummary.push(`Only users: Local CheckUsers`);
else if (currentUserFilter === 'steward') filterSummary.push(`Only users: Steward actions`);
if (filterSummary.length > 0) {
wikitext += `<br />\n''Applied Filters: ${filterSummary.join(' | ')}''\n`;
}
wikitext += `\n`;
// Start building the main results table
let rightsLogText = `\n== Rights Log (In Period) ==\n`;
const per1kExpl = "Calculated as: (Total Actions in selected period / Current Active Users) * 1,000";
wikitext += `{| class="wikitable sortable" style="font-size:90%; text-align:right;"\n! Wiki / User !! Category !! '''Total''' !! per 1k users<ref>${per1kExpl}</ref> ${timeCols.map(c => `!! ${c.label}`).join(' ')}\n`;
// Process each database in alphabetical order
const sortedDBs = Object.keys(results).sort();
for (const db of sortedDBs) {
const metrics = await fetchWikiMetrics(db);
const activeFormatted = metrics.active > 0 ? metrics.active.toLocaleString('en-US') : 'Unknown';
const interwiki = getInterwikiPrefix(db);
// Build wiki-specific header row
let headerRow = `|-\n! colspan="${4 + timeCols.length}" style="background:#eaecf0; text-align:center;" | [[${interwiki}:|${db}]] <small style="font-weight:normal; color:#54595d;">(Active Users as of ${reportDate}: ${activeFormatted})</small> — [[${interwiki}:Special:CheckUserLog|CheckUserLog]] · [[${interwiki}:Special:ListUsers/checkuser|ListUsers/checkuser]]\n`;
let projectRows = [],
wikiTotal = 0,
wikiMonthlyStats = {};
monthCols.forEach(col => wikiMonthlyStats[col.key] = 0);
let projectUsers = Object.keys(results[db] || {}).sort();
for (const user of projectUsers) {
const userData = results[db][user];
const meta = await fetchUserData(user, db, START, END);
// Filter logic: Only show users active in period or holding rights
if (userData.total === 0 && !meta.hasLocalRightsInPeriod) continue;
const isStewardAction = meta.isStewardAction;
const isLocalCU = meta.role.includes("Local CheckUser");
// Apply UI role filters
if (currentUserFilter === 'local' && !isLocalCU) continue;
if (currentUserFilter === 'steward' && !isStewardAction) continue;
// Update totals
wikiTotal += userData.total;
monthCols.forEach(col => wikiMonthlyStats[col.key] += (userData.months[col.key] || 0));
// Calculate per 1k active users metric
let per1k = metrics.active > 0 ? ((userData.total / metrics.active) * 1000).toFixed(1) : "0.0";
// Construct wikitext row with dynamic timeline
let row = `|-\n| style="text-align:left;" | ${user}@${db} || <small>${meta.role}</small> || '''${userData.total}''' || ${per1k}`;
timeCols.forEach(col => {
let val = 0;
if (viewMode === 'months') val = userData.months[col.id] || 0;
else col.months.forEach(m => val += (userData.months[m] || 0));
row += ` || ${val}`;
});
projectRows.push({
html: row + "\n",
log: meta.log ? `'''${user}@${db}''':${meta.log}\n\n` : ""
});
// Construct CSV row
let baseRole = meta.role.split(' (')[0];
let sAssignCount = meta.isStewardAction ? meta.selfAssignCount : "";
let sMaxMins = meta.isStewardAction ? Math.round(meta.maxDurationMins) : "";
csvData += `${db},${user},"${baseRole}",${sAssignCount},${sMaxMins},${userData.total}\n`;
}
// Skip project if no users pass the filters
if (projectRows.length === 0) {
if (!emptyWikis.includes(db)) emptyWikis.push(db);
continue;
}
// Construct project summary row
let wikiPer1k = metrics.active > 0 ? ((wikiTotal / metrics.active) * 1000).toFixed(1) : "0.0";
let totalRow = `|- style="background:#f8f9fa; font-weight:bold;"\n| style="text-align:left;" | TOTAL ${db} || — || ${wikiTotal} || ${wikiPer1k}`;
timeCols.forEach(col => {
let val = 0;
if (viewMode === 'months') val = wikiMonthlyStats[col.id] || 0;
else col.months.forEach(m => val += (wikiMonthlyStats[m] || 0));
totalRow += ` || ${val}`;
});
wikitext += headerRow + totalRow + "\n";
projectRows.forEach(r => {
wikitext += r.html;
if (r.log) rightsLogText += r.log;
});
}
// Close table and append metadata sections
wikitext += `|}\n\n== Projects with 0 actions ==\n<div style="font-size:85%; color:#54595d;">${emptyWikis.sort().join(', ')}</div>\n`;
if (failedWikis.length > 0) {
wikitext += `\n== API Errors / Skipped Projects ==\n<div style="font-size:85%; color:#d33;">${failedWikis.sort().join(', ')}</div>\n`;
}
wikitext += rightsLogText + `\n\n<references />\n`;
// Output results to UI and finalize state
$('#out').val(wikitext);
$('#csv-out').val(csvData);
$('#output-containers').show();
$('#status-msg').text(`Done.`).css("color", "green");
$('#start').prop('disabled', false);
$('#stop').prop('disabled', true);
});
}
// Initialize the UI and finish script setup
setupUI();
});
})();
nad2eoe1ykj2x0i3gskrrqlqn6fv56i
User:MrJaroslavik/GlobalCheckUserList.js
2
174968
739275
739202
2026-04-24T18:24:32Z
MrJaroslavik
44012
+
739275
javascript
text/javascript
// GlobalCheckUserList.js
// -------------------------------------------------------
// Features:
// - Scans current CheckUsers across 800+ projects. With filters "only these" and "Only with local CheckUsers"
// - Checks Checks Meta and Local logs for the grant date.
// - Wikitext output in one sortable table.
// - Nicknames link to local logs; headers link to project, CheckUsers list, and CheckUserLog.
// - Shows last logged actions - CheckUser, Admin, General and if User is OS, shows last OS action
// - UI: Integrated at [[meta:Special:BlankPage/GlobalCheckUserList]].
// - Created with Gemini 3.
// -------------------------------------------------------
(function() {
'use strict';
// Run only on Special:BlankPage/GlobalCheckUserList
const isBlankPage = mw.config.get('wgCanonicalSpecialPageName') === 'Blankpage';
const isOurScript = mw.config.get('wgTitle').includes('GlobalCheckUserList');
if (!isBlankPage || !isOurScript) return;
let globalWikiMap = {};
// List of wikis where CheckUser extension is disabled - https://noc.wikimedia.org/conf/highlight.php?file=dblists/checkuser-disabled.dblist
const disabledCUWikis = [
'aawikibooks', 'abwiktionary', 'advisorywiki', 'akwiktionary', 'angwikiquote',
'angwikisource', 'astwikibooks', 'astwikiquote', 'aswikibooks', 'aswiktionary',
'avwiktionary', 'aywikibooks', 'bhwiktionary', 'biwikibooks', 'biwiktionary',
'bmwikibooks', 'bmwikiquote', 'bmwiktionary', 'bowiktionary', 'chwikibooks',
'chwiktionary', 'cnwikimedia', 'crwikiquote', 'crwiktionary', 'dzwiktionary',
'gawikibooks', 'gnwikibooks', 'gotwikibooks', 'guwikibooks', 'htwikisource',
'huwikinews', 'hzwiki', 'iewikibooks', 'iiwiki', 'internalwiki', 'kjwiki',
'kkwikiquote', 'knwikibooks', 'krwiki', 'krwikiquote', 'kswikibooks',
'kswikiquote', 'kwwikiquote', 'lbwikibooks', 'lbwikiquote', 'lnwikibooks',
'lvwikibooks', 'mhwiktionary', 'mnwikibooks', 'muswiki', 'nahwikibooks',
'nawikibooks', 'nawikiquote', 'ndswikibooks', 'ndswikiquote', 'piwiktionary',
'pswikibooks', 'quwikibooks', 'quwikiquote', 'rmwikibooks', 'rmwiktionary',
'rnwiktionary', 'scwiktionary', 'searchcomwiki', 'snwiktionary', 'spcomwiki',
'swwikibooks', 'thwikinews', 'tkwikibooks', 'tkwikiquote', 'towiktionary',
'transitionteamwiki', 'trwikinews', 'ttwikiquote', 'twwiktionary',
'ugwikibooks', 'ugwikiquote', 'vowikibooks', 'vowikiquote', 'wawikibooks',
'wikimania2005wiki', 'wikimania2006wiki', 'wikimania2007wiki',
'wikimania2009wiki', 'wikimania2015wiki', 'wowikiquote', 'xhwikibooks',
'xhwiktionary', 'yowikibooks', 'yowiktionary', 'zawikibooks', 'zawikiquote',
'zawiktionary', 'zh_min_nanwikibooks', 'zuwikibooks',
// Closed/Unavailable projects (added manually)
'aawiktionary', 'akwikibooks', 'amwikiquote', 'angwikibooks', 'bgwikinews',
'bowikibooks', 'chowiki', 'cowikibooks', 'cowikiquote', 'gawikiquote',
'howiki', 'ikwiktionary', 'kywikibooks', 'mhwiki', 'miwikibooks',
'mywikibooks', 'nawiki', 'nzwikimedia', 'pa_uswikimedia', 'qualitywiki',
'ruwikimedia', 'sdwikinews', 'sewikibooks', 'simplewikibooks', 'strategywiki',
'suwikibooks', 'tenwiki', 'usabilitywiki', 'uzwikibooks', 'wikimania2010wiki',
'wikimania2011wiki', 'wikimania2012wiki', 'wikimania2013wiki', 'wikimania2014wiki',
'wikimania2016wiki', 'wikimania2017wiki', 'wikimania2018wiki', 'zh_min_nanwikiquote'
];
// Projects known that have local CheckUsers - https://meta.wikimedia.org/wiki/CheckUser_policy/Users_with_CheckUser_access
const localCUWikis = [
'arwiki', 'bnwiki', 'cawiki', 'cswiki', 'dawiki', 'nlwiki', 'enwikibooks',
'enwiki', 'enwikivoyage', 'enwiktionary', 'fiwiki', 'frwiki', 'dewiki',
'hewiki', 'huwiki', 'idwiki', 'itwiki', 'jawiki', 'kowiki', 'fawiki',
'plwiki', 'ptwiki', 'ruwiki', 'srwiki', 'simplewiki', 'slwiki', 'eswiki',
'svwiki', 'thwiki', 'trwiki', 'ukwiki', 'viwiki', 'commonswiki',
'specieswiki', 'metawiki', 'wikidatawiki'
];
// Fetch all available wikis and filter out restricted ones
function loadGlobalWikiMap() {
const api = new mw.Api();
return api.get({
action: 'sitematrix',
format: 'json',
smtype: 'language|special',
smlangprop: 'site',
smsiteprop: 'dbname|url',
formatversion: 2,
origin: '*'
}).then(function(data) {
const matrix = data.sitematrix;
const wikiMap = {};
Object.keys(matrix).forEach(function(key) {
const entry = matrix[key];
if (entry.site && Array.isArray(entry.site)) {
entry.site.forEach(function(site) {
if (site.private || site.fishbowl || disabledCUWikis.indexOf(site.dbname) !== -1) return;
wikiMap[site.dbname] = site.url;
});
}
});
if (matrix.specials && Array.isArray(matrix.specials)) {
matrix.specials.forEach(function(special) {
if (special.private || special.fishbowl || disabledCUWikis.indexOf(special.dbname) !== -1) return;
wikiMap[special.dbname] = special.url;
});
}
return wikiMap;
});
}
// Convert database names to interwiki prefixes for table links
function getInterwikiPrefix(db) {
const specialMap = {
'commonswiki': 'c', 'metawiki': 'm', 'wikidatawiki': 'd',
'wikifunctionswiki': 'f', 'mediawikiwiki': 'mw', 'specieswiki': 'species',
'sourceswiki': 'oldwikisource', 'foundationwiki': 'foundation',
'incubatorwiki': 'incubator', 'outreachwiki': 'outreach',
'betawikiversity': 'betawikiversity', 'be_x_oldwiki': 'be-tarask',
'zh_classicalwiki': 'lzh', 'zh_min_nanwiki': 'nan', 'zh_yuewiki': 'yue'
};
if (specialMap[db]) return specialMap[db];
const name = db.replace(/_/g, '-');
if (name.endsWith('wikisource')) return 's:' + name.slice(0, -10);
if (name.endsWith('wikiversity')) return 'v:' + name.slice(0, -11);
if (name.endsWith('wiktionary')) return 'wikt:' + name.slice(0, -10);
if (name.endsWith('wikivoyage')) return 'voy:' + name.slice(0, -10);
if (name.endsWith('wikibooks')) return 'b:' + name.slice(0, -9);
if (name.endsWith('wikiquote')) return 'q:' + name.slice(0, -9);
if (name.endsWith('wikinews')) return 'n:' + name.slice(0, -8);
if (name.endsWith('wikimedia')) return 'wm' + name.slice(0, -9);
if (name.endsWith('wiki')) return 'w:' + name.slice(0, -4);
return 'w:' + name;
}
// Manual overrides for missing grant dates
// Format: "Username@dbname": "YYYY-MM-DD"
const customGrantDates = {
"RiazACU@bnwiki": "2022-05-13",
"KnudW@dawiki": "2017-03-23",
"Dbeef@enwiki": "2025-03-07",
"MarcGarver@enwikibooks": "2012-03-26",
"Jake Park@eswiki": "2021-07-03",
"Nohirara@idwiki": "2016-06-03",
"Superspritz@itwiki": "2012-12-06",
"Uncitoyen@trwiki": "2020-03-31"
};
// Helper function to pause execution (useful for preventing API limits)
const DELAY_MS = 500;
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
// Makes API calls with automatic retries for "429 Too Many Requests" errors
async function robustCall(api, params) {
let retries = 0;
const maxRetries = 3;
while (retries < maxRetries) {
try {
// Attempt to fetch data from the MediaWiki API
return await api.get(params);
} catch (err) {
// Check if the server is actively rejecting requests due to high load (HTTP 429)
// Note: Optional chaining (?.) is avoided for broad MediaWiki editor compatibility
if (err && err.status === 429) {
retries++;
// Read the 'Retry-After' header provided by the server, or default to a scaling wait time
const waitTime = parseInt(err.xhr && err.xhr.getResponseHeader('Retry-After')) || (30 * retries);
// Notify the user via the UI that the script is pausing (supports both UI container types)
$('#status-msg, #cu-loader').html(
`<span style="color:orange; font-weight:bold;">Server is busy! Pausing for ${waitTime} seconds...</span>`
).show();
// Pause execution for the requested duration before trying again
await sleep(waitTime * 1000);
} else {
// If it is a different error (network failure, 500 Internal Error, etc.), stop and throw
throw err;
}
}
}
// Fail completely if max retries are exceeded
throw new Error("Failed to reach API after multiple attempts due to server limits.");
}
// Returns a standardized UTC timestamp for report headers (YYYY-MM-DD HH:MM)
function getReportTimestamp() {
return new Date().toISOString().replace('T', ' ').substring(0, 16) + ' (UTC)';
}
// Process various API response formats into a clean array of group names
const extractGroups = function(data) {
if (!data) return [];
var res = [];
if (Array.isArray(data)) {
res = data;
} else if (typeof data === 'object') {
res = Object.values(data);
} else if (typeof data === 'string') {
res = data.split(',').map(function(s) {
return s.trim();
});
}
return res.map(function(g) {
if (typeof g !== 'string') return g;
return g.replace(/\s+/g, '').toLowerCase();
});
};
mw.loader.using(['mediawiki.api', 'mediawiki.ForeignApi']).then(async function() {
$('#mw-content-text').html('<strong>GlobalCheckUserList.js: Mapping wikis...</strong>');
globalWikiMap = await loadGlobalWikiMap();
const metaApi = new mw.ForeignApi('https://meta.wikimedia.org/w/api.php');
let isRunning = false;
let currentFilterMode = 'all';
function setupUI() {
$('#firstHeading').text('GlobalCheckUserList');
$('#mw-content-text').empty().append(`
<div style="border:1px solid #a2a9b1; padding:15px; background:#f8f9fa;">
<p>Audits current CheckUsers across Wikimedia projects. Reports are grouped into one single table.</p>
<div style="margin-bottom:10px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>Wiki Filter:</strong>
<label style="cursor:pointer;"><input type="radio" name="w-mode" id="btn-all" checked> All</label>
<label style="cursor:pointer;"><input type="radio" name="w-mode" id="btn-localcu"> Local CU Wikis</label>
<label style="cursor:pointer;"><input type="radio" name="w-mode" id="btn-only"> Only these</label>
<span id="wiki-help-trigger" style="cursor:help; background:#36c; color:#fff; border-radius:50%; width:18px; height:18px; display:inline-block; text-align:center; font-weight:bold;" title="Show available DB names">?</span>
</div>
<div id="filter-input-container" style="display:none; margin-bottom:15px;">
<input id="wiki-filter" type="text" style="width:100%;" placeholder="dbname1, dbname2...">
</div>
<div id="wiki-list-help" style="display:none; margin-bottom:10px; padding:10px; background:#fff; border:1px solid #a2a9b1; font-size:11px; max-height:120px; overflow-y:auto; font-family:monospace;">
<strong>Available database names:</strong><br>${Object.keys(globalWikiMap).sort().join(', ')}
</div>
<div style="margin-top:20px;">
<button id="start" class="mw-ui-button mw-ui-progressive">Run Audit</button>
<button id="stop" class="mw-ui-button mw-ui-destructive" disabled>Stop</button>
</div>
<div id="status-msg" style="margin-top:10px; font-weight:bold; color:#0056b3;">Ready.</div>
<div style="margin-top:5px;">
<progress id="bar" value="0" max="${Object.keys(globalWikiMap).length}" style="width:100%"></progress>
</div>
<div id="output-container" style="display:none; margin-top:15px;">
<strong>Wikitext Report:</strong>
<textarea id="out" style="width:100%; height:450px; font-family:monospace; font-size:11px; padding:5px; border:1px solid #c8ccd1;"></textarea>
</div>
</div>
`);
$('#btn-all').click(function() {
currentFilterMode = 'all';
$('#filter-input-container').hide();
});
$('#btn-localcu').click(function() {
currentFilterMode = 'localcu';
$('#filter-input-container').hide();
});
$('#btn-only').click(function() {
currentFilterMode = 'include';
$('#filter-input-container').show().focus();
});
$('#wiki-help-trigger').click(function() {
$('#wiki-list-help').toggle();
});
$('#start').click(function() {
runAudit();
});
$('#stop').click(function() {
isRunning = false;
});
}
async function findGrantDate(user, db) {
// Check manual list first
const key = user + "@" + db;
if (customGrantDates[key]) return customGrantDates[key];
// Search Meta-Wiki for steward actions (User:Name@dbname)
// We use User:Name for Meta itself, and User:Name@dbname for other wikis
const title = (db === 'metawiki') ? 'User:' + user : 'User:' + user + '@' + db;
let mPars = {
action: 'query',
list: 'logevents',
letype: 'rights',
letitle: title,
ledir: 'newer',
lelimit: 'max',
formatversion: 2
};
let resM = await robustCall(metaApi, mPars);
let logsM = (resM.query && resM.query.logevents) || [];
for (let ev of logsM) {
let p = ev.params || {};
let nG = extractGroups(p.newgroups || p.add || p[1] || p["1"]);
let oG = extractGroups(p.oldgroups || p.remove || p[0] || p["0"]);
if (nG.indexOf('checkuser') !== -1 && oG.indexOf('checkuser') === -1) {
return ev.timestamp.substring(0, 10);
}
}
return "Unknown";
}
async function runAudit() {
isRunning = true;
let wt = "";
const adminTypes = [
'block', 'delete', 'protect', 'rights', 'merge', 'abusefilter',
'contentmodel', 'import', 'managetags', 'massmessage',
'checkuser-temporary-account', 'ipinfo', 'pagelang',
'renameuser', 'stable', 'gblblock', 'abusefilter-protected-vars'
];
const cuInPublicTypes = ['abusefilterprivatedetails'];
$('#start').prop('disabled', true);
$('#stop').prop('disabled', false);
$('#status-msg').text('Waiting for queue lock...').css("color", "orange");
// Request browser lock to prevent concurrent runs
await navigator.locks.request('global_cu_list_lock', async () => {
wt = "== Global CheckUser List ==\n";
wt += "''Report generated on: " + getReportTimestamp() + "<br />\n";
wt += "Generated with [[testwiki:User:MrJaroslavik/GlobalCheckUserList.js|GlobalCheckUserList.js]]''\n";
const filterText = $('#wiki-filter').val().trim().toLowerCase();
const filterList = filterText ? filterText.split(',').map(function(s) {
return s.trim();
}).filter(function(s) {
return s !== "";
}) : [];
if (currentFilterMode === 'localcu') {
wt += "<br />''Filter applied: Wikis with local CheckUsers''<ref>[[meta:CheckUser policy/Users with CheckUser access]]</ref>\n";
} else if (currentFilterMode === 'include' && filterList.length > 0) {
wt += "<br />''Filter applied: Only these wikis (" + filterList.join(', ') + ")''\n";
}
wt += "\n";
wt += '{| class="wikitable sortable" style="font-size:90%; width:100%;"\n' +
'! User !! CU Since !! Duration !! Last CU Action<ref>Logs: checkuserlog, checkuser-temporary-account, abusefilterprivatedetails</ref> !! ' +
'Last Admin Action<ref>Logs: ' + adminTypes.join(', ') + '</ref> !! ' +
'Last OS Action<ref>Logs: suppress</ref> !! ' +
'Last Logged Action<ref>Logs: all public logged actions</ref> !! Last Edit\n';
const allWikis = Object.keys(globalWikiMap);
let wikisToScan;
if (currentFilterMode === 'include' && filterList.length > 0) {
wikisToScan = allWikis.filter(function(w) {
return filterList.indexOf(w) !== -1;
});
} else if (currentFilterMode === 'localcu') {
wikisToScan = allWikis.filter(function(w) {
return localCUWikis.indexOf(w) !== -1;
});
} else {
wikisToScan = allWikis;
}
$('#bar').attr('max', wikisToScan.length).val(0);
for (let i = 0; i < wikisToScan.length; i++) {
if (!isRunning) break;
const db = wikisToScan[i];
$('#status-msg').text('Auditing ' + db + ' (' + (i + 1) + '/' + wikisToScan.length + ')...').css("color", "#0056b3");
try {
const wikiUrl = globalWikiMap[db];
const localApi = new mw.ForeignApi(wikiUrl + '/w/api.php');
// Find all users currently in the checkuser group
let users = [],
auDone = false,
auCont = null;
while (!auDone && isRunning) {
let auPars = {
action: 'query',
list: 'allusers',
augroup: 'checkuser',
auprop: 'groups',
aulimit: 'max',
formatversion: 2
};
if (auCont) Object.assign(auPars, auCont);
let res = await robustCall(localApi, auPars);
users = users.concat(res.query.allusers || []);
if (res.continue) auCont = res.continue;
else auDone = true;
}
if (users.length > 0) {
const iw = getInterwikiPrefix(db);
wt += '|-\n! colspan="8" style="background:#eaecf0; text-align:center;" | ' +
'[[' + iw + ':|' + db + ']] — [[' + iw + ':Special:ListUsers/checkuser|(list)]] — [[' + iw + ':Special:CheckUserLog|(log)]]\n';
for (const u of users) {
const username = u.name;
const sinceDate = await findGrantDate(username, db, localApi);
// Calculate how many days user has held rights
let days = "-";
if (sinceDate && sinceDate !== "Unknown") {
const cleanDate = sinceDate.substring(0, 10);
const diff = new Date() - new Date(cleanDate);
days = Math.floor(diff / 86400000) + "d";
}
// Check private CheckUser log
const cuLog = await robustCall(localApi, {
action: 'query',
list: 'checkuserlog',
culuser: username,
cullimit: 1,
formatversion: 2
});
let lastCU = (cuLog.query && cuLog.query.checkuserlog && cuLog.query.checkuserlog.entries && cuLog.query.checkuserlog.entries[0]) ?
cuLog.query.checkuserlog.entries[0].timestamp.substring(0, 10) : "Never";
// Fetch public log history
const allUserLogsReq = await robustCall(localApi, {
action: 'query',
list: 'logevents',
leuser: username,
lelimit: 'max',
formatversion: 2
});
const allUserLogs = (allUserLogsReq.query && allUserLogsReq.query.logevents) || [];
// Cross-check for public logs that indicate CU activity
const pubCUEvt = allUserLogs.find(function(ev) {
return cuInPublicTypes.indexOf(ev.type) !== -1;
});
if (pubCUEvt) {
const pubCUTs = pubCUEvt.timestamp.substring(0, 10);
if (lastCU === "Never" || pubCUTs > lastCU) lastCU = pubCUTs;
}
// Identify latest admin action
const admEvent = allUserLogs.find(function(ev) {
return adminTypes.indexOf(ev.type) !== -1;
});
const lastAdm = admEvent ? admEvent.timestamp.substring(0, 10) : "Never";
// Get most recent log entry of any type
const lastGen = allUserLogs.length > 0 ? allUserLogs[0].timestamp.substring(0, 10) : "Never";
// Look for Suppression actions (filtering for setstatus)
const osLog = await robustCall(localApi, {
action: 'query',
list: 'logevents',
leuser: username,
letype: 'suppress',
lelimit: 50, // Scan more entries to find setstatus
formatversion: 2
});
let lastOS = "-";
var uGroups = extractGroups(u.groups);
var hasCurrentOSGroups = (uGroups.indexOf('suppress') !== -1 || uGroups.indexOf('oversight') !== -1);
if (hasCurrentOSGroups) {
lastOS = "Never";
if (osLog.query && osLog.query.logevents) {
// Find first entry that is NOT 'setstatus'
const realOSEvent = osLog.query.logevents.find(function(e) {
// Check if params exists and if subtype is NOT setstatus
return !e.params || (e.params && e.params.subtype !== 'setstatus');
});
if (realOSEvent) {
lastOS = realOSEvent.timestamp.substring(0, 10);
}
}
}
// Check last manual edit date
const edLog = await robustCall(localApi, {
action: 'query',
list: 'usercontribs',
ucuser: username,
uclimit: 1,
formatversion: 2
});
const lastEdit = (edLog.query && edLog.query.usercontribs && edLog.query.usercontribs[0]) ? edLog.query.usercontribs[0].timestamp.substring(0, 10) : "Never";
wt += '|-\n| style="text-align:left; white-space:nowrap;" | [[' + iw + ':Special:Log/' + username + '|' + username + '@' + db + ']] || ' +
sinceDate + ' || ' + days + ' || ' + lastCU + ' || ' + lastAdm + ' || ' + lastOS + ' || ' + lastGen + ' || ' + lastEdit + '\n';
}
}
} catch (err) {
console.error('Audit failed for ' + db, err);
}
$('#bar').val(i + 1);
await sleep(DELAY_MS);
}
wt += '|}\n\n=== References ===\n<references />\n';
$('#out').val(wt);
$('#output-container').show();
$('#status-msg').text('Audit complete!').css("color", "green");
$('#start').prop('disabled', false);
$('#stop').prop('disabled', true);
});
}
setupUI();
});
})();
gs9uu5q95n132gekuk96o5jths321w2
739279
739275
2026-04-24T18:32:01Z
MrJaroslavik
44012
enter
739279
javascript
text/javascript
// GlobalCheckUserList.js
// -------------------------------------------------------
// Features:
// - Scans current CheckUsers across 800+ projects. With filters "only these" and "Only with local CheckUsers"
// - Checks Checks Meta and Local logs for the grant date.
// - Wikitext output in one sortable table.
// - Nicknames link to local logs; headers link to project, CheckUsers list, and CheckUserLog.
// - Shows last logged actions - CheckUser, Admin, General and if User is OS, shows last OS action
// - UI: Integrated at [[meta:Special:BlankPage/GlobalCheckUserList]].
// - Created with Gemini 3.
// -------------------------------------------------------
(function() {
'use strict';
// Run only on Special:BlankPage/GlobalCheckUserList
const isBlankPage = mw.config.get('wgCanonicalSpecialPageName') === 'Blankpage';
const isOurScript = mw.config.get('wgTitle').includes('GlobalCheckUserList');
if (!isBlankPage || !isOurScript) return;
let globalWikiMap = {};
// List of wikis where CheckUser extension is disabled - https://noc.wikimedia.org/conf/highlight.php?file=dblists/checkuser-disabled.dblist
const disabledCUWikis = [
'aawikibooks', 'abwiktionary', 'advisorywiki', 'akwiktionary', 'angwikiquote',
'angwikisource', 'astwikibooks', 'astwikiquote', 'aswikibooks', 'aswiktionary',
'avwiktionary', 'aywikibooks', 'bhwiktionary', 'biwikibooks', 'biwiktionary',
'bmwikibooks', 'bmwikiquote', 'bmwiktionary', 'bowiktionary', 'chwikibooks',
'chwiktionary', 'cnwikimedia', 'crwikiquote', 'crwiktionary', 'dzwiktionary',
'gawikibooks', 'gnwikibooks', 'gotwikibooks', 'guwikibooks', 'htwikisource',
'huwikinews', 'hzwiki', 'iewikibooks', 'iiwiki', 'internalwiki', 'kjwiki',
'kkwikiquote', 'knwikibooks', 'krwiki', 'krwikiquote', 'kswikibooks',
'kswikiquote', 'kwwikiquote', 'lbwikibooks', 'lbwikiquote', 'lnwikibooks',
'lvwikibooks', 'mhwiktionary', 'mnwikibooks', 'muswiki', 'nahwikibooks',
'nawikibooks', 'nawikiquote', 'ndswikibooks', 'ndswikiquote', 'piwiktionary',
'pswikibooks', 'quwikibooks', 'quwikiquote', 'rmwikibooks', 'rmwiktionary',
'rnwiktionary', 'scwiktionary', 'searchcomwiki', 'snwiktionary', 'spcomwiki',
'swwikibooks', 'thwikinews', 'tkwikibooks', 'tkwikiquote', 'towiktionary',
'transitionteamwiki', 'trwikinews', 'ttwikiquote', 'twwiktionary',
'ugwikibooks', 'ugwikiquote', 'vowikibooks', 'vowikiquote', 'wawikibooks',
'wikimania2005wiki', 'wikimania2006wiki', 'wikimania2007wiki',
'wikimania2009wiki', 'wikimania2015wiki', 'wowikiquote', 'xhwikibooks',
'xhwiktionary', 'yowikibooks', 'yowiktionary', 'zawikibooks', 'zawikiquote',
'zawiktionary', 'zh_min_nanwikibooks', 'zuwikibooks',
// Closed/Unavailable projects (added manually)
'aawiktionary', 'akwikibooks', 'amwikiquote', 'angwikibooks', 'bgwikinews',
'bowikibooks', 'chowiki', 'cowikibooks', 'cowikiquote', 'gawikiquote',
'howiki', 'ikwiktionary', 'kywikibooks', 'mhwiki', 'miwikibooks',
'mywikibooks', 'nawiki', 'nzwikimedia', 'pa_uswikimedia', 'qualitywiki',
'ruwikimedia', 'sdwikinews', 'sewikibooks', 'simplewikibooks', 'strategywiki',
'suwikibooks', 'tenwiki', 'usabilitywiki', 'uzwikibooks', 'wikimania2010wiki',
'wikimania2011wiki', 'wikimania2012wiki', 'wikimania2013wiki', 'wikimania2014wiki',
'wikimania2016wiki', 'wikimania2017wiki', 'wikimania2018wiki', 'zh_min_nanwikiquote'
];
// Projects known that have local CheckUsers - https://meta.wikimedia.org/wiki/CheckUser_policy/Users_with_CheckUser_access
const localCUWikis = [
'arwiki', 'bnwiki', 'cawiki', 'cswiki', 'dawiki', 'nlwiki', 'enwikibooks',
'enwiki', 'enwikivoyage', 'enwiktionary', 'fiwiki', 'frwiki', 'dewiki',
'hewiki', 'huwiki', 'idwiki', 'itwiki', 'jawiki', 'kowiki', 'fawiki',
'plwiki', 'ptwiki', 'ruwiki', 'srwiki', 'simplewiki', 'slwiki', 'eswiki',
'svwiki', 'thwiki', 'trwiki', 'ukwiki', 'viwiki', 'commonswiki',
'specieswiki', 'metawiki', 'wikidatawiki'
];
// Fetch all available wikis and filter out restricted ones
function loadGlobalWikiMap() {
const api = new mw.Api();
return api.get({
action: 'sitematrix',
format: 'json',
smtype: 'language|special',
smlangprop: 'site',
smsiteprop: 'dbname|url',
formatversion: 2,
origin: '*'
}).then(function(data) {
const matrix = data.sitematrix;
const wikiMap = {};
Object.keys(matrix).forEach(function(key) {
const entry = matrix[key];
if (entry.site && Array.isArray(entry.site)) {
entry.site.forEach(function(site) {
if (site.private || site.fishbowl || disabledCUWikis.indexOf(site.dbname) !== -1) return;
wikiMap[site.dbname] = site.url;
});
}
});
if (matrix.specials && Array.isArray(matrix.specials)) {
matrix.specials.forEach(function(special) {
if (special.private || special.fishbowl || disabledCUWikis.indexOf(special.dbname) !== -1) return;
wikiMap[special.dbname] = special.url;
});
}
return wikiMap;
});
}
// Convert database names to interwiki prefixes for table links
function getInterwikiPrefix(db) {
const specialMap = {
'commonswiki': 'c', 'metawiki': 'm', 'wikidatawiki': 'd',
'wikifunctionswiki': 'f', 'mediawikiwiki': 'mw', 'specieswiki': 'species',
'sourceswiki': 'oldwikisource', 'foundationwiki': 'foundation',
'incubatorwiki': 'incubator', 'outreachwiki': 'outreach',
'betawikiversity': 'betawikiversity', 'be_x_oldwiki': 'be-tarask',
'zh_classicalwiki': 'lzh', 'zh_min_nanwiki': 'nan', 'zh_yuewiki': 'yue'
};
if (specialMap[db]) return specialMap[db];
const name = db.replace(/_/g, '-');
if (name.endsWith('wikisource')) return 's:' + name.slice(0, -10);
if (name.endsWith('wikiversity')) return 'v:' + name.slice(0, -11);
if (name.endsWith('wiktionary')) return 'wikt:' + name.slice(0, -10);
if (name.endsWith('wikivoyage')) return 'voy:' + name.slice(0, -10);
if (name.endsWith('wikibooks')) return 'b:' + name.slice(0, -9);
if (name.endsWith('wikiquote')) return 'q:' + name.slice(0, -9);
if (name.endsWith('wikinews')) return 'n:' + name.slice(0, -8);
if (name.endsWith('wikimedia')) return 'wm' + name.slice(0, -9);
if (name.endsWith('wiki')) return 'w:' + name.slice(0, -4);
return 'w:' + name;
}
// Manual overrides for missing grant dates
// Format: "Username@dbname": "YYYY-MM-DD"
const customGrantDates = {
"RiazACU@bnwiki": "2022-05-13",
"KnudW@dawiki": "2017-03-23",
"Dbeef@enwiki": "2025-03-07",
"MarcGarver@enwikibooks": "2012-03-26",
"Jake Park@eswiki": "2021-07-03",
"Nohirara@idwiki": "2016-06-03",
"Superspritz@itwiki": "2012-12-06",
"Uncitoyen@trwiki": "2020-03-31"
};
// Helper function to pause execution (useful for preventing API limits)
const DELAY_MS = 500;
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
// Makes API calls with automatic retries for "429 Too Many Requests" errors
async function robustCall(api, params) {
let retries = 0;
const maxRetries = 3;
while (retries < maxRetries) {
try {
// Attempt to fetch data from the MediaWiki API
return await api.get(params);
} catch (err) {
// Check if the server is actively rejecting requests due to high load (HTTP 429)
// Note: Optional chaining (?.) is avoided for broad MediaWiki editor compatibility
if (err && err.status === 429) {
retries++;
// Read the 'Retry-After' header provided by the server, or default to a scaling wait time
const waitTime = parseInt(err.xhr && err.xhr.getResponseHeader('Retry-After')) || (30 * retries);
// Notify the user via the UI that the script is pausing (supports both UI container types)
$('#status-msg, #cu-loader').html(
`<span style="color:orange; font-weight:bold;">Server is busy! Pausing for ${waitTime} seconds...</span>`
).show();
// Pause execution for the requested duration before trying again
await sleep(waitTime * 1000);
} else {
// If it is a different error (network failure, 500 Internal Error, etc.), stop and throw
throw err;
}
}
}
// Fail completely if max retries are exceeded
throw new Error("Failed to reach API after multiple attempts due to server limits.");
}
// Returns a standardized UTC timestamp for report headers (YYYY-MM-DD HH:MM)
function getReportTimestamp() {
return new Date().toISOString().replace('T', ' ').substring(0, 16) + ' (UTC)';
}
// Process various API response formats into a clean array of group names
const extractGroups = function(data) {
if (!data) return [];
var res = [];
if (Array.isArray(data)) {
res = data;
} else if (typeof data === 'object') {
res = Object.values(data);
} else if (typeof data === 'string') {
res = data.split(',').map(function(s) {
return s.trim();
});
}
return res.map(function(g) {
if (typeof g !== 'string') return g;
return g.replace(/\s+/g, '').toLowerCase();
});
};
mw.loader.using(['mediawiki.api', 'mediawiki.ForeignApi']).then(async function() {
$('#mw-content-text').html('<strong>GlobalCheckUserList.js: Mapping wikis...</strong>');
globalWikiMap = await loadGlobalWikiMap();
const metaApi = new mw.ForeignApi('https://meta.wikimedia.org/w/api.php');
let isRunning = false;
let currentFilterMode = 'all';
function setupUI() {
$('#firstHeading').text('GlobalCheckUserList');
$('#mw-content-text').empty().append(`
<div style="border:1px solid #a2a9b1; padding:15px; background:#f8f9fa;">
<p>Audits current CheckUsers across Wikimedia projects. Reports are grouped into one single table.</p>
<div style="margin-bottom:10px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>Wiki Filter:</strong>
<label style="cursor:pointer;"><input type="radio" name="w-mode" id="btn-all" checked> All</label>
<label style="cursor:pointer;"><input type="radio" name="w-mode" id="btn-localcu"> Local CU Wikis</label>
<label style="cursor:pointer;"><input type="radio" name="w-mode" id="btn-only"> Only these</label>
<span id="wiki-help-trigger" style="cursor:help; background:#36c; color:#fff; border-radius:50%; width:18px; height:18px; display:inline-block; text-align:center; font-weight:bold;" title="Show available DB names">?</span>
</div>
<div id="filter-input-container" style="display:none; margin-bottom:15px;">
<input id="wiki-filter" type="text" style="width:100%;" placeholder="dbname1, dbname2...">
</div>
<div id="wiki-list-help" style="display:none; margin-bottom:10px; padding:10px; background:#fff; border:1px solid #a2a9b1; font-size:11px; max-height:120px; overflow-y:auto; font-family:monospace;">
<strong>Available database names:</strong><br>${Object.keys(globalWikiMap).sort().join(', ')}
</div>
<div style="margin-top:20px;">
<button id="start" class="mw-ui-button mw-ui-progressive">Run Audit</button>
<button id="stop" class="mw-ui-button mw-ui-destructive" disabled>Stop</button>
</div>
<div id="status-msg" style="margin-top:10px; font-weight:bold; color:#0056b3;">Ready.</div>
<div style="margin-top:5px;">
<progress id="bar" value="0" max="${Object.keys(globalWikiMap).length}" style="width:100%"></progress>
</div>
<div id="output-container" style="display:none; margin-top:15px;">
<strong>Wikitext Report:</strong>
<textarea id="out" style="width:100%; height:450px; font-family:monospace; font-size:11px; padding:5px; border:1px solid #c8ccd1;"></textarea>
</div>
</div>
`);
$('#btn-all').click(function() {
currentFilterMode = 'all';
$('#filter-input-container').hide();
});
$('#btn-localcu').click(function() {
currentFilterMode = 'localcu';
$('#filter-input-container').hide();
});
$('#btn-only').click(function() {
currentFilterMode = 'include';
$('#filter-input-container').show().focus();
});
$('#wiki-help-trigger').click(function() {
$('#wiki-list-help').toggle();
});
// Enter
$('#wiki-filter').keypress(function(e) {
if (e.which === 13) { // 13 is Enter
e.preventDefault();
$('#start').click();
}
});
$('#start').click(function() {
runAudit();
});
$('#stop').click(function() {
isRunning = false;
});
}
async function findGrantDate(user, db) {
// Check manual list first
const key = user + "@" + db;
if (customGrantDates[key]) return customGrantDates[key];
// Search Meta-Wiki for steward actions (User:Name@dbname)
// We use User:Name for Meta itself, and User:Name@dbname for other wikis
const title = (db === 'metawiki') ? 'User:' + user : 'User:' + user + '@' + db;
let mPars = {
action: 'query',
list: 'logevents',
letype: 'rights',
letitle: title,
ledir: 'newer',
lelimit: 'max',
formatversion: 2
};
let resM = await robustCall(metaApi, mPars);
let logsM = (resM.query && resM.query.logevents) || [];
for (let ev of logsM) {
let p = ev.params || {};
let nG = extractGroups(p.newgroups || p.add || p[1] || p["1"]);
let oG = extractGroups(p.oldgroups || p.remove || p[0] || p["0"]);
if (nG.indexOf('checkuser') !== -1 && oG.indexOf('checkuser') === -1) {
return ev.timestamp.substring(0, 10);
}
}
return "Unknown";
}
async function runAudit() {
isRunning = true;
let wt = "";
const adminTypes = [
'block', 'delete', 'protect', 'rights', 'merge', 'abusefilter',
'contentmodel', 'import', 'managetags', 'massmessage',
'checkuser-temporary-account', 'ipinfo', 'pagelang',
'renameuser', 'stable', 'gblblock', 'abusefilter-protected-vars'
];
const cuInPublicTypes = ['abusefilterprivatedetails'];
$('#start').prop('disabled', true);
$('#stop').prop('disabled', false);
$('#status-msg').text('Waiting for queue lock...').css("color", "orange");
// Request browser lock to prevent concurrent runs
await navigator.locks.request('global_cu_list_lock', async () => {
wt = "== Global CheckUser List ==\n";
wt += "''Report generated on: " + getReportTimestamp() + "<br />\n";
wt += "Generated with [[testwiki:User:MrJaroslavik/GlobalCheckUserList.js|GlobalCheckUserList.js]]''\n";
const filterText = $('#wiki-filter').val().trim().toLowerCase();
const filterList = filterText ? filterText.split(',').map(function(s) {
return s.trim();
}).filter(function(s) {
return s !== "";
}) : [];
if (currentFilterMode === 'localcu') {
wt += "<br />''Filter applied: Wikis with local CheckUsers''<ref>[[meta:CheckUser policy/Users with CheckUser access]]</ref>\n";
} else if (currentFilterMode === 'include' && filterList.length > 0) {
wt += "<br />''Filter applied: Only these wikis (" + filterList.join(', ') + ")''\n";
}
wt += "\n";
wt += '{| class="wikitable sortable" style="font-size:90%; width:100%;"\n' +
'! User !! CU Since !! Duration !! Last CU Action<ref>Logs: checkuserlog, checkuser-temporary-account, abusefilterprivatedetails</ref> !! ' +
'Last Admin Action<ref>Logs: ' + adminTypes.join(', ') + '</ref> !! ' +
'Last OS Action<ref>Logs: suppress</ref> !! ' +
'Last Logged Action<ref>Logs: all public logged actions</ref> !! Last Edit\n';
const allWikis = Object.keys(globalWikiMap);
let wikisToScan;
if (currentFilterMode === 'include' && filterList.length > 0) {
wikisToScan = allWikis.filter(function(w) {
return filterList.indexOf(w) !== -1;
});
} else if (currentFilterMode === 'localcu') {
wikisToScan = allWikis.filter(function(w) {
return localCUWikis.indexOf(w) !== -1;
});
} else {
wikisToScan = allWikis;
}
$('#bar').attr('max', wikisToScan.length).val(0);
for (let i = 0; i < wikisToScan.length; i++) {
if (!isRunning) break;
const db = wikisToScan[i];
$('#status-msg').text('Auditing ' + db + ' (' + (i + 1) + '/' + wikisToScan.length + ')...').css("color", "#0056b3");
try {
const wikiUrl = globalWikiMap[db];
const localApi = new mw.ForeignApi(wikiUrl + '/w/api.php');
// Find all users currently in the checkuser group
let users = [],
auDone = false,
auCont = null;
while (!auDone && isRunning) {
let auPars = {
action: 'query',
list: 'allusers',
augroup: 'checkuser',
auprop: 'groups',
aulimit: 'max',
formatversion: 2
};
if (auCont) Object.assign(auPars, auCont);
let res = await robustCall(localApi, auPars);
users = users.concat(res.query.allusers || []);
if (res.continue) auCont = res.continue;
else auDone = true;
}
if (users.length > 0) {
const iw = getInterwikiPrefix(db);
wt += '|-\n! colspan="8" style="background:#eaecf0; text-align:center;" | ' +
'[[' + iw + ':|' + db + ']] — [[' + iw + ':Special:ListUsers/checkuser|(list)]] — [[' + iw + ':Special:CheckUserLog|(log)]]\n';
for (const u of users) {
const username = u.name;
const sinceDate = await findGrantDate(username, db, localApi);
// Calculate how many days user has held rights
let days = "-";
if (sinceDate && sinceDate !== "Unknown") {
const cleanDate = sinceDate.substring(0, 10);
const diff = new Date() - new Date(cleanDate);
days = Math.floor(diff / 86400000) + "d";
}
// Check private CheckUser log
const cuLog = await robustCall(localApi, {
action: 'query',
list: 'checkuserlog',
culuser: username,
cullimit: 1,
formatversion: 2
});
let lastCU = (cuLog.query && cuLog.query.checkuserlog && cuLog.query.checkuserlog.entries && cuLog.query.checkuserlog.entries[0]) ?
cuLog.query.checkuserlog.entries[0].timestamp.substring(0, 10) : "Never";
// Fetch public log history
const allUserLogsReq = await robustCall(localApi, {
action: 'query',
list: 'logevents',
leuser: username,
lelimit: 'max',
formatversion: 2
});
const allUserLogs = (allUserLogsReq.query && allUserLogsReq.query.logevents) || [];
// Cross-check for public logs that indicate CU activity
const pubCUEvt = allUserLogs.find(function(ev) {
return cuInPublicTypes.indexOf(ev.type) !== -1;
});
if (pubCUEvt) {
const pubCUTs = pubCUEvt.timestamp.substring(0, 10);
if (lastCU === "Never" || pubCUTs > lastCU) lastCU = pubCUTs;
}
// Identify latest admin action
const admEvent = allUserLogs.find(function(ev) {
return adminTypes.indexOf(ev.type) !== -1;
});
const lastAdm = admEvent ? admEvent.timestamp.substring(0, 10) : "Never";
// Get most recent log entry of any type
const lastGen = allUserLogs.length > 0 ? allUserLogs[0].timestamp.substring(0, 10) : "Never";
// Look for Suppression actions (filtering for setstatus)
const osLog = await robustCall(localApi, {
action: 'query',
list: 'logevents',
leuser: username,
letype: 'suppress',
lelimit: 50, // Scan more entries to find setstatus
formatversion: 2
});
let lastOS = "-";
var uGroups = extractGroups(u.groups);
var hasCurrentOSGroups = (uGroups.indexOf('suppress') !== -1 || uGroups.indexOf('oversight') !== -1);
if (hasCurrentOSGroups) {
lastOS = "Never";
if (osLog.query && osLog.query.logevents) {
// Find first entry that is NOT 'setstatus'
const realOSEvent = osLog.query.logevents.find(function(e) {
// Check if params exists and if subtype is NOT setstatus
return !e.params || (e.params && e.params.subtype !== 'setstatus');
});
if (realOSEvent) {
lastOS = realOSEvent.timestamp.substring(0, 10);
}
}
}
// Check last manual edit date
const edLog = await robustCall(localApi, {
action: 'query',
list: 'usercontribs',
ucuser: username,
uclimit: 1,
formatversion: 2
});
const lastEdit = (edLog.query && edLog.query.usercontribs && edLog.query.usercontribs[0]) ? edLog.query.usercontribs[0].timestamp.substring(0, 10) : "Never";
wt += '|-\n| style="text-align:left; white-space:nowrap;" | [[' + iw + ':Special:Log/' + username + '|' + username + '@' + db + ']] || ' +
sinceDate + ' || ' + days + ' || ' + lastCU + ' || ' + lastAdm + ' || ' + lastOS + ' || ' + lastGen + ' || ' + lastEdit + '\n';
}
}
} catch (err) {
console.error('Audit failed for ' + db, err);
}
$('#bar').val(i + 1);
await sleep(DELAY_MS);
}
wt += '|}\n\n=== References ===\n<references />\n';
$('#out').val(wt);
$('#output-container').show();
$('#status-msg').text('Audit complete!').css("color", "green");
$('#start').prop('disabled', false);
$('#stop').prop('disabled', true);
});
}
setupUI();
});
})();
joe7ka3qtnn6c9mrl952tbgmwwcwikm
739282
739279
2026-04-24T18:57:06Z
MrJaroslavik
44012
enter
739282
javascript
text/javascript
// GlobalCheckUserList.js
// -------------------------------------------------------
// Features:
// - Scans current CheckUsers across 800+ projects. With filters "only these" and "Only with local CheckUsers"
// - Checks Checks Meta and Local logs for the grant date.
// - Wikitext output in one sortable table.
// - Nicknames link to local logs; headers link to project, CheckUsers list, and CheckUserLog.
// - Shows last logged actions - CheckUser, Admin, General and if User is OS, shows last OS action
// - UI: Integrated at [[meta:Special:BlankPage/GlobalCheckUserList]].
// - Created with Gemini 3.
// -------------------------------------------------------
(function() {
'use strict';
// Run only on Special:BlankPage/GlobalCheckUserList
const isBlankPage = mw.config.get('wgCanonicalSpecialPageName') === 'Blankpage';
const isOurScript = mw.config.get('wgTitle').includes('GlobalCheckUserList');
if (!isBlankPage || !isOurScript) return;
let globalWikiMap = {};
// List of wikis where CheckUser extension is disabled - https://noc.wikimedia.org/conf/highlight.php?file=dblists/checkuser-disabled.dblist
const disabledCUWikis = [
'aawikibooks', 'abwiktionary', 'advisorywiki', 'akwiktionary', 'angwikiquote',
'angwikisource', 'astwikibooks', 'astwikiquote', 'aswikibooks', 'aswiktionary',
'avwiktionary', 'aywikibooks', 'bhwiktionary', 'biwikibooks', 'biwiktionary',
'bmwikibooks', 'bmwikiquote', 'bmwiktionary', 'bowiktionary', 'chwikibooks',
'chwiktionary', 'cnwikimedia', 'crwikiquote', 'crwiktionary', 'dzwiktionary',
'gawikibooks', 'gnwikibooks', 'gotwikibooks', 'guwikibooks', 'htwikisource',
'huwikinews', 'hzwiki', 'iewikibooks', 'iiwiki', 'internalwiki', 'kjwiki',
'kkwikiquote', 'knwikibooks', 'krwiki', 'krwikiquote', 'kswikibooks',
'kswikiquote', 'kwwikiquote', 'lbwikibooks', 'lbwikiquote', 'lnwikibooks',
'lvwikibooks', 'mhwiktionary', 'mnwikibooks', 'muswiki', 'nahwikibooks',
'nawikibooks', 'nawikiquote', 'ndswikibooks', 'ndswikiquote', 'piwiktionary',
'pswikibooks', 'quwikibooks', 'quwikiquote', 'rmwikibooks', 'rmwiktionary',
'rnwiktionary', 'scwiktionary', 'searchcomwiki', 'snwiktionary', 'spcomwiki',
'swwikibooks', 'thwikinews', 'tkwikibooks', 'tkwikiquote', 'towiktionary',
'transitionteamwiki', 'trwikinews', 'ttwikiquote', 'twwiktionary',
'ugwikibooks', 'ugwikiquote', 'vowikibooks', 'vowikiquote', 'wawikibooks',
'wikimania2005wiki', 'wikimania2006wiki', 'wikimania2007wiki',
'wikimania2009wiki', 'wikimania2015wiki', 'wowikiquote', 'xhwikibooks',
'xhwiktionary', 'yowikibooks', 'yowiktionary', 'zawikibooks', 'zawikiquote',
'zawiktionary', 'zh_min_nanwikibooks', 'zuwikibooks',
// Closed/Unavailable projects (added manually)
'aawiktionary', 'akwikibooks', 'amwikiquote', 'angwikibooks', 'bgwikinews',
'bowikibooks', 'chowiki', 'cowikibooks', 'cowikiquote', 'gawikiquote',
'howiki', 'ikwiktionary', 'kywikibooks', 'mhwiki', 'miwikibooks',
'mywikibooks', 'nawiki', 'nzwikimedia', 'pa_uswikimedia', 'qualitywiki',
'ruwikimedia', 'sdwikinews', 'sewikibooks', 'simplewikibooks', 'strategywiki',
'suwikibooks', 'tenwiki', 'usabilitywiki', 'uzwikibooks', 'wikimania2010wiki',
'wikimania2011wiki', 'wikimania2012wiki', 'wikimania2013wiki', 'wikimania2014wiki',
'wikimania2016wiki', 'wikimania2017wiki', 'wikimania2018wiki', 'zh_min_nanwikiquote'
];
// Projects known that have local CheckUsers - https://meta.wikimedia.org/wiki/CheckUser_policy/Users_with_CheckUser_access
const localCUWikis = [
'arwiki', 'bnwiki', 'cawiki', 'cswiki', 'dawiki', 'nlwiki', 'enwikibooks',
'enwiki', 'enwikivoyage', 'enwiktionary', 'fiwiki', 'frwiki', 'dewiki',
'hewiki', 'huwiki', 'idwiki', 'itwiki', 'jawiki', 'kowiki', 'fawiki',
'plwiki', 'ptwiki', 'ruwiki', 'srwiki', 'simplewiki', 'slwiki', 'eswiki',
'svwiki', 'thwiki', 'trwiki', 'ukwiki', 'viwiki', 'commonswiki',
'specieswiki', 'metawiki', 'wikidatawiki'
];
// Fetch all available wikis and filter out restricted ones
function loadGlobalWikiMap() {
const api = new mw.Api();
return api.get({
action: 'sitematrix',
format: 'json',
smtype: 'language|special',
smlangprop: 'site',
smsiteprop: 'dbname|url',
formatversion: 2,
origin: '*'
}).then(function(data) {
const matrix = data.sitematrix;
const wikiMap = {};
Object.keys(matrix).forEach(function(key) {
const entry = matrix[key];
if (entry.site && Array.isArray(entry.site)) {
entry.site.forEach(function(site) {
if (site.private || site.fishbowl || disabledCUWikis.indexOf(site.dbname) !== -1) return;
wikiMap[site.dbname] = site.url;
});
}
});
if (matrix.specials && Array.isArray(matrix.specials)) {
matrix.specials.forEach(function(special) {
if (special.private || special.fishbowl || disabledCUWikis.indexOf(special.dbname) !== -1) return;
wikiMap[special.dbname] = special.url;
});
}
return wikiMap;
});
}
// Convert database names to interwiki prefixes for table links
function getInterwikiPrefix(db) {
const specialMap = {
'commonswiki': 'c', 'metawiki': 'm', 'wikidatawiki': 'd',
'wikifunctionswiki': 'f', 'mediawikiwiki': 'mw', 'specieswiki': 'species',
'sourceswiki': 'oldwikisource', 'foundationwiki': 'foundation',
'incubatorwiki': 'incubator', 'outreachwiki': 'outreach',
'betawikiversity': 'betawikiversity', 'be_x_oldwiki': 'be-tarask',
'zh_classicalwiki': 'lzh', 'zh_min_nanwiki': 'nan', 'zh_yuewiki': 'yue'
};
if (specialMap[db]) return specialMap[db];
const name = db.replace(/_/g, '-');
if (name.endsWith('wikisource')) return 's:' + name.slice(0, -10);
if (name.endsWith('wikiversity')) return 'v:' + name.slice(0, -11);
if (name.endsWith('wiktionary')) return 'wikt:' + name.slice(0, -10);
if (name.endsWith('wikivoyage')) return 'voy:' + name.slice(0, -10);
if (name.endsWith('wikibooks')) return 'b:' + name.slice(0, -9);
if (name.endsWith('wikiquote')) return 'q:' + name.slice(0, -9);
if (name.endsWith('wikinews')) return 'n:' + name.slice(0, -8);
if (name.endsWith('wikimedia')) return 'wm' + name.slice(0, -9);
if (name.endsWith('wiki')) return 'w:' + name.slice(0, -4);
return 'w:' + name;
}
// Manual overrides for missing grant dates
// Format: "Username@dbname": "YYYY-MM-DD"
const customGrantDates = {
"RiazACU@bnwiki": "2022-05-13",
"KnudW@dawiki": "2017-03-23",
"Dbeef@enwiki": "2025-03-07",
"MarcGarver@enwikibooks": "2012-03-26",
"Jake Park@eswiki": "2021-07-03",
"Nohirara@idwiki": "2016-06-03",
"Superspritz@itwiki": "2012-12-06",
"Uncitoyen@trwiki": "2020-03-31"
};
// Helper function to pause execution (useful for preventing API limits)
const DELAY_MS = 500;
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
// Makes API calls with automatic retries for "429 Too Many Requests" errors
async function robustCall(api, params) {
let retries = 0;
const maxRetries = 3;
while (retries < maxRetries) {
try {
// Attempt to fetch data from the MediaWiki API
return await api.get(params);
} catch (err) {
// Check if the server is actively rejecting requests due to high load (HTTP 429)
// Note: Optional chaining (?.) is avoided for broad MediaWiki editor compatibility
if (err && err.status === 429) {
retries++;
// Read the 'Retry-After' header provided by the server, or default to a scaling wait time
const waitTime = parseInt(err.xhr && err.xhr.getResponseHeader('Retry-After')) || (30 * retries);
// Notify the user via the UI that the script is pausing (supports both UI container types)
$('#status-msg, #cu-loader').html(
`<span style="color:orange; font-weight:bold;">Server is busy! Pausing for ${waitTime} seconds...</span>`
).show();
// Pause execution for the requested duration before trying again
await sleep(waitTime * 1000);
} else {
// If it is a different error (network failure, 500 Internal Error, etc.), stop and throw
throw err;
}
}
}
// Fail completely if max retries are exceeded
throw new Error("Failed to reach API after multiple attempts due to server limits.");
}
// Returns a standardized UTC timestamp for report headers (YYYY-MM-DD HH:MM)
function getReportTimestamp() {
return new Date().toISOString().replace('T', ' ').substring(0, 16) + ' (UTC)';
}
// Process various API response formats into a clean array of group names
const extractGroups = function(data) {
if (!data) return [];
var res = [];
if (Array.isArray(data)) {
res = data;
} else if (typeof data === 'object') {
res = Object.values(data);
} else if (typeof data === 'string') {
res = data.split(',').map(function(s) {
return s.trim();
});
}
return res.map(function(g) {
if (typeof g !== 'string') return g;
return g.replace(/\s+/g, '').toLowerCase();
});
};
mw.loader.using(['mediawiki.api', 'mediawiki.ForeignApi']).then(async function() {
$('#mw-content-text').html('<strong>GlobalCheckUserList.js: Mapping wikis...</strong>');
globalWikiMap = await loadGlobalWikiMap();
const metaApi = new mw.ForeignApi('https://meta.wikimedia.org/w/api.php');
let isRunning = false;
let currentFilterMode = 'all';
function setupUI() {
$('#firstHeading').text('GlobalCheckUserList');
$('#mw-content-text').empty().append(`
<div style="border:1px solid #a2a9b1; padding:15px; background:#f8f9fa;">
<p>Audits current CheckUsers across Wikimedia projects. Reports are grouped into one single table.</p>
<div style="margin-bottom:10px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>Wiki Filter:</strong>
<label style="cursor:pointer;"><input type="radio" name="w-mode" id="btn-all" checked> All</label>
<label style="cursor:pointer;"><input type="radio" name="w-mode" id="btn-localcu"> Local CU Wikis</label>
<label style="cursor:pointer;"><input type="radio" name="w-mode" id="btn-only"> Only these</label>
<span id="wiki-help-trigger" style="cursor:help; background:#36c; color:#fff; border-radius:50%; width:18px; height:18px; display:inline-block; text-align:center; font-weight:bold;" title="Show available DB names">?</span>
</div>
<div id="filter-input-container" style="display:none; margin-bottom:15px;">
<input id="wiki-filter" type="text" style="width:100%;" placeholder="dbname1, dbname2...">
</div>
<div id="wiki-list-help" style="display:none; margin-bottom:10px; padding:10px; background:#fff; border:1px solid #a2a9b1; font-size:11px; max-height:120px; overflow-y:auto; font-family:monospace;">
<strong>Available database names:</strong><br>${Object.keys(globalWikiMap).sort().join(', ')}
</div>
<div style="margin-top:20px;">
<button id="start" class="mw-ui-button mw-ui-progressive">Run Audit</button>
<button id="stop" class="mw-ui-button mw-ui-destructive" disabled>Stop</button>
</div>
<div id="status-msg" style="margin-top:10px; font-weight:bold; color:#0056b3;">Ready.</div>
<div style="margin-top:5px;">
<progress id="bar" value="0" max="${Object.keys(globalWikiMap).length}" style="width:100%"></progress>
</div>
<div id="output-container" style="display:none; margin-top:15px;">
<strong>Wikitext Report:</strong>
<textarea id="out" style="width:100%; height:450px; font-family:monospace; font-size:11px; padding:5px; border:1px solid #c8ccd1;"></textarea>
</div>
</div>
`);
$('#btn-all').click(function() {
currentFilterMode = 'all';
$('#filter-input-container').hide();
});
$('#btn-localcu').click(function() {
currentFilterMode = 'localcu';
$('#filter-input-container').hide();
});
$('#btn-only').click(function() {
currentFilterMode = 'include';
$('#filter-input-container').show().focus();
});
$('#wiki-help-trigger').click(function() {
$('#wiki-list-help').toggle();
});
// Enter
$('#wiki-filter').keypress(function(e) {
if (e.which === 13) { // 13 is Enter
e.preventDefault();
$('#start').click();
}
});
$('#start').click(function() {
runAudit();
});
// Enter
$(document).on('keypress', '#wiki-filter', function(e) {
if (e.which === 13) {
e.preventDefault();
$('#start').click();
}
});
$('#stop').click(function() {
isRunning = false;
});
}
async function findGrantDate(user, db) {
// Check manual list first
const key = user + "@" + db;
if (customGrantDates[key]) return customGrantDates[key];
// Search Meta-Wiki for steward actions (User:Name@dbname)
// We use User:Name for Meta itself, and User:Name@dbname for other wikis
const title = (db === 'metawiki') ? 'User:' + user : 'User:' + user + '@' + db;
let mPars = {
action: 'query',
list: 'logevents',
letype: 'rights',
letitle: title,
ledir: 'newer',
lelimit: 'max',
formatversion: 2
};
let resM = await robustCall(metaApi, mPars);
let logsM = (resM.query && resM.query.logevents) || [];
for (let ev of logsM) {
let p = ev.params || {};
let nG = extractGroups(p.newgroups || p.add || p[1] || p["1"]);
let oG = extractGroups(p.oldgroups || p.remove || p[0] || p["0"]);
if (nG.indexOf('checkuser') !== -1 && oG.indexOf('checkuser') === -1) {
return ev.timestamp.substring(0, 10);
}
}
return "Unknown";
}
async function runAudit() {
isRunning = true;
let wt = "";
const adminTypes = [
'block', 'delete', 'protect', 'rights', 'merge', 'abusefilter',
'contentmodel', 'import', 'managetags', 'massmessage',
'checkuser-temporary-account', 'ipinfo', 'pagelang',
'renameuser', 'stable', 'gblblock', 'abusefilter-protected-vars'
];
const cuInPublicTypes = ['abusefilterprivatedetails'];
$('#start').prop('disabled', true);
$('#stop').prop('disabled', false);
$('#status-msg').text('Waiting for queue lock...').css("color", "orange");
// Request browser lock to prevent concurrent runs
await navigator.locks.request('global_cu_list_lock', async () => {
wt = "== Global CheckUser List ==\n";
wt += "''Report generated on: " + getReportTimestamp() + "<br />\n";
wt += "Generated with [[testwiki:User:MrJaroslavik/GlobalCheckUserList.js|GlobalCheckUserList.js]]''\n";
const filterText = $('#wiki-filter').val().trim().toLowerCase();
const filterList = filterText ? filterText.split(',').map(function(s) {
return s.trim();
}).filter(function(s) {
return s !== "";
}) : [];
if (currentFilterMode === 'localcu') {
wt += "<br />''Filter applied: Wikis with local CheckUsers''<ref>[[meta:CheckUser policy/Users with CheckUser access]]</ref>\n";
} else if (currentFilterMode === 'include' && filterList.length > 0) {
wt += "<br />''Filter applied: Only these wikis (" + filterList.join(', ') + ")''\n";
}
wt += "\n";
wt += '{| class="wikitable sortable" style="font-size:90%; width:100%;"\n' +
'! User !! CU Since !! Duration !! Last CU Action<ref>Logs: checkuserlog, checkuser-temporary-account, abusefilterprivatedetails</ref> !! ' +
'Last Admin Action<ref>Logs: ' + adminTypes.join(', ') + '</ref> !! ' +
'Last OS Action<ref>Logs: suppress</ref> !! ' +
'Last Logged Action<ref>Logs: all public logged actions</ref> !! Last Edit\n';
const allWikis = Object.keys(globalWikiMap);
let wikisToScan;
if (currentFilterMode === 'include' && filterList.length > 0) {
wikisToScan = allWikis.filter(function(w) {
return filterList.indexOf(w) !== -1;
});
} else if (currentFilterMode === 'localcu') {
wikisToScan = allWikis.filter(function(w) {
return localCUWikis.indexOf(w) !== -1;
});
} else {
wikisToScan = allWikis;
}
$('#bar').attr('max', wikisToScan.length).val(0);
for (let i = 0; i < wikisToScan.length; i++) {
if (!isRunning) break;
const db = wikisToScan[i];
$('#status-msg').text('Auditing ' + db + ' (' + (i + 1) + '/' + wikisToScan.length + ')...').css("color", "#0056b3");
try {
const wikiUrl = globalWikiMap[db];
const localApi = new mw.ForeignApi(wikiUrl + '/w/api.php');
// Find all users currently in the checkuser group
let users = [],
auDone = false,
auCont = null;
while (!auDone && isRunning) {
let auPars = {
action: 'query',
list: 'allusers',
augroup: 'checkuser',
auprop: 'groups',
aulimit: 'max',
formatversion: 2
};
if (auCont) Object.assign(auPars, auCont);
let res = await robustCall(localApi, auPars);
users = users.concat(res.query.allusers || []);
if (res.continue) auCont = res.continue;
else auDone = true;
}
if (users.length > 0) {
const iw = getInterwikiPrefix(db);
wt += '|-\n! colspan="8" style="background:#eaecf0; text-align:center;" | ' +
'[[' + iw + ':|' + db + ']] — [[' + iw + ':Special:ListUsers/checkuser|(list)]] — [[' + iw + ':Special:CheckUserLog|(log)]]\n';
for (const u of users) {
const username = u.name;
const sinceDate = await findGrantDate(username, db, localApi);
// Calculate how many days user has held rights
let days = "-";
if (sinceDate && sinceDate !== "Unknown") {
const cleanDate = sinceDate.substring(0, 10);
const diff = new Date() - new Date(cleanDate);
days = Math.floor(diff / 86400000) + "d";
}
// Check private CheckUser log
const cuLog = await robustCall(localApi, {
action: 'query',
list: 'checkuserlog',
culuser: username,
cullimit: 1,
formatversion: 2
});
let lastCU = (cuLog.query && cuLog.query.checkuserlog && cuLog.query.checkuserlog.entries && cuLog.query.checkuserlog.entries[0]) ?
cuLog.query.checkuserlog.entries[0].timestamp.substring(0, 10) : "Never";
// Fetch public log history
const allUserLogsReq = await robustCall(localApi, {
action: 'query',
list: 'logevents',
leuser: username,
lelimit: 'max',
formatversion: 2
});
const allUserLogs = (allUserLogsReq.query && allUserLogsReq.query.logevents) || [];
// Cross-check for public logs that indicate CU activity
const pubCUEvt = allUserLogs.find(function(ev) {
return cuInPublicTypes.indexOf(ev.type) !== -1;
});
if (pubCUEvt) {
const pubCUTs = pubCUEvt.timestamp.substring(0, 10);
if (lastCU === "Never" || pubCUTs > lastCU) lastCU = pubCUTs;
}
// Identify latest admin action
const admEvent = allUserLogs.find(function(ev) {
return adminTypes.indexOf(ev.type) !== -1;
});
const lastAdm = admEvent ? admEvent.timestamp.substring(0, 10) : "Never";
// Get most recent log entry of any type
const lastGen = allUserLogs.length > 0 ? allUserLogs[0].timestamp.substring(0, 10) : "Never";
// Look for Suppression actions (filtering for setstatus)
const osLog = await robustCall(localApi, {
action: 'query',
list: 'logevents',
leuser: username,
letype: 'suppress',
lelimit: 50, // Scan more entries to find setstatus
formatversion: 2
});
let lastOS = "-";
var uGroups = extractGroups(u.groups);
var hasCurrentOSGroups = (uGroups.indexOf('suppress') !== -1 || uGroups.indexOf('oversight') !== -1);
if (hasCurrentOSGroups) {
lastOS = "Never";
if (osLog.query && osLog.query.logevents) {
// Find first entry that is NOT 'setstatus'
const realOSEvent = osLog.query.logevents.find(function(e) {
// Check if params exists and if subtype is NOT setstatus
return !e.params || (e.params && e.params.subtype !== 'setstatus');
});
if (realOSEvent) {
lastOS = realOSEvent.timestamp.substring(0, 10);
}
}
}
// Check last manual edit date
const edLog = await robustCall(localApi, {
action: 'query',
list: 'usercontribs',
ucuser: username,
uclimit: 1,
formatversion: 2
});
const lastEdit = (edLog.query && edLog.query.usercontribs && edLog.query.usercontribs[0]) ? edLog.query.usercontribs[0].timestamp.substring(0, 10) : "Never";
wt += '|-\n| style="text-align:left; white-space:nowrap;" | [[' + iw + ':Special:Log/' + username + '|' + username + '@' + db + ']] || ' +
sinceDate + ' || ' + days + ' || ' + lastCU + ' || ' + lastAdm + ' || ' + lastOS + ' || ' + lastGen + ' || ' + lastEdit + '\n';
}
}
} catch (err) {
console.error('Audit failed for ' + db, err);
}
$('#bar').val(i + 1);
await sleep(DELAY_MS);
}
wt += '|}\n\n=== References ===\n<references />\n';
$('#out').val(wt);
$('#output-container').show();
$('#status-msg').text('Audit complete!').css("color", "green");
$('#start').prop('disabled', false);
$('#stop').prop('disabled', true);
});
}
setupUI();
});
})();
6qgawo3hpjm5b4uedoy4bien3dgvkor
739286
739282
2026-04-24T19:00:29Z
MrJaroslavik
44012
rv
739286
javascript
text/javascript
// GlobalCheckUserList.js
// -------------------------------------------------------
// Features:
// - Scans current CheckUsers across 800+ projects. With filters "only these" and "Only with local CheckUsers"
// - Checks Checks Meta and Local logs for the grant date.
// - Wikitext output in one sortable table.
// - Nicknames link to local logs; headers link to project, CheckUsers list, and CheckUserLog.
// - Shows last logged actions - CheckUser, Admin, General and if User is OS, shows last OS action
// - UI: Integrated at [[meta:Special:BlankPage/GlobalCheckUserList]].
// - Created with Gemini 3.
// -------------------------------------------------------
(function() {
'use strict';
// Run only on Special:BlankPage/GlobalCheckUserList
const isBlankPage = mw.config.get('wgCanonicalSpecialPageName') === 'Blankpage';
const isOurScript = mw.config.get('wgTitle').includes('GlobalCheckUserList');
if (!isBlankPage || !isOurScript) return;
let globalWikiMap = {};
// List of wikis where CheckUser extension is disabled - https://noc.wikimedia.org/conf/highlight.php?file=dblists/checkuser-disabled.dblist
const disabledCUWikis = [
'aawikibooks', 'abwiktionary', 'advisorywiki', 'akwiktionary', 'angwikiquote',
'angwikisource', 'astwikibooks', 'astwikiquote', 'aswikibooks', 'aswiktionary',
'avwiktionary', 'aywikibooks', 'bhwiktionary', 'biwikibooks', 'biwiktionary',
'bmwikibooks', 'bmwikiquote', 'bmwiktionary', 'bowiktionary', 'chwikibooks',
'chwiktionary', 'cnwikimedia', 'crwikiquote', 'crwiktionary', 'dzwiktionary',
'gawikibooks', 'gnwikibooks', 'gotwikibooks', 'guwikibooks', 'htwikisource',
'huwikinews', 'hzwiki', 'iewikibooks', 'iiwiki', 'internalwiki', 'kjwiki',
'kkwikiquote', 'knwikibooks', 'krwiki', 'krwikiquote', 'kswikibooks',
'kswikiquote', 'kwwikiquote', 'lbwikibooks', 'lbwikiquote', 'lnwikibooks',
'lvwikibooks', 'mhwiktionary', 'mnwikibooks', 'muswiki', 'nahwikibooks',
'nawikibooks', 'nawikiquote', 'ndswikibooks', 'ndswikiquote', 'piwiktionary',
'pswikibooks', 'quwikibooks', 'quwikiquote', 'rmwikibooks', 'rmwiktionary',
'rnwiktionary', 'scwiktionary', 'searchcomwiki', 'snwiktionary', 'spcomwiki',
'swwikibooks', 'thwikinews', 'tkwikibooks', 'tkwikiquote', 'towiktionary',
'transitionteamwiki', 'trwikinews', 'ttwikiquote', 'twwiktionary',
'ugwikibooks', 'ugwikiquote', 'vowikibooks', 'vowikiquote', 'wawikibooks',
'wikimania2005wiki', 'wikimania2006wiki', 'wikimania2007wiki',
'wikimania2009wiki', 'wikimania2015wiki', 'wowikiquote', 'xhwikibooks',
'xhwiktionary', 'yowikibooks', 'yowiktionary', 'zawikibooks', 'zawikiquote',
'zawiktionary', 'zh_min_nanwikibooks', 'zuwikibooks',
// Closed/Unavailable projects (added manually)
'aawiktionary', 'akwikibooks', 'amwikiquote', 'angwikibooks', 'bgwikinews',
'bowikibooks', 'chowiki', 'cowikibooks', 'cowikiquote', 'gawikiquote',
'howiki', 'ikwiktionary', 'kywikibooks', 'mhwiki', 'miwikibooks',
'mywikibooks', 'nawiki', 'nzwikimedia', 'pa_uswikimedia', 'qualitywiki',
'ruwikimedia', 'sdwikinews', 'sewikibooks', 'simplewikibooks', 'strategywiki',
'suwikibooks', 'tenwiki', 'usabilitywiki', 'uzwikibooks', 'wikimania2010wiki',
'wikimania2011wiki', 'wikimania2012wiki', 'wikimania2013wiki', 'wikimania2014wiki',
'wikimania2016wiki', 'wikimania2017wiki', 'wikimania2018wiki', 'zh_min_nanwikiquote'
];
// Projects known that have local CheckUsers - https://meta.wikimedia.org/wiki/CheckUser_policy/Users_with_CheckUser_access
const localCUWikis = [
'arwiki', 'bnwiki', 'cawiki', 'cswiki', 'dawiki', 'nlwiki', 'enwikibooks',
'enwiki', 'enwikivoyage', 'enwiktionary', 'fiwiki', 'frwiki', 'dewiki',
'hewiki', 'huwiki', 'idwiki', 'itwiki', 'jawiki', 'kowiki', 'fawiki',
'plwiki', 'ptwiki', 'ruwiki', 'srwiki', 'simplewiki', 'slwiki', 'eswiki',
'svwiki', 'thwiki', 'trwiki', 'ukwiki', 'viwiki', 'commonswiki',
'specieswiki', 'metawiki', 'wikidatawiki'
];
// Fetch all available wikis and filter out restricted ones
function loadGlobalWikiMap() {
const api = new mw.Api();
return api.get({
action: 'sitematrix',
format: 'json',
smtype: 'language|special',
smlangprop: 'site',
smsiteprop: 'dbname|url',
formatversion: 2,
origin: '*'
}).then(function(data) {
const matrix = data.sitematrix;
const wikiMap = {};
Object.keys(matrix).forEach(function(key) {
const entry = matrix[key];
if (entry.site && Array.isArray(entry.site)) {
entry.site.forEach(function(site) {
if (site.private || site.fishbowl || disabledCUWikis.indexOf(site.dbname) !== -1) return;
wikiMap[site.dbname] = site.url;
});
}
});
if (matrix.specials && Array.isArray(matrix.specials)) {
matrix.specials.forEach(function(special) {
if (special.private || special.fishbowl || disabledCUWikis.indexOf(special.dbname) !== -1) return;
wikiMap[special.dbname] = special.url;
});
}
return wikiMap;
});
}
// Convert database names to interwiki prefixes for table links
function getInterwikiPrefix(db) {
const specialMap = {
'commonswiki': 'c', 'metawiki': 'm', 'wikidatawiki': 'd',
'wikifunctionswiki': 'f', 'mediawikiwiki': 'mw', 'specieswiki': 'species',
'sourceswiki': 'oldwikisource', 'foundationwiki': 'foundation',
'incubatorwiki': 'incubator', 'outreachwiki': 'outreach',
'betawikiversity': 'betawikiversity', 'be_x_oldwiki': 'be-tarask',
'zh_classicalwiki': 'lzh', 'zh_min_nanwiki': 'nan', 'zh_yuewiki': 'yue'
};
if (specialMap[db]) return specialMap[db];
const name = db.replace(/_/g, '-');
if (name.endsWith('wikisource')) return 's:' + name.slice(0, -10);
if (name.endsWith('wikiversity')) return 'v:' + name.slice(0, -11);
if (name.endsWith('wiktionary')) return 'wikt:' + name.slice(0, -10);
if (name.endsWith('wikivoyage')) return 'voy:' + name.slice(0, -10);
if (name.endsWith('wikibooks')) return 'b:' + name.slice(0, -9);
if (name.endsWith('wikiquote')) return 'q:' + name.slice(0, -9);
if (name.endsWith('wikinews')) return 'n:' + name.slice(0, -8);
if (name.endsWith('wikimedia')) return 'wm' + name.slice(0, -9);
if (name.endsWith('wiki')) return 'w:' + name.slice(0, -4);
return 'w:' + name;
}
// Manual overrides for missing grant dates
// Format: "Username@dbname": "YYYY-MM-DD"
const customGrantDates = {
"RiazACU@bnwiki": "2022-05-13",
"KnudW@dawiki": "2017-03-23",
"Dbeef@enwiki": "2025-03-07",
"MarcGarver@enwikibooks": "2012-03-26",
"Jake Park@eswiki": "2021-07-03",
"Nohirara@idwiki": "2016-06-03",
"Superspritz@itwiki": "2012-12-06",
"Uncitoyen@trwiki": "2020-03-31"
};
// Helper function to pause execution (useful for preventing API limits)
const DELAY_MS = 500;
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
// Makes API calls with automatic retries for "429 Too Many Requests" errors
async function robustCall(api, params) {
let retries = 0;
const maxRetries = 3;
while (retries < maxRetries) {
try {
// Attempt to fetch data from the MediaWiki API
return await api.get(params);
} catch (err) {
// Check if the server is actively rejecting requests due to high load (HTTP 429)
// Note: Optional chaining (?.) is avoided for broad MediaWiki editor compatibility
if (err && err.status === 429) {
retries++;
// Read the 'Retry-After' header provided by the server, or default to a scaling wait time
const waitTime = parseInt(err.xhr && err.xhr.getResponseHeader('Retry-After')) || (30 * retries);
// Notify the user via the UI that the script is pausing (supports both UI container types)
$('#status-msg, #cu-loader').html(
`<span style="color:orange; font-weight:bold;">Server is busy! Pausing for ${waitTime} seconds...</span>`
).show();
// Pause execution for the requested duration before trying again
await sleep(waitTime * 1000);
} else {
// If it is a different error (network failure, 500 Internal Error, etc.), stop and throw
throw err;
}
}
}
// Fail completely if max retries are exceeded
throw new Error("Failed to reach API after multiple attempts due to server limits.");
}
// Returns a standardized UTC timestamp for report headers (YYYY-MM-DD HH:MM)
function getReportTimestamp() {
return new Date().toISOString().replace('T', ' ').substring(0, 16) + ' (UTC)';
}
// Process various API response formats into a clean array of group names
const extractGroups = function(data) {
if (!data) return [];
var res = [];
if (Array.isArray(data)) {
res = data;
} else if (typeof data === 'object') {
res = Object.values(data);
} else if (typeof data === 'string') {
res = data.split(',').map(function(s) {
return s.trim();
});
}
return res.map(function(g) {
if (typeof g !== 'string') return g;
return g.replace(/\s+/g, '').toLowerCase();
});
};
mw.loader.using(['mediawiki.api', 'mediawiki.ForeignApi']).then(async function() {
$('#mw-content-text').html('<strong>GlobalCheckUserList.js: Mapping wikis...</strong>');
globalWikiMap = await loadGlobalWikiMap();
const metaApi = new mw.ForeignApi('https://meta.wikimedia.org/w/api.php');
let isRunning = false;
let currentFilterMode = 'all';
function setupUI() {
$('#firstHeading').text('GlobalCheckUserList');
$('#mw-content-text').empty().append(`
<div style="border:1px solid #a2a9b1; padding:15px; background:#f8f9fa;">
<p>Audits current CheckUsers across Wikimedia projects. Reports are grouped into one single table.</p>
<div style="margin-bottom:10px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>Wiki Filter:</strong>
<label style="cursor:pointer;"><input type="radio" name="w-mode" id="btn-all" checked> All</label>
<label style="cursor:pointer;"><input type="radio" name="w-mode" id="btn-localcu"> Local CU Wikis</label>
<label style="cursor:pointer;"><input type="radio" name="w-mode" id="btn-only"> Only these</label>
<span id="wiki-help-trigger" style="cursor:help; background:#36c; color:#fff; border-radius:50%; width:18px; height:18px; display:inline-block; text-align:center; font-weight:bold;" title="Show available DB names">?</span>
</div>
<div id="filter-input-container" style="display:none; margin-bottom:15px;">
<input id="wiki-filter" type="text" style="width:100%;" placeholder="dbname1, dbname2...">
</div>
<div id="wiki-list-help" style="display:none; margin-bottom:10px; padding:10px; background:#fff; border:1px solid #a2a9b1; font-size:11px; max-height:120px; overflow-y:auto; font-family:monospace;">
<strong>Available database names:</strong><br>${Object.keys(globalWikiMap).sort().join(', ')}
</div>
<div style="margin-top:20px;">
<button id="start" class="mw-ui-button mw-ui-progressive">Run Audit</button>
<button id="stop" class="mw-ui-button mw-ui-destructive" disabled>Stop</button>
</div>
<div id="status-msg" style="margin-top:10px; font-weight:bold; color:#0056b3;">Ready.</div>
<div style="margin-top:5px;">
<progress id="bar" value="0" max="${Object.keys(globalWikiMap).length}" style="width:100%"></progress>
</div>
<div id="output-container" style="display:none; margin-top:15px;">
<strong>Wikitext Report:</strong>
<textarea id="out" style="width:100%; height:450px; font-family:monospace; font-size:11px; padding:5px; border:1px solid #c8ccd1;"></textarea>
</div>
</div>
`);
$('#btn-all').click(function() {
currentFilterMode = 'all';
$('#filter-input-container').hide();
});
$('#btn-localcu').click(function() {
currentFilterMode = 'localcu';
$('#filter-input-container').hide();
});
$('#btn-only').click(function() {
currentFilterMode = 'include';
$('#filter-input-container').show().focus();
});
$('#wiki-help-trigger').click(function() {
$('#wiki-list-help').toggle();
});
$('#start').click(function() {
runAudit();
});
$('#stop').click(function() {
isRunning = false;
});
}
async function findGrantDate(user, db) {
// Check manual list first
const key = user + "@" + db;
if (customGrantDates[key]) return customGrantDates[key];
// Search Meta-Wiki for steward actions (User:Name@dbname)
// We use User:Name for Meta itself, and User:Name@dbname for other wikis
const title = (db === 'metawiki') ? 'User:' + user : 'User:' + user + '@' + db;
let mPars = {
action: 'query',
list: 'logevents',
letype: 'rights',
letitle: title,
ledir: 'newer',
lelimit: 'max',
formatversion: 2
};
let resM = await robustCall(metaApi, mPars);
let logsM = (resM.query && resM.query.logevents) || [];
for (let ev of logsM) {
let p = ev.params || {};
let nG = extractGroups(p.newgroups || p.add || p[1] || p["1"]);
let oG = extractGroups(p.oldgroups || p.remove || p[0] || p["0"]);
if (nG.indexOf('checkuser') !== -1 && oG.indexOf('checkuser') === -1) {
return ev.timestamp.substring(0, 10);
}
}
return "Unknown";
}
async function runAudit() {
isRunning = true;
let wt = "";
const adminTypes = [
'block', 'delete', 'protect', 'rights', 'merge', 'abusefilter',
'contentmodel', 'import', 'managetags', 'massmessage',
'checkuser-temporary-account', 'ipinfo', 'pagelang',
'renameuser', 'stable', 'gblblock', 'abusefilter-protected-vars'
];
const cuInPublicTypes = ['abusefilterprivatedetails'];
$('#start').prop('disabled', true);
$('#stop').prop('disabled', false);
$('#status-msg').text('Waiting for queue lock...').css("color", "orange");
// Request browser lock to prevent concurrent runs
await navigator.locks.request('global_cu_list_lock', async () => {
wt = "== Global CheckUser List ==\n";
wt += "''Report generated on: " + getReportTimestamp() + "<br />\n";
wt += "Generated with [[testwiki:User:MrJaroslavik/GlobalCheckUserList.js|GlobalCheckUserList.js]]''\n";
const filterText = $('#wiki-filter').val().trim().toLowerCase();
const filterList = filterText ? filterText.split(',').map(function(s) {
return s.trim();
}).filter(function(s) {
return s !== "";
}) : [];
if (currentFilterMode === 'localcu') {
wt += "<br />''Filter applied: Wikis with local CheckUsers''<ref>[[meta:CheckUser policy/Users with CheckUser access]]</ref>\n";
} else if (currentFilterMode === 'include' && filterList.length > 0) {
wt += "<br />''Filter applied: Only these wikis (" + filterList.join(', ') + ")''\n";
}
wt += "\n";
wt += '{| class="wikitable sortable" style="font-size:90%; width:100%;"\n' +
'! User !! CU Since !! Duration !! Last CU Action<ref>Logs: checkuserlog, checkuser-temporary-account, abusefilterprivatedetails</ref> !! ' +
'Last Admin Action<ref>Logs: ' + adminTypes.join(', ') + '</ref> !! ' +
'Last OS Action<ref>Logs: suppress</ref> !! ' +
'Last Logged Action<ref>Logs: all public logged actions</ref> !! Last Edit\n';
const allWikis = Object.keys(globalWikiMap);
let wikisToScan;
if (currentFilterMode === 'include' && filterList.length > 0) {
wikisToScan = allWikis.filter(function(w) {
return filterList.indexOf(w) !== -1;
});
} else if (currentFilterMode === 'localcu') {
wikisToScan = allWikis.filter(function(w) {
return localCUWikis.indexOf(w) !== -1;
});
} else {
wikisToScan = allWikis;
}
$('#bar').attr('max', wikisToScan.length).val(0);
for (let i = 0; i < wikisToScan.length; i++) {
if (!isRunning) break;
const db = wikisToScan[i];
$('#status-msg').text('Auditing ' + db + ' (' + (i + 1) + '/' + wikisToScan.length + ')...').css("color", "#0056b3");
try {
const wikiUrl = globalWikiMap[db];
const localApi = new mw.ForeignApi(wikiUrl + '/w/api.php');
// Find all users currently in the checkuser group
let users = [],
auDone = false,
auCont = null;
while (!auDone && isRunning) {
let auPars = {
action: 'query',
list: 'allusers',
augroup: 'checkuser',
auprop: 'groups',
aulimit: 'max',
formatversion: 2
};
if (auCont) Object.assign(auPars, auCont);
let res = await robustCall(localApi, auPars);
users = users.concat(res.query.allusers || []);
if (res.continue) auCont = res.continue;
else auDone = true;
}
if (users.length > 0) {
const iw = getInterwikiPrefix(db);
wt += '|-\n! colspan="8" style="background:#eaecf0; text-align:center;" | ' +
'[[' + iw + ':|' + db + ']] — [[' + iw + ':Special:ListUsers/checkuser|(list)]] — [[' + iw + ':Special:CheckUserLog|(log)]]\n';
for (const u of users) {
const username = u.name;
const sinceDate = await findGrantDate(username, db, localApi);
// Calculate how many days user has held rights
let days = "-";
if (sinceDate && sinceDate !== "Unknown") {
const cleanDate = sinceDate.substring(0, 10);
const diff = new Date() - new Date(cleanDate);
days = Math.floor(diff / 86400000) + "d";
}
// Check private CheckUser log
const cuLog = await robustCall(localApi, {
action: 'query',
list: 'checkuserlog',
culuser: username,
cullimit: 1,
formatversion: 2
});
let lastCU = (cuLog.query && cuLog.query.checkuserlog && cuLog.query.checkuserlog.entries && cuLog.query.checkuserlog.entries[0]) ?
cuLog.query.checkuserlog.entries[0].timestamp.substring(0, 10) : "Never";
// Fetch public log history
const allUserLogsReq = await robustCall(localApi, {
action: 'query',
list: 'logevents',
leuser: username,
lelimit: 'max',
formatversion: 2
});
const allUserLogs = (allUserLogsReq.query && allUserLogsReq.query.logevents) || [];
// Cross-check for public logs that indicate CU activity
const pubCUEvt = allUserLogs.find(function(ev) {
return cuInPublicTypes.indexOf(ev.type) !== -1;
});
if (pubCUEvt) {
const pubCUTs = pubCUEvt.timestamp.substring(0, 10);
if (lastCU === "Never" || pubCUTs > lastCU) lastCU = pubCUTs;
}
// Identify latest admin action
const admEvent = allUserLogs.find(function(ev) {
return adminTypes.indexOf(ev.type) !== -1;
});
const lastAdm = admEvent ? admEvent.timestamp.substring(0, 10) : "Never";
// Get most recent log entry of any type
const lastGen = allUserLogs.length > 0 ? allUserLogs[0].timestamp.substring(0, 10) : "Never";
// Look for Suppression actions (filtering for setstatus)
const osLog = await robustCall(localApi, {
action: 'query',
list: 'logevents',
leuser: username,
letype: 'suppress',
lelimit: 50, // Scan more entries to find setstatus
formatversion: 2
});
let lastOS = "-";
var uGroups = extractGroups(u.groups);
var hasCurrentOSGroups = (uGroups.indexOf('suppress') !== -1 || uGroups.indexOf('oversight') !== -1);
if (hasCurrentOSGroups) {
lastOS = "Never";
if (osLog.query && osLog.query.logevents) {
// Find first entry that is NOT 'setstatus'
const realOSEvent = osLog.query.logevents.find(function(e) {
// Check if params exists and if subtype is NOT setstatus
return !e.params || (e.params && e.params.subtype !== 'setstatus');
});
if (realOSEvent) {
lastOS = realOSEvent.timestamp.substring(0, 10);
}
}
}
// Check last manual edit date
const edLog = await robustCall(localApi, {
action: 'query',
list: 'usercontribs',
ucuser: username,
uclimit: 1,
formatversion: 2
});
const lastEdit = (edLog.query && edLog.query.usercontribs && edLog.query.usercontribs[0]) ? edLog.query.usercontribs[0].timestamp.substring(0, 10) : "Never";
wt += '|-\n| style="text-align:left; white-space:nowrap;" | [[' + iw + ':Special:Log/' + username + '|' + username + '@' + db + ']] || ' +
sinceDate + ' || ' + days + ' || ' + lastCU + ' || ' + lastAdm + ' || ' + lastOS + ' || ' + lastGen + ' || ' + lastEdit + '\n';
}
}
} catch (err) {
console.error('Audit failed for ' + db, err);
}
$('#bar').val(i + 1);
await sleep(DELAY_MS);
}
wt += '|}\n\n=== References ===\n<references />\n';
$('#out').val(wt);
$('#output-container').show();
$('#status-msg').text('Audit complete!').css("color", "green");
$('#start').prop('disabled', false);
$('#stop').prop('disabled', true);
});
}
setupUI();
});
})();
gs9uu5q95n132gekuk96o5jths321w2
Wikipedia:Requests/Permissions/Wooze
4
175008
739235
2026-04-24T12:09:58Z
Wooze
54732
Created page with "<!--@Bureaucrats: If you close this request as {{Done}} add {{Request-done|1=~~~~|2=Closing rationale}} to the top of the page. If you close it as {{Not done}}, add {{Request-not done|1=~~~~|2=Closing rationale}} there, then close with {{Request closed}}.--> === [[User:{{subst:REVISIONUSER}}|{{subst:REVISIONUSER}}]] === * {{User3|{{subst:REVISIONUSER}}}}, [[Special:CentralAuth/{{subst:REVISIONUSER}}|global contribs]] ~~~~~ * '''Motive for request:''' Hello, I’m Wooze,..."
739235
wikitext
text/x-wiki
<!--@Bureaucrats: If you close this request as {{Done}} add {{Request-done|1=~~~~|2=Closing rationale}} to the top of the page. If you close it as {{Not done}}, add {{Request-not done|1=~~~~|2=Closing rationale}} there, then close with {{Request closed}}.-->
=== [[User:Wooze|Wooze]] ===
* {{User3|Wooze}}, [[Special:CentralAuth/Wooze|global contribs]] 12:09, 24 April 2026 (UTC)
* '''Motive for request:''' Hello, I’m Wooze, a sysop on the Turkish Wikipedia. I would like to request sysop access on TestWiki in order to test a tool I am developing. Thanks. [[User:Wooze|Wooze]] ([[User talk:Wooze|talk]]) 12:09, 24 April 2026 (UTC)
* '''Requested rights:''' Sysop
* '''Comments:''' <!-- Comments of other users -->
<!-- DO NOT FORGET to transclude your request at THE TOP of [[Wikipedia:Requests/Permissions]] or nobody will see it! -->
[[Category:!Requests]]
[[Category:Really big category]]
__NOINDEX__
5nx3p5jkgauc405wuqw748mnpv0vred
739250
739235
2026-04-24T12:35:38Z
EPIC
44279
done
739250
wikitext
text/x-wiki
{{Request-done|1=[[User:EPIC|EPIC]] ([[User talk:EPIC|talk]]) 12:35, 24 April 2026 (UTC)|2=Valid use case, sysop on a production wiki.}}
=== [[User:Wooze|Wooze]] ===
* {{User3|Wooze}}, [[Special:CentralAuth/Wooze|global contribs]] 12:09, 24 April 2026 (UTC)
* '''Motive for request:''' Hello, I’m Wooze, a sysop on the Turkish Wikipedia. I would like to request sysop access on TestWiki in order to test a tool I am developing. Thanks. [[User:Wooze|Wooze]] ([[User talk:Wooze|talk]]) 12:09, 24 April 2026 (UTC)
* '''Requested rights:''' Sysop
* '''Comments:''' <!-- Comments of other users -->
<!-- DO NOT FORGET to transclude your request at THE TOP of [[Wikipedia:Requests/Permissions]] or nobody will see it! -->
[[Category:!Requests]]
[[Category:Really big category]]
__NOINDEX__
{{Request closed}}
fc5hgmdezps8xh6nact7a9liaolmtq1
User:Cryptocurrency777
2
175011
739268
2026-04-24T15:39:59Z
Cryptocurrency777
73698
Created page with "{{DISPLAYTITLE:User:<span style="color:Lavender;">'''Cryptocurrency777'''</span>}}"
739268
wikitext
text/x-wiki
{{DISPLAYTITLE:User:<span style="color:Lavender;">'''Cryptocurrency777'''</span>}}
fpda5poxzi9aca8t8jpixkkpcw2sd1g
739269
739268
2026-04-24T15:40:56Z
Cryptocurrency777
73698
739269
wikitext
text/x-wiki
{{DISPLAYTITLE:User:<span style="color:Lavender;">'''Cryptocurrency777'''</span>}}
😤
pya3mnzpmdee2urik2s8lrbuctz46mw
User:MMunyoki (WMF)/Starter kit/Welcome banner
2
175012
739270
2026-04-24T16:12:12Z
MMunyoki (WMF)
53374
Initialised by StarterKit tool — ready for translation
739270
wikitext
text/x-wiki
<div style="text-align: center; font-family: 'Linux Libertine', Georgia, Times, serif; margin: 1.5em 0;">
<span style="font-size: 2.3em; line-height: 1.2;">Welcome to {{#language:{{PAGELANGUAGE}}}} Wikipedia</span><br/>
<span style="font-size: 1.1em; color: #54595d;">The free encyclopedia that anyone can edit</span><br/>
<span style="font-size: 0.95em; color: #72777d; margin-top: 0.5em; display: inline-block;">{{NUMBEROFACTIVEUSERS}} active editors • '''{{NUMBEROFARTICLES}}''' articles in this {{SITENAME}}</span>
</div>
<noinclude>[[Category:Starter kit templates]]</noinclude>
2gaz0l1265uxned195lmw3a6r0oocxa
Castries
0
175013
739273
2026-04-24T17:30:35Z
Cryptocurrency777
73698
test
739273
wikitext
text/x-wiki
'''Castries''' (/kəˈstriːz/) is the capital and largest city of [[Saint Lucia]], an island country in the [[Caribbean]]. The urban area has a population of approximately 20,000, while the eponymous district has a population of just under 70,000, as of May 2013. The city covers 80 km2 (31 sq mi).
Castries is on a flood plain and is built on reclaimed land. It houses the seat of government and the head offices of many foreign and local businesses. The city is laid out in a grid pattern. Its sheltered harbour receives cargo vessels, ferries and cruise ships. It houses duty-free shopping facilities such as Point Seraphine and La Place Carenage.
altmr6ygnypkg2wxby01mfap2w11n29
Event:Testdulu
1728
175014
739296
2026-04-25T06:51:24Z
Annidafattiya
73701
Created page with "ini demo untuk bikin event page"
739296
wikitext
text/x-wiki
ini demo untuk bikin event page
516qgza7kzd2oc4e0ir997i53op57xc