Wikipedia
testwiki
https://test.wikipedia.org/wiki/Main_Page
MediaWiki 1.46.0-wmf.23
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
Translate test
0
80899
737775
737613
2026-04-12T20:35:12Z
Tanbiruzzaman
57483
737775
wikitext
text/x-wiki
<languages/>
{{int:Project-localized-name-metawiki}}
{{TNT|Hubs|banner|dev=y|admin=y}}
[[File:MediaWiki-Manual bookstyle-transparent.png|{{dir|{{pagelang}}|left|right}}|175px|<translate><!--T:1-->
MediaWiki documentation</translate>]]
<translate>
<!--T:2-->
This is a '''technical manual for the MediaWiki software'''.</translate>
<translate>
<!--T:3-->
It contains information for '''developers''' and '''system administrators''' on '''installing''', '''managing''' and '''developing''' for the MediaWiki software.
<!--T:4-->
This manual is '''not for end users''' of MediaWiki.</translate>
<translate>
<!--T:5-->
If you are looking for '''documentation to help you use MediaWiki, read the [[<tvar|help>Special:MyLanguage/Help:Contents</>|MediaWiki Handbook]].'''
==Main sections== <!--T:6-->
</translate>
{{TNT|merge|Sysadmin hub|Developer hub}}
{| style="background:transparent;"
| style="width:50%; vertical-align:top; border:1px solid #aaa; padding: .5em 1.5em;" |
<translate>
=== For system administrators === <!--T:7-->
</translate>
; [[Special:MyLanguage/Manual:Installation guide|<translate><!--T:8-->
Installation</translate>]]
: <translate><!--T:9-->
Guide to setting up a new MediaWiki installation.</translate>
: {{ll|Download}} | [[Special:MyLanguage/Manual:Installing MediaWiki|<translate><!--T:10-->
Installing</translate>]] | [[Special:MyLanguage/Manual:Configuring MediaWiki|<translate><!--T:11-->
Initial configuration</translate>]]
: [[Special:MyLanguage/Manual:Configuration settings (alphabetical)|<translate><!--T:12-->
Alphabetical list of settings</translate>]] | [[Special:MyLanguage/Manual:Configuration settings|<translate><!--T:13-->
Settings listed by function</translate>]]
; {{ll|Manual:System administration|nsp=0}}
: <translate><!--T:14-->
Guide to do administrative tasks on your wiki.</translate>
: [[Special:MyLanguage/Manual:Backing up a wiki|<translate><!--T:15-->
Backing up</translate>]] | {{ll|Manual:Maintenance scripts|nsp=0}}
; {{ll|Manual:Upgrading|nsp=0}}
: <translate><!--T:16-->
Guide to upgrade your MediaWiki installation.</translate>
''<translate><!--T:17-->
More on <tvar|hub>{{ll|Sysadmin hub}}</>.</translate>''
| style="width:50%; vertical-align:top; border:1px solid #aaa; padding: .5em 1.5em;" |
<translate>
=== For developers === <!--T:18-->
</translate>
; <translate><!--T:19-->
Architecture</translate>
: <translate><!--T:20-->
An overview of the key parts of MediaWiki's source code.</translate>
: {{ll|Manual:Code|nsp=0}} | {{ll|Manual:Global object variables|nsp=0}} | [https://doc.wikimedia.org/ <translate><!--T:21-->
Doxygen-generated documentation</translate>]
; {{ll|Manual:Database layout|nsp=0}}
: <translate><!--T:22-->
Details about the database architecture used by MediaWiki.</translate>
: <translate><!--T:23-->
<tvar|mysql>{{ll|Manual:MySQL|nsp=0}}</> | <tvar|pgsql>{{ll|Manual:PostgreSQL|nsp=0}}</> | <tvar|sqlite>{{ll|Manual:SQLite|nsp=0}}</> | <tvar|db2>{{ll|Manual:IBM DB2|nsp=0}}</> engines</translate>
; {{ll|Manual:Developing extensions|nsp=0}}
: <translate><!--T:24-->
An overview of the ways to create a new MediaWiki extension.</translate>
: {{ll|Manual:Hooks|nsp=0}} | {{ll|Manual:Parser functions|nsp=0}} | [[Special:MyLanguage/Manual:Tag extensions|<translate><!--T:25-->
Tag</translate>]] | [[Special:MyLanguage/Manual:Special pages|<translate><!--T:26-->
Special page</translate>]] | {{ll|Manual:Skins|nsp=0}}
; <translate><!--T:27-->
Web access</translate>
: <translate><!--T:28-->
Details about [[w:Query string|query string]] parameters that can be passed to MediaWiki access scripts.</translate>
: {{ll|Manual:Parameters to index.php|index.php}} | {{ll|API:Main page|api.php}}
''<translate><!--T:29-->
More on <tvar|hub>{{ll|Developer hub}}</>.</translate>''
|}
<translate>
=== Others === <!--T:30-->
</translate>
; [[Special:MyLanguage/Manual:FAQ|<translate><!--T:31-->
MediaWiki FAQ</translate>]]
: <translate><!--T:32-->
Frequently asked questions about MediaWiki.</translate>
<translate>
== Browsing the manual == <!--T:33-->
<!--T:34-->
There are multiple ways to browse through the documentation.</translate>
<translate>
<!--T:35-->
Readers having trouble finding a particular topic in the section above may find the following ways of browsing to be helpful.
</translate>
* [[Special:Allpages/Manual:]] - <translate><!--T:36-->
An automatically generated list of all pages in the Manual: namespace.</translate>
* [[:Category:Manual]] - <translate><!--T:37-->
the top-level Manual category</translate>
<translate>
== Improving the manual == <!--T:38-->
<!--T:39-->
* There are still a lot of holes in this manual!</translate> <translate><!--T:40-->
See the [[<tvar|todo>Special:MyLanguage/Manual:Contents/To do</>|'to do' page]] for details.</translate>
<translate>
<!--T:41-->
* There is still content on <tvar|url>http://meta.wikimedia.org</> which may need to be migrated.</translate> <translate><!--T:42-->
If you can't find information on a particular issue in this documentation, please visit <tvar|faq>[[meta:MediaWiki_FAQ]]</> and <tvar|help>[[meta:Help:Contents]]</>.</translate>
<translate>
<!--T:43-->
* '''[[Project:Manual]]''' is a place to discuss and co-ordinate the development of the Manual: namespace.</translate>
<translate>
<!--T:44-->
* See also <tvar|issues>[[Project:Current issues]]</>.
== MediaWiki Virtual Library == <!--T:45-->
<!--T:46-->
* '''[[<tvar|cat>:Category:MediaWiki Virtual Library (MVL)</>|MediaWiki Virtual Library]] (MVL)''' has manuals, guides, and collections of selected articles in PDF form you create on-the-fly
</translate>
[[File:Incubator-notext.png|alt=foobaz|thumb|foobar]]
{| style="background:transparent;"
| style="width:50%; vertical-align:top; border:1px solid #aaa; padding: .5em 1.5em;" |
=== For system administrators ===
; [[Special:MyLanguage/Manual:Installation guide|Installation]]
: Guide to setting up a new MediaWiki installation.
: {{ll|Download}} | [[Special:MyLanguage/Manual:Installing MediaWiki|
Installing]] | [[Special:MyLanguage/Manual:Configuring MediaWiki|
Initial configuration]]
: [[Special:MyLanguage/Manual:Configuration settings (alphabetical)|
Alphabetical list of settings]] | [[Special:MyLanguage/Manual:Configuration settings|
Settings listed by function]]
; {{ll|Manual:System administration|nsp=0}}
: Guide to do administrative tasks on your wiki.
: [[Special:MyLanguage/Manual:Backing up a wiki|Backing up]] | {{ll|Manual:Maintenance scripts|nsp=0}}
; {{ll|Manual:Upgrading|nsp=0}}
:
Guide to upgrade your MediaWiki installation.
''More on {{ll|Sysadmin hub}}.''
| style="width:50%; vertical-align:top; border:1px solid #aaa; padding: .5em 1.5em;" |
=== For developers ===
; Architecture
: An overview of the key parts of MediaWiki's source code.
: {{ll|Manual:Code|nsp=0}} | {{ll|Manual:Global object variables|nsp=0}} | [https://doc.wikimedia.org/
Doxygen-generated documentation]
; {{ll|Manual:Database layout|nsp=0}}
: Details about the database architecture used by MediaWiki.
: {{ll|Manual:MySQL|nsp=0}} | {{ll|Manual:PostgreSQL|nsp=0}} | {{ll|Manual:SQLite|nsp=0}} | {{ll|Manual:IBM DB2|nsp=0}} engines
; {{ll|Manual:Developing extensions|nsp=0}}
: An overview of the ways to create a new MediaWiki extension.
: {{ll|Manual:Hooks|nsp=0}} | {{ll|Manual:Parser functions|nsp=0}} | [[Special:MyLanguage/Manual:Tag extensions|Tag]] | [[Special:MyLanguage/Manual:Special pages|Special page]] | {{ll|Manual:Skins|nsp=0}}
; Web access
: Details about [[w:Query string|query string]] parameters that can be passed to MediaWiki access scripts.
: {{ll|Manual:Parameters to index.php|index.php}} | {{ll|API:Main page|api.php}}
''More on {{ll|Developer hub}}.''
|}
[[File:Soldering a 0805.jpg|alt=foobaz|thumb|foobar]]
[[Category:MediaWiki technical documentation{{translation}}| ]]
[[Category:Manual{{translation}}| ]]
hz9r8vz5s2dz74yy183jmbu82sp5sbw
Translate test/bn
0
80903
737776
737616
2026-04-12T20:35:35Z
FuzzyBot
18251
Updating to match new version of source page
737776
wikitext
text/x-wiki
<languages/>
{{int:Project-localized-name-metawiki}}
{{TNT|Hubs|banner|dev=y|admin=y}}
[[File:MediaWiki-Manual bookstyle-transparent.png|{{dir|{{pagelang}}|left|right}}|175px|<div class="mw-translate-fuzzy">
test
</div>]]
<div lang="en" dir="ltr" class="mw-content-ltr">
This is a '''technical manual for the MediaWiki software'''.
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
It contains information for '''developers''' and '''system administrators''' on '''installing''', '''managing''' and '''developing''' for the MediaWiki software.
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
This manual is '''not for end users''' of MediaWiki.
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
If you are looking for '''documentation to help you use MediaWiki, read the [[Special:MyLanguage/Help:Contents|MediaWiki Handbook]].'''
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
==Main sections==
</div>
{{TNT|merge|Sysadmin hub|Developer hub}}
{| style="background:transparent;"
| style="width:50%; vertical-align:top; border:1px solid #aaa; padding: .5em 1.5em;" |
<div lang="en" dir="ltr" class="mw-content-ltr">
=== For system administrators ===
</div>
; [[Special:MyLanguage/Manual:Installation guide|<div lang="en" dir="ltr" class="mw-content-ltr">
Installation
</div>]]
: <div lang="en" dir="ltr" class="mw-content-ltr">
Guide to setting up a new MediaWiki installation.
</div>
: {{ll|Download}} | [[Special:MyLanguage/Manual:Installing MediaWiki|<div lang="en" dir="ltr" class="mw-content-ltr">
Installing
</div>]] | [[Special:MyLanguage/Manual:Configuring MediaWiki|<div lang="en" dir="ltr" class="mw-content-ltr">
Initial configuration
</div>]]
: [[Special:MyLanguage/Manual:Configuration settings (alphabetical)|<div lang="en" dir="ltr" class="mw-content-ltr">
Alphabetical list of settings
</div>]] | [[Special:MyLanguage/Manual:Configuration settings|<div lang="en" dir="ltr" class="mw-content-ltr">
Settings listed by function
</div>]]
; {{ll|Manual:System administration|nsp=0}}
: <div lang="en" dir="ltr" class="mw-content-ltr">
Guide to do administrative tasks on your wiki.
</div>
: [[Special:MyLanguage/Manual:Backing up a wiki|<div lang="en" dir="ltr" class="mw-content-ltr">
Backing up
</div>]] | {{ll|Manual:Maintenance scripts|nsp=0}}
; {{ll|Manual:Upgrading|nsp=0}}
: <div lang="en" dir="ltr" class="mw-content-ltr">
Guide to upgrade your MediaWiki installation.
</div>
''<div lang="en" dir="ltr" class="mw-content-ltr">
More on {{ll|Sysadmin hub}}.
</div>''
| style="width:50%; vertical-align:top; border:1px solid #aaa; padding: .5em 1.5em;" |
<div lang="en" dir="ltr" class="mw-content-ltr">
=== For developers ===
</div>
; <div lang="en" dir="ltr" class="mw-content-ltr">
Architecture
</div>
: <div lang="en" dir="ltr" class="mw-content-ltr">
An overview of the key parts of MediaWiki's source code.
</div>
: {{ll|Manual:Code|nsp=0}} | {{ll|Manual:Global object variables|nsp=0}} | [https://doc.wikimedia.org/ <div lang="en" dir="ltr" class="mw-content-ltr">
Doxygen-generated documentation
</div>]
; {{ll|Manual:Database layout|nsp=0}}
: <div lang="en" dir="ltr" class="mw-content-ltr">
Details about the database architecture used by MediaWiki.
</div>
: <div lang="en" dir="ltr" class="mw-content-ltr">
{{ll|Manual:MySQL|nsp=0}} | {{ll|Manual:PostgreSQL|nsp=0}} | {{ll|Manual:SQLite|nsp=0}} | {{ll|Manual:IBM DB2|nsp=0}} engines
</div>
; {{ll|Manual:Developing extensions|nsp=0}}
: <div lang="en" dir="ltr" class="mw-content-ltr">
An overview of the ways to create a new MediaWiki extension.
</div>
: {{ll|Manual:Hooks|nsp=0}} | {{ll|Manual:Parser functions|nsp=0}} | [[Special:MyLanguage/Manual:Tag extensions|<div lang="en" dir="ltr" class="mw-content-ltr">
Tag
</div>]] | [[Special:MyLanguage/Manual:Special pages|<div lang="en" dir="ltr" class="mw-content-ltr">
Special page
</div>]] | {{ll|Manual:Skins|nsp=0}}
; <div lang="en" dir="ltr" class="mw-content-ltr">
Web access
</div>
: <div lang="en" dir="ltr" class="mw-content-ltr">
Details about [[w:Query string|query string]] parameters that can be passed to MediaWiki access scripts.
</div>
: {{ll|Manual:Parameters to index.php|index.php}} | {{ll|API:Main page|api.php}}
''<div lang="en" dir="ltr" class="mw-content-ltr">
More on {{ll|Developer hub}}.
</div>''
|}
<div lang="en" dir="ltr" class="mw-content-ltr">
=== Others ===
</div>
; [[Special:MyLanguage/Manual:FAQ|<div lang="en" dir="ltr" class="mw-content-ltr">
MediaWiki FAQ
</div>]]
: <div lang="en" dir="ltr" class="mw-content-ltr">
Frequently asked questions about MediaWiki.
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
== Browsing the manual ==
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
There are multiple ways to browse through the documentation.
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
Readers having trouble finding a particular topic in the section above may find the following ways of browsing to be helpful.
</div>
* [[Special:Allpages/Manual:]] - <div lang="en" dir="ltr" class="mw-content-ltr">
An automatically generated list of all pages in the Manual: namespace.
</div>
* [[:Category:Manual]] - <div lang="en" dir="ltr" class="mw-content-ltr">
the top-level Manual category
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
== Improving the manual ==
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
* There are still a lot of holes in this manual!
</div> <div lang="en" dir="ltr" class="mw-content-ltr">
See the [[Special:MyLanguage/Manual:Contents/To do|'to do' page]] for details.
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
* There is still content on http://meta.wikimedia.org which may need to be migrated.
</div> <div lang="en" dir="ltr" class="mw-content-ltr">
If you can't find information on a particular issue in this documentation, please visit [[meta:MediaWiki_FAQ]] and [[meta:Help:Contents]].
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
* '''[[Project:Manual]]''' is a place to discuss and co-ordinate the development of the Manual: namespace.
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
* See also [[Project:Current issues]].
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
== MediaWiki Virtual Library ==
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
* '''[[:Category:MediaWiki Virtual Library (MVL)|MediaWiki Virtual Library]] (MVL)''' has manuals, guides, and collections of selected articles in PDF form you create on-the-fly
</div>
[[File:Incubator-notext.png|alt=foobaz|thumb|foobar]]
{| style="background:transparent;"
| style="width:50%; vertical-align:top; border:1px solid #aaa; padding: .5em 1.5em;" |
=== For system administrators ===
; [[Special:MyLanguage/Manual:Installation guide|Installation]]
: Guide to setting up a new MediaWiki installation.
: {{ll|Download}} | [[Special:MyLanguage/Manual:Installing MediaWiki|
Installing]] | [[Special:MyLanguage/Manual:Configuring MediaWiki|
Initial configuration]]
: [[Special:MyLanguage/Manual:Configuration settings (alphabetical)|
Alphabetical list of settings]] | [[Special:MyLanguage/Manual:Configuration settings|
Settings listed by function]]
; {{ll|Manual:System administration|nsp=0}}
: Guide to do administrative tasks on your wiki.
: [[Special:MyLanguage/Manual:Backing up a wiki|Backing up]] | {{ll|Manual:Maintenance scripts|nsp=0}}
; {{ll|Manual:Upgrading|nsp=0}}
:
Guide to upgrade your MediaWiki installation.
''More on {{ll|Sysadmin hub}}.''
| style="width:50%; vertical-align:top; border:1px solid #aaa; padding: .5em 1.5em;" |
=== For developers ===
; Architecture
: An overview of the key parts of MediaWiki's source code.
: {{ll|Manual:Code|nsp=0}} | {{ll|Manual:Global object variables|nsp=0}} | [https://doc.wikimedia.org/
Doxygen-generated documentation]
; {{ll|Manual:Database layout|nsp=0}}
: Details about the database architecture used by MediaWiki.
: {{ll|Manual:MySQL|nsp=0}} | {{ll|Manual:PostgreSQL|nsp=0}} | {{ll|Manual:SQLite|nsp=0}} | {{ll|Manual:IBM DB2|nsp=0}} engines
; {{ll|Manual:Developing extensions|nsp=0}}
: An overview of the ways to create a new MediaWiki extension.
: {{ll|Manual:Hooks|nsp=0}} | {{ll|Manual:Parser functions|nsp=0}} | [[Special:MyLanguage/Manual:Tag extensions|Tag]] | [[Special:MyLanguage/Manual:Special pages|Special page]] | {{ll|Manual:Skins|nsp=0}}
; Web access
: Details about [[w:Query string|query string]] parameters that can be passed to MediaWiki access scripts.
: {{ll|Manual:Parameters to index.php|index.php}} | {{ll|API:Main page|api.php}}
''More on {{ll|Developer hub}}.''
|}
[[File:Soldering a 0805.jpg|alt=foobaz|thumb|foobar]]
[[Category:MediaWiki technical documentation{{translation}}| ]]
[[Category:Manual{{translation}}| ]]
e6b1g4nrscfqlw2q96a58mecazg8x33
Translate test/en
0
85154
737777
737615
2026-04-12T20:35:35Z
FuzzyBot
18251
Updating to match new version of source page
737777
wikitext
text/x-wiki
<languages/>
{{int:Project-localized-name-metawiki}}
{{TNT|Hubs|banner|dev=y|admin=y}}
[[File:MediaWiki-Manual bookstyle-transparent.png|{{dir|{{pagelang}}|left|right}}|175px|MediaWiki documentation]]
This is a '''technical manual for the MediaWiki software'''.
It contains information for '''developers''' and '''system administrators''' on '''installing''', '''managing''' and '''developing''' for the MediaWiki software.
This manual is '''not for end users''' of MediaWiki.
If you are looking for '''documentation to help you use MediaWiki, read the [[Special:MyLanguage/Help:Contents|MediaWiki Handbook]].'''
==Main sections==
{{TNT|merge|Sysadmin hub|Developer hub}}
{| style="background:transparent;"
| style="width:50%; vertical-align:top; border:1px solid #aaa; padding: .5em 1.5em;" |
=== For system administrators ===
; [[Special:MyLanguage/Manual:Installation guide|Installation]]
: Guide to setting up a new MediaWiki installation.
: {{ll|Download}} | [[Special:MyLanguage/Manual:Installing MediaWiki|Installing]] | [[Special:MyLanguage/Manual:Configuring MediaWiki|Initial configuration]]
: [[Special:MyLanguage/Manual:Configuration settings (alphabetical)|Alphabetical list of settings]] | [[Special:MyLanguage/Manual:Configuration settings|Settings listed by function]]
; {{ll|Manual:System administration|nsp=0}}
: Guide to do administrative tasks on your wiki.
: [[Special:MyLanguage/Manual:Backing up a wiki|Backing up]] | {{ll|Manual:Maintenance scripts|nsp=0}}
; {{ll|Manual:Upgrading|nsp=0}}
: Guide to upgrade your MediaWiki installation.
''More on {{ll|Sysadmin hub}}.''
| style="width:50%; vertical-align:top; border:1px solid #aaa; padding: .5em 1.5em;" |
=== For developers ===
; Architecture
: An overview of the key parts of MediaWiki's source code.
: {{ll|Manual:Code|nsp=0}} | {{ll|Manual:Global object variables|nsp=0}} | [https://doc.wikimedia.org/ Doxygen-generated documentation]
; {{ll|Manual:Database layout|nsp=0}}
: Details about the database architecture used by MediaWiki.
: {{ll|Manual:MySQL|nsp=0}} | {{ll|Manual:PostgreSQL|nsp=0}} | {{ll|Manual:SQLite|nsp=0}} | {{ll|Manual:IBM DB2|nsp=0}} engines
; {{ll|Manual:Developing extensions|nsp=0}}
: An overview of the ways to create a new MediaWiki extension.
: {{ll|Manual:Hooks|nsp=0}} | {{ll|Manual:Parser functions|nsp=0}} | [[Special:MyLanguage/Manual:Tag extensions|Tag]] | [[Special:MyLanguage/Manual:Special pages|Special page]] | {{ll|Manual:Skins|nsp=0}}
; Web access
: Details about [[w:Query string|query string]] parameters that can be passed to MediaWiki access scripts.
: {{ll|Manual:Parameters to index.php|index.php}} | {{ll|API:Main page|api.php}}
''More on {{ll|Developer hub}}.''
|}
=== Others ===
; [[Special:MyLanguage/Manual:FAQ|MediaWiki FAQ]]
: Frequently asked questions about MediaWiki.
== Browsing the manual ==
There are multiple ways to browse through the documentation.
Readers having trouble finding a particular topic in the section above may find the following ways of browsing to be helpful.
* [[Special:Allpages/Manual:]] - An automatically generated list of all pages in the Manual: namespace.
* [[:Category:Manual]] - the top-level Manual category
== Improving the manual ==
* There are still a lot of holes in this manual! See the [[Special:MyLanguage/Manual:Contents/To do|'to do' page]] for details.
* There is still content on http://meta.wikimedia.org which may need to be migrated. If you can't find information on a particular issue in this documentation, please visit [[meta:MediaWiki_FAQ]] and [[meta:Help:Contents]].
* '''[[Project:Manual]]''' is a place to discuss and co-ordinate the development of the Manual: namespace.
* See also [[Project:Current issues]].
== MediaWiki Virtual Library ==
* '''[[:Category:MediaWiki Virtual Library (MVL)|MediaWiki Virtual Library]] (MVL)''' has manuals, guides, and collections of selected articles in PDF form you create on-the-fly
[[File:Incubator-notext.png|alt=foobaz|thumb|foobar]]
{| style="background:transparent;"
| style="width:50%; vertical-align:top; border:1px solid #aaa; padding: .5em 1.5em;" |
=== For system administrators ===
; [[Special:MyLanguage/Manual:Installation guide|Installation]]
: Guide to setting up a new MediaWiki installation.
: {{ll|Download}} | [[Special:MyLanguage/Manual:Installing MediaWiki|
Installing]] | [[Special:MyLanguage/Manual:Configuring MediaWiki|
Initial configuration]]
: [[Special:MyLanguage/Manual:Configuration settings (alphabetical)|
Alphabetical list of settings]] | [[Special:MyLanguage/Manual:Configuration settings|
Settings listed by function]]
; {{ll|Manual:System administration|nsp=0}}
: Guide to do administrative tasks on your wiki.
: [[Special:MyLanguage/Manual:Backing up a wiki|Backing up]] | {{ll|Manual:Maintenance scripts|nsp=0}}
; {{ll|Manual:Upgrading|nsp=0}}
:
Guide to upgrade your MediaWiki installation.
''More on {{ll|Sysadmin hub}}.''
| style="width:50%; vertical-align:top; border:1px solid #aaa; padding: .5em 1.5em;" |
=== For developers ===
; Architecture
: An overview of the key parts of MediaWiki's source code.
: {{ll|Manual:Code|nsp=0}} | {{ll|Manual:Global object variables|nsp=0}} | [https://doc.wikimedia.org/
Doxygen-generated documentation]
; {{ll|Manual:Database layout|nsp=0}}
: Details about the database architecture used by MediaWiki.
: {{ll|Manual:MySQL|nsp=0}} | {{ll|Manual:PostgreSQL|nsp=0}} | {{ll|Manual:SQLite|nsp=0}} | {{ll|Manual:IBM DB2|nsp=0}} engines
; {{ll|Manual:Developing extensions|nsp=0}}
: An overview of the ways to create a new MediaWiki extension.
: {{ll|Manual:Hooks|nsp=0}} | {{ll|Manual:Parser functions|nsp=0}} | [[Special:MyLanguage/Manual:Tag extensions|Tag]] | [[Special:MyLanguage/Manual:Special pages|Special page]] | {{ll|Manual:Skins|nsp=0}}
; Web access
: Details about [[w:Query string|query string]] parameters that can be passed to MediaWiki access scripts.
: {{ll|Manual:Parameters to index.php|index.php}} | {{ll|API:Main page|api.php}}
''More on {{ll|Developer hub}}.''
|}
[[File:Soldering a 0805.jpg|alt=foobaz|thumb|foobar]]
[[Category:MediaWiki technical documentation{{translation}}| ]]
[[Category:Manual{{translation}}| ]]
m8huxxwmu93mftkuefn0gbqr8enl0zn
Translate test/uk
0
85207
737781
737619
2026-04-12T20:35:37Z
FuzzyBot
18251
Updating to match new version of source page
737781
wikitext
text/x-wiki
<languages/>
{{int:Project-localized-name-metawiki}}
{{TNT|Hubs|banner|dev=y|admin=y}}
[[File:MediaWiki-Manual bookstyle-transparent.png|{{dir|{{pagelang}}|left|right}}|175px|Документація MediaWiki]]
<div lang="en" dir="ltr" class="mw-content-ltr">
This is a '''technical manual for the MediaWiki software'''.
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
It contains information for '''developers''' and '''system administrators''' on '''installing''', '''managing''' and '''developing''' for the MediaWiki software.
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
This manual is '''not for end users''' of MediaWiki.
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
If you are looking for '''documentation to help you use MediaWiki, read the [[Special:MyLanguage/Help:Contents|MediaWiki Handbook]].'''
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
==Main sections==
</div>
{{TNT|merge|Sysadmin hub|Developer hub}}
{| style="background:transparent;"
| style="width:50%; vertical-align:top; border:1px solid #aaa; padding: .5em 1.5em;" |
<div lang="en" dir="ltr" class="mw-content-ltr">
=== For system administrators ===
</div>
; [[Special:MyLanguage/Manual:Installation guide|<div lang="en" dir="ltr" class="mw-content-ltr">
Installation
</div>]]
: <div lang="en" dir="ltr" class="mw-content-ltr">
Guide to setting up a new MediaWiki installation.
</div>
: {{ll|Download}} | [[Special:MyLanguage/Manual:Installing MediaWiki|<div lang="en" dir="ltr" class="mw-content-ltr">
Installing
</div>]] | [[Special:MyLanguage/Manual:Configuring MediaWiki|<div lang="en" dir="ltr" class="mw-content-ltr">
Initial configuration
</div>]]
: [[Special:MyLanguage/Manual:Configuration settings (alphabetical)|<div lang="en" dir="ltr" class="mw-content-ltr">
Alphabetical list of settings
</div>]] | [[Special:MyLanguage/Manual:Configuration settings|<div lang="en" dir="ltr" class="mw-content-ltr">
Settings listed by function
</div>]]
; {{ll|Manual:System administration|nsp=0}}
: <div lang="en" dir="ltr" class="mw-content-ltr">
Guide to do administrative tasks on your wiki.
</div>
: [[Special:MyLanguage/Manual:Backing up a wiki|<div lang="en" dir="ltr" class="mw-content-ltr">
Backing up
</div>]] | {{ll|Manual:Maintenance scripts|nsp=0}}
; {{ll|Manual:Upgrading|nsp=0}}
: <div lang="en" dir="ltr" class="mw-content-ltr">
Guide to upgrade your MediaWiki installation.
</div>
''<div lang="en" dir="ltr" class="mw-content-ltr">
More on {{ll|Sysadmin hub}}.
</div>''
| style="width:50%; vertical-align:top; border:1px solid #aaa; padding: .5em 1.5em;" |
<div lang="en" dir="ltr" class="mw-content-ltr">
=== For developers ===
</div>
; <div lang="en" dir="ltr" class="mw-content-ltr">
Architecture
</div>
: <div lang="en" dir="ltr" class="mw-content-ltr">
An overview of the key parts of MediaWiki's source code.
</div>
: {{ll|Manual:Code|nsp=0}} | {{ll|Manual:Global object variables|nsp=0}} | [https://doc.wikimedia.org/ <div lang="en" dir="ltr" class="mw-content-ltr">
Doxygen-generated documentation
</div>]
; {{ll|Manual:Database layout|nsp=0}}
: <div lang="en" dir="ltr" class="mw-content-ltr">
Details about the database architecture used by MediaWiki.
</div>
: <div lang="en" dir="ltr" class="mw-content-ltr">
{{ll|Manual:MySQL|nsp=0}} | {{ll|Manual:PostgreSQL|nsp=0}} | {{ll|Manual:SQLite|nsp=0}} | {{ll|Manual:IBM DB2|nsp=0}} engines
</div>
; {{ll|Manual:Developing extensions|nsp=0}}
: <div lang="en" dir="ltr" class="mw-content-ltr">
An overview of the ways to create a new MediaWiki extension.
</div>
: {{ll|Manual:Hooks|nsp=0}} | {{ll|Manual:Parser functions|nsp=0}} | [[Special:MyLanguage/Manual:Tag extensions|<div lang="en" dir="ltr" class="mw-content-ltr">
Tag
</div>]] | [[Special:MyLanguage/Manual:Special pages|<div lang="en" dir="ltr" class="mw-content-ltr">
Special page
</div>]] | {{ll|Manual:Skins|nsp=0}}
; <div lang="en" dir="ltr" class="mw-content-ltr">
Web access
</div>
: <div lang="en" dir="ltr" class="mw-content-ltr">
Details about [[w:Query string|query string]] parameters that can be passed to MediaWiki access scripts.
</div>
: {{ll|Manual:Parameters to index.php|index.php}} | {{ll|API:Main page|api.php}}
''<div lang="en" dir="ltr" class="mw-content-ltr">
More on {{ll|Developer hub}}.
</div>''
|}
<div lang="en" dir="ltr" class="mw-content-ltr">
=== Others ===
</div>
; [[Special:MyLanguage/Manual:FAQ|<div lang="en" dir="ltr" class="mw-content-ltr">
MediaWiki FAQ
</div>]]
: <div lang="en" dir="ltr" class="mw-content-ltr">
Frequently asked questions about MediaWiki.
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
== Browsing the manual ==
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
There are multiple ways to browse through the documentation.
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
Readers having trouble finding a particular topic in the section above may find the following ways of browsing to be helpful.
</div>
* [[Special:Allpages/Manual:]] - <div lang="en" dir="ltr" class="mw-content-ltr">
An automatically generated list of all pages in the Manual: namespace.
</div>
* [[:Category:Manual]] - <div lang="en" dir="ltr" class="mw-content-ltr">
the top-level Manual category
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
== Improving the manual ==
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
* There are still a lot of holes in this manual!
</div> <div lang="en" dir="ltr" class="mw-content-ltr">
See the [[Special:MyLanguage/Manual:Contents/To do|'to do' page]] for details.
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
* There is still content on http://meta.wikimedia.org which may need to be migrated.
</div> <div lang="en" dir="ltr" class="mw-content-ltr">
If you can't find information on a particular issue in this documentation, please visit [[meta:MediaWiki_FAQ]] and [[meta:Help:Contents]].
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
* '''[[Project:Manual]]''' is a place to discuss and co-ordinate the development of the Manual: namespace.
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
* See also [[Project:Current issues]].
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
== MediaWiki Virtual Library ==
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
* '''[[:Category:MediaWiki Virtual Library (MVL)|MediaWiki Virtual Library]] (MVL)''' has manuals, guides, and collections of selected articles in PDF form you create on-the-fly
</div>
[[File:Incubator-notext.png|alt=foobaz|thumb|foobar]]
{| style="background:transparent;"
| style="width:50%; vertical-align:top; border:1px solid #aaa; padding: .5em 1.5em;" |
=== For system administrators ===
; [[Special:MyLanguage/Manual:Installation guide|Installation]]
: Guide to setting up a new MediaWiki installation.
: {{ll|Download}} | [[Special:MyLanguage/Manual:Installing MediaWiki|
Installing]] | [[Special:MyLanguage/Manual:Configuring MediaWiki|
Initial configuration]]
: [[Special:MyLanguage/Manual:Configuration settings (alphabetical)|
Alphabetical list of settings]] | [[Special:MyLanguage/Manual:Configuration settings|
Settings listed by function]]
; {{ll|Manual:System administration|nsp=0}}
: Guide to do administrative tasks on your wiki.
: [[Special:MyLanguage/Manual:Backing up a wiki|Backing up]] | {{ll|Manual:Maintenance scripts|nsp=0}}
; {{ll|Manual:Upgrading|nsp=0}}
:
Guide to upgrade your MediaWiki installation.
''More on {{ll|Sysadmin hub}}.''
| style="width:50%; vertical-align:top; border:1px solid #aaa; padding: .5em 1.5em;" |
=== For developers ===
; Architecture
: An overview of the key parts of MediaWiki's source code.
: {{ll|Manual:Code|nsp=0}} | {{ll|Manual:Global object variables|nsp=0}} | [https://doc.wikimedia.org/
Doxygen-generated documentation]
; {{ll|Manual:Database layout|nsp=0}}
: Details about the database architecture used by MediaWiki.
: {{ll|Manual:MySQL|nsp=0}} | {{ll|Manual:PostgreSQL|nsp=0}} | {{ll|Manual:SQLite|nsp=0}} | {{ll|Manual:IBM DB2|nsp=0}} engines
; {{ll|Manual:Developing extensions|nsp=0}}
: An overview of the ways to create a new MediaWiki extension.
: {{ll|Manual:Hooks|nsp=0}} | {{ll|Manual:Parser functions|nsp=0}} | [[Special:MyLanguage/Manual:Tag extensions|Tag]] | [[Special:MyLanguage/Manual:Special pages|Special page]] | {{ll|Manual:Skins|nsp=0}}
; Web access
: Details about [[w:Query string|query string]] parameters that can be passed to MediaWiki access scripts.
: {{ll|Manual:Parameters to index.php|index.php}} | {{ll|API:Main page|api.php}}
''More on {{ll|Developer hub}}.''
|}
[[File:Soldering a 0805.jpg|alt=foobaz|thumb|foobar]]
[[Category:MediaWiki technical documentation{{translation}}| ]]
[[Category:Manual{{translation}}| ]]
tvfnsqopuq2y876ksia4vn3mqolnk69
Translate test/es
0
96703
737778
737614
2026-04-12T20:35:35Z
FuzzyBot
18251
Updating to match new version of source page
737778
wikitext
text/x-wiki
<languages/>
{{int:Project-localized-name-metawiki}}
{{TNT|Hubs|banner|dev=y|admin=y}}
[[File:MediaWiki-Manual bookstyle-transparent.png|{{dir|{{pagelang}}|left|right}}|175px|MediaWiki Documentación]]
<div lang="en" dir="ltr" class="mw-content-ltr">
It contains information for '''developers''' and '''system administrators''' on '''installing''', '''managing''' and '''developing''' for the MediaWiki software.
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
This manual is '''not for end users''' of MediaWiki.
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
If you are looking for '''documentation to help you use MediaWiki, read the [[Special:MyLanguage/Help:Contents|MediaWiki Handbook]].'''
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
==Main sections==
</div>
{{TNT|merge|Sysadmin hub|Developer hub}}
{| style="background:transparent;"
| style="width:50%; vertical-align:top; border:1px solid #aaa; padding: .5em 1.5em;" |
<div lang="en" dir="ltr" class="mw-content-ltr">
=== For system administrators ===
</div>
; [[Special:MyLanguage/Manual:Installation guide|<div lang="en" dir="ltr" class="mw-content-ltr">
Installation
</div>]]
: <div lang="en" dir="ltr" class="mw-content-ltr">
Guide to setting up a new MediaWiki installation.
</div>
: {{ll|Download}} | [[Special:MyLanguage/Manual:Installing MediaWiki|<div lang="en" dir="ltr" class="mw-content-ltr">
Installing
</div>]] | [[Special:MyLanguage/Manual:Configuring MediaWiki|<div lang="en" dir="ltr" class="mw-content-ltr">
Initial configuration
</div>]]
: [[Special:MyLanguage/Manual:Configuration settings (alphabetical)|<div lang="en" dir="ltr" class="mw-content-ltr">
Alphabetical list of settings
</div>]] | [[Special:MyLanguage/Manual:Configuration settings|<div lang="en" dir="ltr" class="mw-content-ltr">
Settings listed by function
</div>]]
; {{ll|Manual:System administration|nsp=0}}
: <div lang="en" dir="ltr" class="mw-content-ltr">
Guide to do administrative tasks on your wiki.
</div>
: [[Special:MyLanguage/Manual:Backing up a wiki|<div lang="en" dir="ltr" class="mw-content-ltr">
Backing up
</div>]] | {{ll|Manual:Maintenance scripts|nsp=0}}
; {{ll|Manual:Upgrading|nsp=0}}
: <div lang="en" dir="ltr" class="mw-content-ltr">
Guide to upgrade your MediaWiki installation.
</div>
''<div lang="en" dir="ltr" class="mw-content-ltr">
More on {{ll|Sysadmin hub}}.
</div>''
| style="width:50%; vertical-align:top; border:1px solid #aaa; padding: .5em 1.5em;" |
<div lang="en" dir="ltr" class="mw-content-ltr">
=== For developers ===
</div>
; <div lang="en" dir="ltr" class="mw-content-ltr">
Architecture
</div>
: <div lang="en" dir="ltr" class="mw-content-ltr">
An overview of the key parts of MediaWiki's source code.
</div>
: {{ll|Manual:Code|nsp=0}} | {{ll|Manual:Global object variables|nsp=0}} | [https://doc.wikimedia.org/ <div lang="en" dir="ltr" class="mw-content-ltr">
Doxygen-generated documentation
</div>]
; {{ll|Manual:Database layout|nsp=0}}
: <div lang="en" dir="ltr" class="mw-content-ltr">
Details about the database architecture used by MediaWiki.
</div>
: <div lang="en" dir="ltr" class="mw-content-ltr">
{{ll|Manual:MySQL|nsp=0}} | {{ll|Manual:PostgreSQL|nsp=0}} | {{ll|Manual:SQLite|nsp=0}} | {{ll|Manual:IBM DB2|nsp=0}} engines
</div>
; {{ll|Manual:Developing extensions|nsp=0}}
: <div lang="en" dir="ltr" class="mw-content-ltr">
An overview of the ways to create a new MediaWiki extension.
</div>
: {{ll|Manual:Hooks|nsp=0}} | {{ll|Manual:Parser functions|nsp=0}} | [[Special:MyLanguage/Manual:Tag extensions|<div lang="en" dir="ltr" class="mw-content-ltr">
Tag
</div>]] | [[Special:MyLanguage/Manual:Special pages|<div lang="en" dir="ltr" class="mw-content-ltr">
Special page
</div>]] | {{ll|Manual:Skins|nsp=0}}
; <div lang="en" dir="ltr" class="mw-content-ltr">
Web access
</div>
: <div lang="en" dir="ltr" class="mw-content-ltr">
Details about [[w:Query string|query string]] parameters that can be passed to MediaWiki access scripts.
</div>
: {{ll|Manual:Parameters to index.php|index.php}} | {{ll|API:Main page|api.php}}
''<div lang="en" dir="ltr" class="mw-content-ltr">
More on {{ll|Developer hub}}.
</div>''
|}
<div lang="en" dir="ltr" class="mw-content-ltr">
=== Others ===
</div>
; [[Special:MyLanguage/Manual:FAQ|<div lang="en" dir="ltr" class="mw-content-ltr">
MediaWiki FAQ
</div>]]
: <div lang="en" dir="ltr" class="mw-content-ltr">
Frequently asked questions about MediaWiki.
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
== Browsing the manual ==
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
There are multiple ways to browse through the documentation.
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
Readers having trouble finding a particular topic in the section above may find the following ways of browsing to be helpful.
</div>
* [[Special:Allpages/Manual:]] - <div lang="en" dir="ltr" class="mw-content-ltr">
An automatically generated list of all pages in the Manual: namespace.
</div>
* [[:Category:Manual]] - <div lang="en" dir="ltr" class="mw-content-ltr">
the top-level Manual category
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
== Improving the manual ==
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
* There are still a lot of holes in this manual!
</div> <div lang="en" dir="ltr" class="mw-content-ltr">
See the [[Special:MyLanguage/Manual:Contents/To do|'to do' page]] for details.
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
* There is still content on http://meta.wikimedia.org which may need to be migrated.
</div> <div lang="en" dir="ltr" class="mw-content-ltr">
If you can't find information on a particular issue in this documentation, please visit [[meta:MediaWiki_FAQ]] and [[meta:Help:Contents]].
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
* '''[[Project:Manual]]''' is a place to discuss and co-ordinate the development of the Manual: namespace.
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
* See also [[Project:Current issues]].
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
== MediaWiki Virtual Library ==
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
* '''[[:Category:MediaWiki Virtual Library (MVL)|MediaWiki Virtual Library]] (MVL)''' has manuals, guides, and collections of selected articles in PDF form you create on-the-fly
</div>
[[File:Incubator-notext.png|alt=foobaz|thumb|foobar]]
{| style="background:transparent;"
| style="width:50%; vertical-align:top; border:1px solid #aaa; padding: .5em 1.5em;" |
=== For system administrators ===
; [[Special:MyLanguage/Manual:Installation guide|Installation]]
: Guide to setting up a new MediaWiki installation.
: {{ll|Download}} | [[Special:MyLanguage/Manual:Installing MediaWiki|
Installing]] | [[Special:MyLanguage/Manual:Configuring MediaWiki|
Initial configuration]]
: [[Special:MyLanguage/Manual:Configuration settings (alphabetical)|
Alphabetical list of settings]] | [[Special:MyLanguage/Manual:Configuration settings|
Settings listed by function]]
; {{ll|Manual:System administration|nsp=0}}
: Guide to do administrative tasks on your wiki.
: [[Special:MyLanguage/Manual:Backing up a wiki|Backing up]] | {{ll|Manual:Maintenance scripts|nsp=0}}
; {{ll|Manual:Upgrading|nsp=0}}
:
Guide to upgrade your MediaWiki installation.
''More on {{ll|Sysadmin hub}}.''
| style="width:50%; vertical-align:top; border:1px solid #aaa; padding: .5em 1.5em;" |
=== For developers ===
; Architecture
: An overview of the key parts of MediaWiki's source code.
: {{ll|Manual:Code|nsp=0}} | {{ll|Manual:Global object variables|nsp=0}} | [https://doc.wikimedia.org/
Doxygen-generated documentation]
; {{ll|Manual:Database layout|nsp=0}}
: Details about the database architecture used by MediaWiki.
: {{ll|Manual:MySQL|nsp=0}} | {{ll|Manual:PostgreSQL|nsp=0}} | {{ll|Manual:SQLite|nsp=0}} | {{ll|Manual:IBM DB2|nsp=0}} engines
; {{ll|Manual:Developing extensions|nsp=0}}
: An overview of the ways to create a new MediaWiki extension.
: {{ll|Manual:Hooks|nsp=0}} | {{ll|Manual:Parser functions|nsp=0}} | [[Special:MyLanguage/Manual:Tag extensions|Tag]] | [[Special:MyLanguage/Manual:Special pages|Special page]] | {{ll|Manual:Skins|nsp=0}}
; Web access
: Details about [[w:Query string|query string]] parameters that can be passed to MediaWiki access scripts.
: {{ll|Manual:Parameters to index.php|index.php}} | {{ll|API:Main page|api.php}}
''More on {{ll|Developer hub}}.''
|}
[[File:Soldering a 0805.jpg|alt=foobaz|thumb|foobar]]
[[Category:MediaWiki technical documentation{{translation}}| ]]
[[Category:Manual{{translation}}| ]]
lxoj8n87urdhkukilqtgq4rxqu4sz55
User:Sam Sailor/test.js
2
98186
737799
737481
2026-04-13T07:08:29Z
Sam Sailor
26820
Test
737799
javascript
text/javascript
// <nowiki>
(function() {
var Comrade = Comrade || {};
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').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-" + btoa(unescape(encodeURIComponent(redirTitle))).replace(/=/g, "");
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}]]`;
// Pass the customWikitext through to the creation function
const $btn = $('<button>').text('Create redirect').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);
}
}
function checkChemicalRedirect(wikitext, currentTitle) {
if (!/\{\{\s*Chembox Properties/i.test(wikitext)) {
console.log("Comrade: No 'Chembox Properties' template found.");
return;
}
let formula = "";
const formulaMatch = wikitext.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
formula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
console.log("Comrade: Found explicit Formula parameter:", formula);
} else {
console.log("Comrade: No explicit formula. Searching for individual elements...");
const chemboxSection = wikitext.match(/\{\{\s*Chembox Properties[\s\S]*?\}\}/i);
if (chemboxSection) {
const content = chemboxSection[0];
console.log("Comrade: Chembox content extracted:", content);
// Comprehensive list extracted from [[Template:Chembox Elements/molecular formula]]
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+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch) {
console.log(`Comrade: Match found for ${el}:`, elMatch[0]);
if (elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
}
});
console.log("Comrade: Final element data object:", data);
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
// If carbon exists, C then H
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
// Alphabetical for everything else
const remaining = elementsPresent.filter(el => {
if (hasC && (el === "C" || el === "H")) return false;
return true;
}).sort();
// Special case: If NO carbon, H must be sorted alphabetically
remaining.forEach(el => {
formulaArray.push(el + data[el]);
});
formula = formulaArray.join('');
console.log("Comrade: Generated formula from elements:", formula);
}
} else {
console.log("Comrade: Failed to extract Chembox Properties section content.");
}
}
if (formula && formula !== currentTitle) {
console.log(`Comrade: Success! Triggering redirect for ${formula}`);
checkAndRenderRedirect(formula, currentTitle, "R from chemical formula", "Chemical formula");
} else {
console.log("Comrade: Formula matches current title or is empty. No redirect needed.");
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
// Try to get the URL from Wikidata (P856)
if (qid) {
try {
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
}
});
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
console.log("Comrade: Found domain candidate in Wikidata:", domainCandidate);
}
} catch (e) {
console.log("Comrade: Wikidata API call failed.");
}
}
// Fallback to Infobox URL if Wikidata is empty
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
if (domainCandidate) console.log("Comrade: Found domain candidate in Infobox:", domainCandidate);
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
// Only proceed if it's a root domain (pathname is "/")
if (domain.includes(".") && url.pathname === "/") {
console.log(`Comrade: Verifying if ${domainCandidate} is live via Citation API...`);
// The live check, CORS-friendly proxy
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
$.ajax({
url: citationURL,
method: 'GET',
success: function() {
console.log(`Comrade: URL is live. Proceeding with redirect for: ${domain}`);
checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
},
error: function(xhr) {
console.log(`Comrade: URL failed live check (Status: ${xhr.status}). Skipping redirect.`);
}
});
} else {
console.log(`Comrade: ${domain} rejected (not a root domain or missing TLD).`);
}
} catch (e) {
console.log("Comrade: Error parsing URL object.");
}
} else {
console.log("Comrade: No domain candidate found for this article.");
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan) {
console.log(`[Comrade] Checking ${type} list for: ${name}`);
const api = new mw.Api();
// Added lowercase version of the type to the candidates
const candidates = [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
// Set to keep track of titles we've already checked to avoid redundant API calls
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
console.log(`[Comrade] Querying: ${title}`);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
// redirects: 1 // Follow redirects to find the actual list page
redirects: 0 // Do not follow redirects
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const resolvedTitle = page.title;
// Check for specific name templates
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
// Check for disambiguation pages with name parameters
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
console.log(`[Comrade] Results for ${resolvedTitle} - Match: ${isNameMatch}, Dab: ${isNameDab}`);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) {
console.log(`[Comrade] ${currentTitle} is already listed on ${resolvedTitle}`);
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:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; should it be?"));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not currently listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; 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').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();
// Use custom wikitext if provided (for Long Name), otherwise use standard template
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
return api.postWithToken('csrf', {
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
}).done(() => {
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
});
}
async function performDeorphan() {
const api = new mw.Api();
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);
}
function replaceMI(text) {
const miStart = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miStart.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;
miStart.lastIndex = lastIndex;
}
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.postWithToken('csrf', {
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName} (Comrade)`,
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'
});
console.error("Comrade save error:", err);
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgUserId") !== 3155609 || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") {
return;
} {
$(document).ready(async function() {
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').click(() => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
checkChemicalRedirect(wikitext, currentTitle);
checkDomainRedirect(qid, currentTitle);
if (qid) {
const wikiData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
const entity = wikiData.entities[qid];
const isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
let targetSortName = "";
if (dsMatch && isHuman) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
const givenNameIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
let wdGivens = [];
if (givenNameIds.length > 0) {
const nameData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: givenNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
wdGivens = givenNameIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean);
}
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
const referenceName = entity.labels?.en?.value || currentTitle;
const fixCasing = (part) => {
return part.split(' ').map(word => {
const regex = new RegExp(`\\b${word}\\b`, 'i');
const match = referenceName.match(regex);
if (match) return match[0];
const partsOfTitle = referenceName.split(' ');
const closest = partsOfTitle.find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase()));
return closest || word;
}).join(' ');
};
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixCasing(lastPart)}, ${fixCasing(firstPart)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field (with case fix).");
} else if (wdGivens.length > 0 && lastPart.split(' ').length > 1) {
const lastParts = lastPart.split(' ');
const misplacedGiven = lastParts.find(part => wdGivens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastParts.filter(p => p !== misplacedGiven).join(' ');
const newGiven = `${misplacedGiven} ${firstPart}`.trim();
targetSortName = `${fixCasing(newSurname)}, ${fixCasing(newGiven)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
} else {
const cLast = fixCasing(lastPart);
const cFirst = fixCasing(firstPart);
if (cLast !== lastPart || cFirst !== firstPart) {
targetSortName = `${cLast}, ${cFirst}`;
renderSortNudge(targetSortName, "Case mismatch relative to article title/Wikidata.");
}
}
} else {
const parts = currentDS.split(' ');
if (parts.length >= 2) {
const last = parts.pop();
const first = parts.join(' ');
const ref = entity.labels?.en?.value || currentTitle;
const fix = (p) => p.split(' ').map(w => (ref.match(new RegExp(`\\b${w}\\b`, 'i')) || [w])[0]).join(' ');
targetSortName = `${fix(last)}, ${fix(first)}`;
renderSortNudge(targetSortName, 'Structure should be "Last, First".');
}
}
} else if (isHuman) {
const cleanTitle = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const parts = stripDiacritics(cleanTitle).split(' ');
if (parts.length >= 2) {
const last = parts.pop();
const first = parts.join(' ');
targetSortName = `${last}, ${first}`;
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).click(() => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
}
// --- Long name extraction (using wikitext) ---
const leadLine = wikitext.split('\n').find(line => line.includes("'''"));
const boldMatch = leadLine ? leadLine.match(/'''([^']+)'''/) : null;
if (boldMatch && isHuman) {
// Clean nicknames in quotes "" and parentheses () and normalize spaces
let longName = boldMatch[1].replace(/\s*("[^"]*"|\([^)]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
// Strip disambiguators from currentTitle for a fair length comparison
const baseTitle = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
if (longName.length > baseTitle.length) {
const nameParts = longName.split(' ');
if (nameParts.length > 1) {
const surname = nameParts.pop();
const givenNames = nameParts.join(' ');
const sortKey = `${surname}, ${givenNames}`;
// Format the special redirect wikitext for the long name
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
// Check if redirect exists and render if missing
checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
}
}
// --- Sort name ---
if (isHuman && targetSortName) {
let targetPage = currentTitle;
let rTemplate = "R from sort name";
let labelSuffix = "";
// If the article has a parenthetical disambiguator, find basename
if (currentTitle.includes(' (')) {
targetPage = currentTitle.split(' (')[0];
rTemplate = "R from ambiguous sort name";
labelSuffix = " (ambiguous)";
}
let rCat = rTemplate;
if (targetSortName.includes(',')) {
const parts = targetSortName.split(',');
const lI = parts[0].trim().charAt(0).toUpperCase();
const fI = parts[1].trim().charAt(0).toUpperCase();
if (lI && fI) rCat = `${rTemplate}|${lI}|${fI}`;
}
// Only run checkAndRender once per person
checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name" + labelSuffix);
}
// --- Name list checking via Wikidata P734/P735 ---
if (isHuman) {
const familyNameClaims = entity.claims?.P734 || [];
const givenNameClaims = entity.claims?.P735 || [];
const isKorean = entity.claims?.P172?.some(c => c.mainsnak?.datavalue?.value?.id === "Q14972") || entity.claims?.P27?.some(c => ["Q884", "Q424"].includes(c.mainsnak?.datavalue?.value?.id));
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const getNameLabels = async (claims) => {
const ids = claims.map(c => c.mainsnak.datavalue.value.id);
if (ids.length === 0) return [];
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: ids.join('|'),
props: 'labels',
languages: 'en',
languagefallback: true,
format: 'json',
origin: '*'
}
});
return Object.values(res.entities).map(e => e.labels?.en?.value).filter(Boolean);
};
const [allFamilyNames, allGivenNames] = await Promise.all([
getNameLabels(familyNameClaims),
getNameLabels(givenNameClaims)
]);
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
allFamilyNames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
allGivenNames.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) {
finalGivenNames.add(gn);
}
});
if (finalFamilyNames.size === 0 || finalGivenNames.size === 0) {
const titleParts = tClean.split(' ');
if (titleParts.length >= 2) {
if (finalFamilyNames.size === 0) finalFamilyNames.add(titleParts[titleParts.length - 1]);
if (finalGivenNames.size === 0) finalGivenNames.add(titleParts[0]);
}
}
const isOrphan = isOrphanTagged && backLinkCount < 1;
for (const name of finalGivenNames) {
await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan);
}
for (const name of finalFamilyNames) {
let customTarget = null;
if (isKorean) {
if (name === "Lee") {
customTarget = "List of people with the Korean family name Lee";
} else if (name === "Kim") {
customTarget = "List of people with the Korean family name Kim";
}
}
if (customTarget) {
const listRes = await api.get({
action: 'query',
prop: 'revisions',
titles: customTarget,
rvprop: 'content',
formatversion: 2
});
const listPage = listRes.query.pages[0];
if (listPage && !listPage.missing) {
const listText = listPage.revisions[0].content;
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(listText);
if (!alreadyListed) {
const $container = $('<div>').addClass('comrade-container comrade-info');
const $header = $('<div>').addClass('comrade-header').append($('<strong>').text('Informative:'));
const $content = $('<div>').addClass('comrade-content').append($('<span>').append(`Surname not currently listed at `, $('<a>').attr({
href: mw.util.getUrl(customTarget),
target: '_blank'
}).text(customTarget), "; 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',
'font-family': 'monospace'
}));
const $copyBtn = $('<button>').text('Copy').click(function() {
navigator.clipboard.writeText(snippet).then(() => {
const $this = $(this);
$this.text('Copied!').prop('disabled', true);
setTimeout(() => $this.text('Copy').prop('disabled', false), 1500);
});
});
$snippetWrapper.append($instructionSpan, $copyBtn);
$content.append($snippetWrapper);
if (!hasShortDesc) $content.append($('<div>').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);
}
}
} else {
await checkNameList(name, 'surname', currentTitle, hasShortDesc, isOrphan);
}
}
}
}
} catch (err) {
console.error("Comrade error:", err);
}
});
}
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}`).click(() => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
})();
// </nowiki>
hy9q1zvwiw0yive0ef2kwxrutrkih1a
737800
737799
2026-04-13T07:23:31Z
Sam Sailor
26820
Test
737800
javascript
text/javascript
// <nowiki>
(function() {
var Comrade = Comrade || {};
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').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-" + btoa(unescape(encodeURIComponent(redirTitle))).replace(/=/g, "");
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}]]`;
// Pass the customWikitext through to the creation function
const $btn = $('<button>').text('Create redirect').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);
}
}
function checkChemicalRedirect(wikitext, currentTitle) {
if (!/\{\{\s*Chembox Properties/i.test(wikitext)) {
console.log("Comrade: No 'Chembox Properties' template found.");
return;
}
let formula = "";
const formulaMatch = wikitext.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
formula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
console.log("Comrade: Found explicit Formula parameter:", formula);
} else {
console.log("Comrade: No explicit formula. Searching for individual elements...");
const chemboxSection = wikitext.match(/\{\{\s*Chembox Properties[\s\S]*?\}\}/i);
if (chemboxSection) {
const content = chemboxSection[0];
console.log("Comrade: Chembox content extracted:", content);
// Comprehensive list extracted from [[Template:Chembox Elements/molecular formula]]
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+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch) {
console.log(`Comrade: Match found for ${el}:`, elMatch[0]);
if (elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
}
});
console.log("Comrade: Final element data object:", data);
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
// If carbon exists, C then H
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
// Alphabetical for everything else
const remaining = elementsPresent.filter(el => {
if (hasC && (el === "C" || el === "H")) return false;
return true;
}).sort();
// Special case: If NO carbon, H must be sorted alphabetically
remaining.forEach(el => {
formulaArray.push(el + data[el]);
});
formula = formulaArray.join('');
console.log("Comrade: Generated formula from elements:", formula);
}
} else {
console.log("Comrade: Failed to extract Chembox Properties section content.");
}
}
if (formula && formula !== currentTitle) {
console.log(`Comrade: Success! Triggering redirect for ${formula}`);
checkAndRenderRedirect(formula, currentTitle, "R from chemical formula", "Chemical formula");
} else {
console.log("Comrade: Formula matches current title or is empty. No redirect needed.");
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
// Try to get the URL from Wikidata (P856)
if (qid) {
try {
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
}
});
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
console.log("Comrade: Found domain candidate in Wikidata:", domainCandidate);
}
} catch (e) {
console.log("Comrade: Wikidata API call failed.");
}
}
// Fallback to Infobox URL if Wikidata is empty
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
if (domainCandidate) console.log("Comrade: Found domain candidate in Infobox:", domainCandidate);
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
// Only proceed if it's a root domain (pathname is "/")
if (domain.includes(".") && url.pathname === "/") {
console.log(`Comrade: Verifying if ${domainCandidate} is live via Citation API...`);
// The live check, CORS-friendly proxy
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
$.ajax({
url: citationURL,
method: 'GET',
success: function() {
console.log(`Comrade: URL is live. Proceeding with redirect for: ${domain}`);
checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
},
error: function(xhr) {
console.log(`Comrade: URL failed live check (Status: ${xhr.status}). Skipping redirect.`);
}
});
} else {
console.log(`Comrade: ${domain} rejected (not a root domain or missing TLD).`);
}
} catch (e) {
console.log("Comrade: Error parsing URL object.");
}
} else {
console.log("Comrade: No domain candidate found for this article.");
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan) {
console.log(`[Comrade] Checking ${type} list for: ${name}`);
const api = new mw.Api();
// Added lowercase version of the type to the candidates
const candidates = [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
// Set to keep track of titles we've already checked to avoid redundant API calls
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
console.log(`[Comrade] Querying: ${title}`);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
// redirects: 1 // Follow redirects to find the actual list page
redirects: 0 // Do not follow redirects
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const resolvedTitle = page.title;
// Check for specific name templates
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
// Check for disambiguation pages with name parameters
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
console.log(`[Comrade] Results for ${resolvedTitle} - Match: ${isNameMatch}, Dab: ${isNameDab}`);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) {
console.log(`[Comrade] ${currentTitle} is already listed on ${resolvedTitle}`);
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:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; should it be?"));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not currently listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; 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').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();
// Use custom wikitext if provided (for Long Name), otherwise use standard template
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
return api.postWithToken('csrf', {
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
}).done(() => {
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
});
}
async function performDeorphan() {
const api = new mw.Api();
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);
}
function replaceMI(text) {
const miStart = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miStart.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;
miStart.lastIndex = lastIndex;
}
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.postWithToken('csrf', {
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName} (Comrade)`,
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'
});
console.error("Comrade save error:", err);
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgUserId") !== 3155609 || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") {
return;
} {
$(document).ready(async function() {
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').click(() => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
checkChemicalRedirect(wikitext, currentTitle);
checkDomainRedirect(qid, currentTitle);
if (qid) {
const wikiData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
const entity = wikiData.entities[qid];
const isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
let targetSortName = "";
if (dsMatch && isHuman) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
const givenNameIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
let wdGivens = [];
if (givenNameIds.length > 0) {
const nameData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: givenNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
wdGivens = givenNameIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean);
}
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
const referenceName = entity.labels?.en?.value || currentTitle;
const fixCasing = (part) => {
return part.split(' ').map(word => {
const regex = new RegExp(`\\b${word}\\b`, 'i');
const match = referenceName.match(regex);
if (match) return match[0];
const partsOfTitle = referenceName.split(' ');
const closest = partsOfTitle.find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase()));
return closest || word;
}).join(' ');
};
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixCasing(lastPart)}, ${fixCasing(firstPart)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field (with case fix).");
} else if (wdGivens.length > 0 && lastPart.split(' ').length > 1) {
const lastParts = lastPart.split(' ');
const misplacedGiven = lastParts.find(part => wdGivens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastParts.filter(p => p !== misplacedGiven).join(' ');
const newGiven = `${misplacedGiven} ${firstPart}`.trim();
targetSortName = `${fixCasing(newSurname)}, ${fixCasing(newGiven)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
} else {
const cLast = fixCasing(lastPart);
const cFirst = fixCasing(firstPart);
if (cLast !== lastPart || cFirst !== firstPart) {
targetSortName = `${cLast}, ${cFirst}`;
renderSortNudge(targetSortName, "Case mismatch relative to article title/Wikidata.");
}
}
} else {
const parts = currentDS.split(' ');
if (parts.length >= 2) {
const last = parts.pop();
const first = parts.join(' ');
const ref = entity.labels?.en?.value || currentTitle;
const fix = (p) => p.split(' ').map(w => (ref.match(new RegExp(`\\b${w}\\b`, 'i')) || [w])[0]).join(' ');
targetSortName = `${fix(last)}, ${fix(first)}`;
renderSortNudge(targetSortName, 'Structure should be "Last, First".');
}
}
} else if (isHuman) {
const cleanTitle = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const parts = stripDiacritics(cleanTitle).split(' ');
if (parts.length >= 2) {
const last = parts.pop();
const first = parts.join(' ');
targetSortName = `${last}, ${first}`;
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).click(() => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
}
// --- Long name extraction (using wikitext) ---
const leadLine = wikitext.split('\n').find(line => line.includes("'''"));
const boldMatch = leadLine ? leadLine.match(/'''(.+?)'''/) : null;
if (boldMatch && isHuman) {
// Clean nicknames in quotes "" and parentheses () and normalize spaces
let longName = boldMatch[1].replace(/\s*("[^"]*"|\([^)]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
const baseTitle = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
if (longName.length > baseTitle.length) {
const nameParts = longName.split(' ');
if (nameParts.length > 1) {
const surname = nameParts.pop();
const givenNames = nameParts.join(' ');
const sortKey = `${surname}, ${givenNames}`;
// Format the special redirect wikitext for the long name
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
// Check if redirect exists and render if missing
checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
}
}
// --- Sort name ---
if (isHuman && targetSortName) {
let targetPage = currentTitle;
let rTemplate = "R from sort name";
let labelSuffix = "";
// If the article has a parenthetical disambiguator, find basename
if (currentTitle.includes(' (')) {
targetPage = currentTitle.split(' (')[0];
rTemplate = "R from ambiguous sort name";
labelSuffix = " (ambiguous)";
}
let rCat = rTemplate;
if (targetSortName.includes(',')) {
const parts = targetSortName.split(',');
const lI = parts[0].trim().charAt(0).toUpperCase();
const fI = parts[1].trim().charAt(0).toUpperCase();
if (lI && fI) rCat = `${rTemplate}|${lI}|${fI}`;
}
// Only run checkAndRender once per person
checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name" + labelSuffix);
}
// --- Name list checking via Wikidata P734/P735 ---
if (isHuman) {
const familyNameClaims = entity.claims?.P734 || [];
const givenNameClaims = entity.claims?.P735 || [];
const isKorean = entity.claims?.P172?.some(c => c.mainsnak?.datavalue?.value?.id === "Q14972") || entity.claims?.P27?.some(c => ["Q884", "Q424"].includes(c.mainsnak?.datavalue?.value?.id));
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const getNameLabels = async (claims) => {
const ids = claims.map(c => c.mainsnak.datavalue.value.id);
if (ids.length === 0) return [];
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: ids.join('|'),
props: 'labels',
languages: 'en',
languagefallback: true,
format: 'json',
origin: '*'
}
});
return Object.values(res.entities).map(e => e.labels?.en?.value).filter(Boolean);
};
const [allFamilyNames, allGivenNames] = await Promise.all([
getNameLabels(familyNameClaims),
getNameLabels(givenNameClaims)
]);
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
allFamilyNames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
allGivenNames.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) {
finalGivenNames.add(gn);
}
});
if (finalFamilyNames.size === 0 || finalGivenNames.size === 0) {
const titleParts = tClean.split(' ');
if (titleParts.length >= 2) {
if (finalFamilyNames.size === 0) finalFamilyNames.add(titleParts[titleParts.length - 1]);
if (finalGivenNames.size === 0) finalGivenNames.add(titleParts[0]);
}
}
const isOrphan = isOrphanTagged && backLinkCount < 1;
for (const name of finalGivenNames) {
await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan);
}
for (const name of finalFamilyNames) {
let customTarget = null;
if (isKorean) {
if (name === "Lee") {
customTarget = "List of people with the Korean family name Lee";
} else if (name === "Kim") {
customTarget = "List of people with the Korean family name Kim";
}
}
if (customTarget) {
const listRes = await api.get({
action: 'query',
prop: 'revisions',
titles: customTarget,
rvprop: 'content',
formatversion: 2
});
const listPage = listRes.query.pages[0];
if (listPage && !listPage.missing) {
const listText = listPage.revisions[0].content;
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(listText);
if (!alreadyListed) {
const $container = $('<div>').addClass('comrade-container comrade-info');
const $header = $('<div>').addClass('comrade-header').append($('<strong>').text('Informative:'));
const $content = $('<div>').addClass('comrade-content').append($('<span>').append(`Surname not currently listed at `, $('<a>').attr({
href: mw.util.getUrl(customTarget),
target: '_blank'
}).text(customTarget), "; 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',
'font-family': 'monospace'
}));
const $copyBtn = $('<button>').text('Copy').click(function() {
navigator.clipboard.writeText(snippet).then(() => {
const $this = $(this);
$this.text('Copied!').prop('disabled', true);
setTimeout(() => $this.text('Copy').prop('disabled', false), 1500);
});
});
$snippetWrapper.append($instructionSpan, $copyBtn);
$content.append($snippetWrapper);
if (!hasShortDesc) $content.append($('<div>').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);
}
}
} else {
await checkNameList(name, 'surname', currentTitle, hasShortDesc, isOrphan);
}
}
}
}
} catch (err) {
console.error("Comrade error:", err);
}
});
}
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}`).click(() => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
})();
// </nowiki>
40ippic7ztakv9a5iz019wh2ow7reb3
737801
737800
2026-04-13T07:27:30Z
Sam Sailor
26820
Test
737801
javascript
text/javascript
(function() {
var Comrade = Comrade || {};
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').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-" + btoa(unescape(encodeURIComponent(redirTitle))).replace(/=/g, "");
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}]]`;
// Pass the customWikitext through to the creation function
const $btn = $('<button>').text('Create redirect').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);
}
}
function checkChemicalRedirect(wikitext, currentTitle) {
if (!/\{\{\s*Chembox Properties/i.test(wikitext)) {
console.log("Comrade: No 'Chembox Properties' template found.");
return;
}
let formula = "";
const formulaMatch = wikitext.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
formula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
console.log("Comrade: Found explicit Formula parameter:", formula);
} else {
console.log("Comrade: No explicit formula. Searching for individual elements...");
const chemboxSection = wikitext.match(/\{\{\s*Chembox Properties[\s\S]*?\}\}/i);
if (chemboxSection) {
const content = chemboxSection[0];
console.log("Comrade: Chembox content extracted:", content);
// Comprehensive list extracted from [[Template:Chembox Elements/molecular formula]]
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+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch) {
console.log(`Comrade: Match found for ${el}:`, elMatch[0]);
if (elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
}
});
console.log("Comrade: Final element data object:", data);
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
// If carbon exists, C then H
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
// Alphabetical for everything else
const remaining = elementsPresent.filter(el => {
if (hasC && (el === "C" || el === "H")) return false;
return true;
}).sort();
// Special case: If NO carbon, H must be sorted alphabetically
remaining.forEach(el => {
formulaArray.push(el + data[el]);
});
formula = formulaArray.join('');
console.log("Comrade: Generated formula from elements:", formula);
}
} else {
console.log("Comrade: Failed to extract Chembox Properties section content.");
}
}
if (formula && formula !== currentTitle) {
console.log(`Comrade: Success! Triggering redirect for ${formula}`);
checkAndRenderRedirect(formula, currentTitle, "R from chemical formula", "Chemical formula");
} else {
console.log("Comrade: Formula matches current title or is empty. No redirect needed.");
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
// Try to get the URL from Wikidata (P856)
if (qid) {
try {
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
}
});
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
console.log("Comrade: Found domain candidate in Wikidata:", domainCandidate);
}
} catch (e) {
console.log("Comrade: Wikidata API call failed.");
}
}
// Fallback to Infobox URL if Wikidata is empty
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
if (domainCandidate) console.log("Comrade: Found domain candidate in Infobox:", domainCandidate);
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
// Only proceed if it's a root domain (pathname is "/")
if (domain.includes(".") && url.pathname === "/") {
console.log(`Comrade: Verifying if ${domainCandidate} is live via Citation API...`);
// The live check, CORS-friendly proxy
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
$.ajax({
url: citationURL,
method: 'GET',
success: function() {
console.log(`Comrade: URL is live. Proceeding with redirect for: ${domain}`);
checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
},
error: function(xhr) {
console.log(`Comrade: URL failed live check (Status: ${xhr.status}). Skipping redirect.`);
}
});
} else {
console.log(`Comrade: ${domain} rejected (not a root domain or missing TLD).`);
}
} catch (e) {
console.log("Comrade: Error parsing URL object.");
}
} else {
console.log("Comrade: No domain candidate found for this article.");
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan) {
console.log(`[Comrade] Checking ${type} list for: ${name}`);
const api = new mw.Api();
// Added lowercase version of the type to the candidates
const candidates = [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
// Set to keep track of titles we've already checked to avoid redundant API calls
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
console.log(`[Comrade] Querying: ${title}`);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
// redirects: 1 // Follow redirects to find the actual list page
redirects: 0 // Do not follow redirects
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const resolvedTitle = page.title;
// Check for specific name templates
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
// Check for disambiguation pages with name parameters
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
console.log(`[Comrade] Results for ${resolvedTitle} - Match: ${isNameMatch}, Dab: ${isNameDab}`);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) {
console.log(`[Comrade] ${currentTitle} is already listed on ${resolvedTitle}`);
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:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; should it be?"));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not currently listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; 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').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();
// Use custom wikitext if provided (for Long Name), otherwise use standard template
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
return api.postWithToken('csrf', {
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
}).done(() => {
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
});
}
async function performDeorphan() {
const api = new mw.Api();
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);
}
function replaceMI(text) {
const miStart = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miStart.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;
miStart.lastIndex = lastIndex;
}
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.postWithToken('csrf', {
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName} (Comrade)`,
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'
});
console.error("Comrade save error:", err);
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
if (mw.config.get("wgNamespaceNumber") === 0 && mw.config.get("wgAction") === "view") {
$(document).ready(async function() {
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').click(() => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
checkChemicalRedirect(wikitext, currentTitle);
checkDomainRedirect(qid, currentTitle);
if (qid) {
const wikiData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
const entity = wikiData.entities[qid];
const isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
let targetSortName = "";
if (dsMatch && isHuman) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
const givenNameIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
let wdGivens = [];
if (givenNameIds.length > 0) {
const nameData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: givenNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
wdGivens = givenNameIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean);
}
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
const referenceName = entity.labels?.en?.value || currentTitle;
const fixCasing = (part) => {
return part.split(' ').map(word => {
const regex = new RegExp(`\\b${word}\\b`, 'i');
const match = referenceName.match(regex);
if (match) return match[0];
const partsOfTitle = referenceName.split(' ');
const closest = partsOfTitle.find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase()));
return closest || word;
}).join(' ');
};
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixCasing(lastPart)}, ${fixCasing(firstPart)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field (with case fix).");
} else if (wdGivens.length > 0 && lastPart.split(' ').length > 1) {
const lastParts = lastPart.split(' ');
const misplacedGiven = lastParts.find(part => wdGivens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastParts.filter(p => p !== misplacedGiven).join(' ');
const newGiven = `${misplacedGiven} ${firstPart}`.trim();
targetSortName = `${fixCasing(newSurname)}, ${fixCasing(newGiven)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
} else {
const cLast = fixCasing(lastPart);
const cFirst = fixCasing(firstPart);
if (cLast !== lastPart || cFirst !== firstPart) {
targetSortName = `${cLast}, ${cFirst}`;
renderSortNudge(targetSortName, "Case mismatch relative to article title/Wikidata.");
}
}
} else {
const parts = currentDS.split(' ');
if (parts.length >= 2) {
const last = parts.pop();
const first = parts.join(' ');
const ref = entity.labels?.en?.value || currentTitle;
const fix = (p) => p.split(' ').map(w => (ref.match(new RegExp(`\\b${w}\\b`, 'i')) || [w])[0]).join(' ');
targetSortName = `${fix(last)}, ${fix(first)}`;
renderSortNudge(targetSortName, 'Structure should be "Last, First".');
}
}
} else if (isHuman) {
const cleanTitle = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const parts = stripDiacritics(cleanTitle).split(' ');
if (parts.length >= 2) {
const last = parts.pop();
const first = parts.join(' ');
targetSortName = `${last}, ${first}`;
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).click(() => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
}
// --- Long name extraction (using wikitext) ---
const leadLine = wikitext.split('\n').find(line => line.includes("'''"));
const boldMatch = leadLine ? leadLine.match(/'''([^']+)'''/) : null;
if (boldMatch && isHuman) {
// Clean nicknames in quotes "" and parentheses () and normalize spaces
let longName = boldMatch[1].replace(/\s*("[^"]*"|\([^)]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
if (longName.length > currentTitle.length) {
const nameParts = longName.split(' ');
if (nameParts.length > 1) {
const surname = nameParts.pop();
const givenNames = nameParts.join(' ');
const sortKey = `${surname}, ${givenNames}`;
// Format the special redirect wikitext for the long name
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
// Check if redirect exists and render if missing
checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
}
}
// --- Sort name ---
if (isHuman && targetSortName) {
let targetPage = currentTitle;
let rTemplate = "R from sort name";
let labelSuffix = "";
// If the article has a parenthetical disambiguator, find basename
if (currentTitle.includes(' (')) {
targetPage = currentTitle.split(' (')[0];
rTemplate = "R from ambiguous sort name";
labelSuffix = " (ambiguous)";
}
let rCat = rTemplate;
if (targetSortName.includes(',')) {
const parts = targetSortName.split(',');
const lI = parts[0].trim().charAt(0).toUpperCase();
const fI = parts[1].trim().charAt(0).toUpperCase();
if (lI && fI) rCat = `${rTemplate}|${lI}|${fI}`;
}
// Only run checkAndRender once per person
checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name" + labelSuffix);
}
// --- Name list checking via Wikidata P734/P735 ---
if (isHuman) {
const familyNameClaims = entity.claims?.P734 || [];
const givenNameClaims = entity.claims?.P735 || [];
const isKorean = entity.claims?.P172?.some(c => c.mainsnak?.datavalue?.value?.id === "Q14972") || entity.claims?.P27?.some(c => ["Q884", "Q424"].includes(c.mainsnak?.datavalue?.value?.id));
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const getNameLabels = async (claims) => {
const ids = claims.map(c => c.mainsnak.datavalue.value.id);
if (ids.length === 0) return [];
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: ids.join('|'),
props: 'labels',
languages: 'en',
languagefallback: true,
format: 'json',
origin: '*'
}
});
return Object.values(res.entities).map(e => e.labels?.en?.value).filter(Boolean);
};
const [allFamilyNames, allGivenNames] = await Promise.all([
getNameLabels(familyNameClaims),
getNameLabels(givenNameClaims)
]);
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
allFamilyNames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
allGivenNames.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) {
finalGivenNames.add(gn);
}
});
if (finalFamilyNames.size === 0 || finalGivenNames.size === 0) {
const titleParts = tClean.split(' ');
if (titleParts.length >= 2) {
if (finalFamilyNames.size === 0) finalFamilyNames.add(titleParts[titleParts.length - 1]);
if (finalGivenNames.size === 0) finalGivenNames.add(titleParts[0]);
}
}
const isOrphan = isOrphanTagged && backLinkCount < 1;
for (const name of finalGivenNames) {
await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan);
}
for (const name of finalFamilyNames) {
let customTarget = null;
if (isKorean) {
if (name === "Lee") {
customTarget = "List of people with the Korean family name Lee";
} else if (name === "Kim") {
customTarget = "List of people with the Korean family name Kim";
}
}
if (customTarget) {
const listRes = await api.get({
action: 'query',
prop: 'revisions',
titles: customTarget,
rvprop: 'content',
formatversion: 2
});
const listPage = listRes.query.pages[0];
if (listPage && !listPage.missing) {
const listText = listPage.revisions[0].content;
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(listText);
if (!alreadyListed) {
const $container = $('<div>').addClass('comrade-container comrade-info');
const $header = $('<div>').addClass('comrade-header').append($('<strong>').text('Informative:'));
const $content = $('<div>').addClass('comrade-content').append($('<span>').append(`Surname not currently listed at `, $('<a>').attr({
href: mw.util.getUrl(customTarget),
target: '_blank'
}).text(customTarget), "; 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',
'font-family': 'monospace'
}));
const $copyBtn = $('<button>').text('Copy').click(function() {
navigator.clipboard.writeText(snippet).then(() => {
const $this = $(this);
$this.text('Copied!').prop('disabled', true);
setTimeout(() => $this.text('Copy').prop('disabled', false), 1500);
});
});
$snippetWrapper.append($instructionSpan, $copyBtn);
$content.append($snippetWrapper);
if (!hasShortDesc) $content.append($('<div>').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);
}
}
} else {
await checkNameList(name, 'surname', currentTitle, hasShortDesc, isOrphan);
}
}
}
}
} catch (err) {
console.error("Comrade error:", err);
}
});
}
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}`).click(() => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
})();
0ypvv51pdr55x5j82cx5aaaplm5905k
737802
737801
2026-04-13T07:31:27Z
Sam Sailor
26820
Test
737802
javascript
text/javascript
(function() {
var Comrade = Comrade || {};
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').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-" + btoa(unescape(encodeURIComponent(redirTitle))).replace(/=/g, "");
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}]]`;
// Pass the customWikitext through to the creation function
const $btn = $('<button>').text('Create redirect').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);
}
}
function checkChemicalRedirect(wikitext, currentTitle) {
if (!/\{\{\s*Chembox Properties/i.test(wikitext)) {
console.log("Comrade: No 'Chembox Properties' template found.");
return;
}
let formula = "";
const formulaMatch = wikitext.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
formula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
console.log("Comrade: Found explicit Formula parameter:", formula);
} else {
console.log("Comrade: No explicit formula. Searching for individual elements...");
const chemboxSection = wikitext.match(/\{\{\s*Chembox Properties[\s\S]*?\}\}/i);
if (chemboxSection) {
const content = chemboxSection[0];
console.log("Comrade: Chembox content extracted:", content);
// Comprehensive list extracted from [[Template:Chembox Elements/molecular formula]]
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+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch) {
console.log(`Comrade: Match found for ${el}:`, elMatch[0]);
if (elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
}
});
console.log("Comrade: Final element data object:", data);
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
// If carbon exists, C then H
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
// Alphabetical for everything else
const remaining = elementsPresent.filter(el => {
if (hasC && (el === "C" || el === "H")) return false;
return true;
}).sort();
// Special case: If NO carbon, H must be sorted alphabetically
remaining.forEach(el => {
formulaArray.push(el + data[el]);
});
formula = formulaArray.join('');
console.log("Comrade: Generated formula from elements:", formula);
}
} else {
console.log("Comrade: Failed to extract Chembox Properties section content.");
}
}
if (formula && formula !== currentTitle) {
console.log(`Comrade: Success! Triggering redirect for ${formula}`);
checkAndRenderRedirect(formula, currentTitle, "R from chemical formula", "Chemical formula");
} else {
console.log("Comrade: Formula matches current title or is empty. No redirect needed.");
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
// Try to get the URL from Wikidata (P856)
if (qid) {
try {
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
}
});
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
console.log("Comrade: Found domain candidate in Wikidata:", domainCandidate);
}
} catch (e) {
console.log("Comrade: Wikidata API call failed.");
}
}
// Fallback to Infobox URL if Wikidata is empty
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
if (domainCandidate) console.log("Comrade: Found domain candidate in Infobox:", domainCandidate);
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
// Only proceed if it's a root domain (pathname is "/")
if (domain.includes(".") && url.pathname === "/") {
console.log(`Comrade: Verifying if ${domainCandidate} is live via Citation API...`);
// The live check, CORS-friendly proxy
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
$.ajax({
url: citationURL,
method: 'GET',
success: function() {
console.log(`Comrade: URL is live. Proceeding with redirect for: ${domain}`);
checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
},
error: function(xhr) {
console.log(`Comrade: URL failed live check (Status: ${xhr.status}). Skipping redirect.`);
}
});
} else {
console.log(`Comrade: ${domain} rejected (not a root domain or missing TLD).`);
}
} catch (e) {
console.log("Comrade: Error parsing URL object.");
}
} else {
console.log("Comrade: No domain candidate found for this article.");
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan) {
console.log(`[Comrade] Checking ${type} list for: ${name}`);
const api = new mw.Api();
// Added lowercase version of the type to the candidates
const candidates = [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
// Set to keep track of titles we've already checked to avoid redundant API calls
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
console.log(`[Comrade] Querying: ${title}`);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
// redirects: 1 // Follow redirects to find the actual list page
redirects: 0 // Do not follow redirects
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const resolvedTitle = page.title;
// Check for specific name templates
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
// Check for disambiguation pages with name parameters
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
console.log(`[Comrade] Results for ${resolvedTitle} - Match: ${isNameMatch}, Dab: ${isNameDab}`);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) {
console.log(`[Comrade] ${currentTitle} is already listed on ${resolvedTitle}`);
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:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; should it be?"));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not currently listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; 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').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();
// Use custom wikitext if provided (for Long Name), otherwise use standard template
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
return api.postWithToken('csrf', {
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
}).done(() => {
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
});
}
async function performDeorphan() {
const api = new mw.Api();
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);
}
function replaceMI(text) {
const miStart = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miStart.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;
miStart.lastIndex = lastIndex;
}
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.postWithToken('csrf', {
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName} (Comrade)`,
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'
});
console.error("Comrade save error:", err);
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgUserId") !== 3155609 || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") {
return;
}
$(document).ready(async function() {
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').click(() => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
checkChemicalRedirect(wikitext, currentTitle);
checkDomainRedirect(qid, currentTitle);
if (qid) {
const wikiData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
const entity = wikiData.entities[qid];
const isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
let targetSortName = "";
if (dsMatch && isHuman) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
const givenNameIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
let wdGivens = [];
if (givenNameIds.length > 0) {
const nameData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: givenNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
wdGivens = givenNameIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean);
}
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
const referenceName = entity.labels?.en?.value || currentTitle;
const fixCasing = (part) => {
return part.split(' ').map(word => {
const regex = new RegExp(`\\b${word}\\b`, 'i');
const match = referenceName.match(regex);
if (match) return match[0];
const partsOfTitle = referenceName.split(' ');
const closest = partsOfTitle.find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase()));
return closest || word;
}).join(' ');
};
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixCasing(lastPart)}, ${fixCasing(firstPart)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field (with case fix).");
} else if (wdGivens.length > 0 && lastPart.split(' ').length > 1) {
const lastParts = lastPart.split(' ');
const misplacedGiven = lastParts.find(part => wdGivens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastParts.filter(p => p !== misplacedGiven).join(' ');
const newGiven = `${misplacedGiven} ${firstPart}`.trim();
targetSortName = `${fixCasing(newSurname)}, ${fixCasing(newGiven)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
} else {
const cLast = fixCasing(lastPart);
const cFirst = fixCasing(firstPart);
if (cLast !== lastPart || cFirst !== firstPart) {
targetSortName = `${cLast}, ${cFirst}`;
renderSortNudge(targetSortName, "Case mismatch relative to article title/Wikidata.");
}
}
} else {
const parts = currentDS.split(' ');
if (parts.length >= 2) {
const last = parts.pop();
const first = parts.join(' ');
const ref = entity.labels?.en?.value || currentTitle;
const fix = (p) => p.split(' ').map(w => (ref.match(new RegExp(`\\b${w}\\b`, 'i')) || [w])[0]).join(' ');
targetSortName = `${fix(last)}, ${fix(first)}`;
renderSortNudge(targetSortName, 'Structure should be "Last, First".');
}
}
} else if (isHuman) {
const cleanTitle = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const parts = stripDiacritics(cleanTitle).split(' ');
if (parts.length >= 2) {
const last = parts.pop();
const first = parts.join(' ');
targetSortName = `${last}, ${first}`;
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).click(() => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
}
// --- Long name extraction (using wikitext) ---
const leadLine = wikitext.split('\n').find(line => line.includes("'''"));
const boldMatch = leadLine ? leadLine.match(/'''([^']+)'''/) : null;
if (boldMatch && isHuman) {
// Clean nicknames in quotes "" and parentheses () and normalize spaces
let longName = boldMatch[1].replace(/\s*("[^"]*"|\([^)]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
if (longName.length > currentTitle.length) {
const nameParts = longName.split(' ');
if (nameParts.length > 1) {
const surname = nameParts.pop();
const givenNames = nameParts.join(' ');
const sortKey = `${surname}, ${givenNames}`;
// Format the special redirect wikitext for the long name
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
// Check if redirect exists and render if missing
checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
}
}
// --- Sort name ---
if (isHuman && targetSortName) {
let targetPage = currentTitle;
let rTemplate = "R from sort name";
let labelSuffix = "";
// If the article has a parenthetical disambiguator, find basename
if (currentTitle.includes(' (')) {
targetPage = currentTitle.split(' (')[0];
rTemplate = "R from ambiguous sort name";
labelSuffix = " (ambiguous)";
}
let rCat = rTemplate;
if (targetSortName.includes(',')) {
const parts = targetSortName.split(',');
const lI = parts[0].trim().charAt(0).toUpperCase();
const fI = parts[1].trim().charAt(0).toUpperCase();
if (lI && fI) rCat = `${rTemplate}|${lI}|${fI}`;
}
// Only run checkAndRender once per person
checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name" + labelSuffix);
}
// --- Name list checking via Wikidata P734/P735 ---
if (isHuman) {
const familyNameClaims = entity.claims?.P734 || [];
const givenNameClaims = entity.claims?.P735 || [];
const isKorean = entity.claims?.P172?.some(c => c.mainsnak?.datavalue?.value?.id === "Q14972") || entity.claims?.P27?.some(c => ["Q884", "Q424"].includes(c.mainsnak?.datavalue?.value?.id));
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const getNameLabels = async (claims) => {
const ids = claims.map(c => c.mainsnak.datavalue.value.id);
if (ids.length === 0) return [];
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: ids.join('|'),
props: 'labels',
languages: 'en',
languagefallback: true,
format: 'json',
origin: '*'
}
});
return Object.values(res.entities).map(e => e.labels?.en?.value).filter(Boolean);
};
const [allFamilyNames, allGivenNames] = await Promise.all([
getNameLabels(familyNameClaims),
getNameLabels(givenNameClaims)
]);
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
allFamilyNames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
allGivenNames.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) {
finalGivenNames.add(gn);
}
});
if (finalFamilyNames.size === 0 || finalGivenNames.size === 0) {
const titleParts = tClean.split(' ');
if (titleParts.length >= 2) {
if (finalFamilyNames.size === 0) finalFamilyNames.add(titleParts[titleParts.length - 1]);
if (finalGivenNames.size === 0) finalGivenNames.add(titleParts[0]);
}
}
const isOrphan = isOrphanTagged && backLinkCount < 1;
for (const name of finalGivenNames) {
await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan);
}
for (const name of finalFamilyNames) {
let customTarget = null;
if (isKorean) {
if (name === "Lee") {
customTarget = "List of people with the Korean family name Lee";
} else if (name === "Kim") {
customTarget = "List of people with the Korean family name Kim";
}
}
if (customTarget) {
const listRes = await api.get({
action: 'query',
prop: 'revisions',
titles: customTarget,
rvprop: 'content',
formatversion: 2
});
const listPage = listRes.query.pages[0];
if (listPage && !listPage.missing) {
const listText = listPage.revisions[0].content;
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(listText);
if (!alreadyListed) {
const $container = $('<div>').addClass('comrade-container comrade-info');
const $header = $('<div>').addClass('comrade-header').append($('<strong>').text('Informative:'));
const $content = $('<div>').addClass('comrade-content').append($('<span>').append(`Surname not currently listed at `, $('<a>').attr({
href: mw.util.getUrl(customTarget),
target: '_blank'
}).text(customTarget), "; 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',
'font-family': 'monospace'
}));
const $copyBtn = $('<button>').text('Copy').click(function() {
navigator.clipboard.writeText(snippet).then(() => {
const $this = $(this);
$this.text('Copied!').prop('disabled', true);
setTimeout(() => $this.text('Copy').prop('disabled', false), 1500);
});
});
$snippetWrapper.append($instructionSpan, $copyBtn);
$content.append($snippetWrapper);
if (!hasShortDesc) $content.append($('<div>').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);
}
}
} else {
await checkNameList(name, 'surname', currentTitle, hasShortDesc, isOrphan);
}
}
}
}
} catch (err) {
console.error("Comrade error:", err);
}
});
}
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}`).click(() => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
})();
ne3kykijmm3a258zlrdn7bjvcd3i9i0
737803
737802
2026-04-13T07:33:21Z
Sam Sailor
26820
Test
737803
javascript
text/javascript
(function() {
var Comrade = Comrade || {};
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').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-" + btoa(unescape(encodeURIComponent(redirTitle))).replace(/=/g, "");
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}]]`;
// Pass the customWikitext through to the creation function
const $btn = $('<button>').text('Create redirect').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);
}
}
function checkChemicalRedirect(wikitext, currentTitle) {
if (!/\{\{\s*Chembox Properties/i.test(wikitext)) {
console.log("Comrade: No 'Chembox Properties' template found.");
return;
}
let formula = "";
const formulaMatch = wikitext.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
formula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
console.log("Comrade: Found explicit Formula parameter:", formula);
} else {
console.log("Comrade: No explicit formula. Searching for individual elements...");
const chemboxSection = wikitext.match(/\{\{\s*Chembox Properties[\s\S]*?\}\}/i);
if (chemboxSection) {
const content = chemboxSection[0];
console.log("Comrade: Chembox content extracted:", content);
// Comprehensive list extracted from [[Template:Chembox Elements/molecular formula]]
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+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch) {
console.log(`Comrade: Match found for ${el}:`, elMatch[0]);
if (elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
}
});
console.log("Comrade: Final element data object:", data);
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
// If carbon exists, C then H
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
// Alphabetical for everything else
const remaining = elementsPresent.filter(el => {
if (hasC && (el === "C" || el === "H")) return false;
return true;
}).sort();
// Special case: If NO carbon, H must be sorted alphabetically
remaining.forEach(el => {
formulaArray.push(el + data[el]);
});
formula = formulaArray.join('');
console.log("Comrade: Generated formula from elements:", formula);
}
} else {
console.log("Comrade: Failed to extract Chembox Properties section content.");
}
}
if (formula && formula !== currentTitle) {
console.log(`Comrade: Success! Triggering redirect for ${formula}`);
checkAndRenderRedirect(formula, currentTitle, "R from chemical formula", "Chemical formula");
} else {
console.log("Comrade: Formula matches current title or is empty. No redirect needed.");
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
// Try to get the URL from Wikidata (P856)
if (qid) {
try {
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
}
});
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
console.log("Comrade: Found domain candidate in Wikidata:", domainCandidate);
}
} catch (e) {
console.log("Comrade: Wikidata API call failed.");
}
}
// Fallback to Infobox URL if Wikidata is empty
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
if (domainCandidate) console.log("Comrade: Found domain candidate in Infobox:", domainCandidate);
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
// Only proceed if it's a root domain (pathname is "/")
if (domain.includes(".") && url.pathname === "/") {
console.log(`Comrade: Verifying if ${domainCandidate} is live via Citation API...`);
// The live check, CORS-friendly proxy
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
$.ajax({
url: citationURL,
method: 'GET',
success: function() {
console.log(`Comrade: URL is live. Proceeding with redirect for: ${domain}`);
checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
},
error: function(xhr) {
console.log(`Comrade: URL failed live check (Status: ${xhr.status}). Skipping redirect.`);
}
});
} else {
console.log(`Comrade: ${domain} rejected (not a root domain or missing TLD).`);
}
} catch (e) {
console.log("Comrade: Error parsing URL object.");
}
} else {
console.log("Comrade: No domain candidate found for this article.");
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan) {
console.log(`[Comrade] Checking ${type} list for: ${name}`);
const api = new mw.Api();
// Added lowercase version of the type to the candidates
const candidates = [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
// Set to keep track of titles we've already checked to avoid redundant API calls
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
console.log(`[Comrade] Querying: ${title}`);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
// redirects: 1 // Follow redirects to find the actual list page
redirects: 0 // Do not follow redirects
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const resolvedTitle = page.title;
// Check for specific name templates
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
// Check for disambiguation pages with name parameters
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
console.log(`[Comrade] Results for ${resolvedTitle} - Match: ${isNameMatch}, Dab: ${isNameDab}`);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) {
console.log(`[Comrade] ${currentTitle} is already listed on ${resolvedTitle}`);
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:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; should it be?"));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not currently listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; 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').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();
// Use custom wikitext if provided (for Long Name), otherwise use standard template
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
return api.postWithToken('csrf', {
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
}).done(() => {
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
});
}
async function performDeorphan() {
const api = new mw.Api();
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);
}
function replaceMI(text) {
const miStart = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miStart.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;
miStart.lastIndex = lastIndex;
}
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.postWithToken('csrf', {
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName} (Comrade)`,
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'
});
console.error("Comrade save error:", err);
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgUserId") !== 19244234 || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") {
return;
} {
$(document).ready(async function() {
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').click(() => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
checkChemicalRedirect(wikitext, currentTitle);
checkDomainRedirect(qid, currentTitle);
if (qid) {
const wikiData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
const entity = wikiData.entities[qid];
const isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
let targetSortName = "";
if (dsMatch && isHuman) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
const givenNameIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
let wdGivens = [];
if (givenNameIds.length > 0) {
const nameData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: givenNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
wdGivens = givenNameIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean);
}
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
const referenceName = entity.labels?.en?.value || currentTitle;
const fixCasing = (part) => {
return part.split(' ').map(word => {
const regex = new RegExp(`\\b${word}\\b`, 'i');
const match = referenceName.match(regex);
if (match) return match[0];
const partsOfTitle = referenceName.split(' ');
const closest = partsOfTitle.find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase()));
return closest || word;
}).join(' ');
};
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixCasing(lastPart)}, ${fixCasing(firstPart)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field (with case fix).");
} else if (wdGivens.length > 0 && lastPart.split(' ').length > 1) {
const lastParts = lastPart.split(' ');
const misplacedGiven = lastParts.find(part => wdGivens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastParts.filter(p => p !== misplacedGiven).join(' ');
const newGiven = `${misplacedGiven} ${firstPart}`.trim();
targetSortName = `${fixCasing(newSurname)}, ${fixCasing(newGiven)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
} else {
const cLast = fixCasing(lastPart);
const cFirst = fixCasing(firstPart);
if (cLast !== lastPart || cFirst !== firstPart) {
targetSortName = `${cLast}, ${cFirst}`;
renderSortNudge(targetSortName, "Case mismatch relative to article title/Wikidata.");
}
}
} else {
const parts = currentDS.split(' ');
if (parts.length >= 2) {
const last = parts.pop();
const first = parts.join(' ');
const ref = entity.labels?.en?.value || currentTitle;
const fix = (p) => p.split(' ').map(w => (ref.match(new RegExp(`\\b${w}\\b`, 'i')) || [w])[0]).join(' ');
targetSortName = `${fix(last)}, ${fix(first)}`;
renderSortNudge(targetSortName, 'Structure should be "Last, First".');
}
}
} else if (isHuman) {
const cleanTitle = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const parts = stripDiacritics(cleanTitle).split(' ');
if (parts.length >= 2) {
const last = parts.pop();
const first = parts.join(' ');
targetSortName = `${last}, ${first}`;
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).click(() => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
}
// --- Long name extraction (using wikitext) ---
const leadLine = wikitext.split('\n').find(line => line.includes("'''"));
const boldMatch = leadLine ? leadLine.match(/'''([^']+)'''/) : null;
if (boldMatch && isHuman) {
// Clean nicknames in quotes "" and parentheses () and normalize spaces
let longName = boldMatch[1].replace(/\s*("[^"]*"|\([^)]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
if (longName.length > currentTitle.length) {
const nameParts = longName.split(' ');
if (nameParts.length > 1) {
const surname = nameParts.pop();
const givenNames = nameParts.join(' ');
const sortKey = `${surname}, ${givenNames}`;
// Format the special redirect wikitext for the long name
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
// Check if redirect exists and render if missing
checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
}
}
// --- Sort name ---
if (isHuman && targetSortName) {
let targetPage = currentTitle;
let rTemplate = "R from sort name";
let labelSuffix = "";
// If the article has a parenthetical disambiguator, find basename
if (currentTitle.includes(' (')) {
targetPage = currentTitle.split(' (')[0];
rTemplate = "R from ambiguous sort name";
labelSuffix = " (ambiguous)";
}
let rCat = rTemplate;
if (targetSortName.includes(',')) {
const parts = targetSortName.split(',');
const lI = parts[0].trim().charAt(0).toUpperCase();
const fI = parts[1].trim().charAt(0).toUpperCase();
if (lI && fI) rCat = `${rTemplate}|${lI}|${fI}`;
}
// Only run checkAndRender once per person
checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name" + labelSuffix);
}
// --- Name list checking via Wikidata P734/P735 ---
if (isHuman) {
const familyNameClaims = entity.claims?.P734 || [];
const givenNameClaims = entity.claims?.P735 || [];
const isKorean = entity.claims?.P172?.some(c => c.mainsnak?.datavalue?.value?.id === "Q14972") || entity.claims?.P27?.some(c => ["Q884", "Q424"].includes(c.mainsnak?.datavalue?.value?.id));
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const getNameLabels = async (claims) => {
const ids = claims.map(c => c.mainsnak.datavalue.value.id);
if (ids.length === 0) return [];
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: ids.join('|'),
props: 'labels',
languages: 'en',
languagefallback: true,
format: 'json',
origin: '*'
}
});
return Object.values(res.entities).map(e => e.labels?.en?.value).filter(Boolean);
};
const [allFamilyNames, allGivenNames] = await Promise.all([
getNameLabels(familyNameClaims),
getNameLabels(givenNameClaims)
]);
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
allFamilyNames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
allGivenNames.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) {
finalGivenNames.add(gn);
}
});
if (finalFamilyNames.size === 0 || finalGivenNames.size === 0) {
const titleParts = tClean.split(' ');
if (titleParts.length >= 2) {
if (finalFamilyNames.size === 0) finalFamilyNames.add(titleParts[titleParts.length - 1]);
if (finalGivenNames.size === 0) finalGivenNames.add(titleParts[0]);
}
}
const isOrphan = isOrphanTagged && backLinkCount < 1;
for (const name of finalGivenNames) {
await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan);
}
for (const name of finalFamilyNames) {
let customTarget = null;
if (isKorean) {
if (name === "Lee") {
customTarget = "List of people with the Korean family name Lee";
} else if (name === "Kim") {
customTarget = "List of people with the Korean family name Kim";
}
}
if (customTarget) {
const listRes = await api.get({
action: 'query',
prop: 'revisions',
titles: customTarget,
rvprop: 'content',
formatversion: 2
});
const listPage = listRes.query.pages[0];
if (listPage && !listPage.missing) {
const listText = listPage.revisions[0].content;
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(listText);
if (!alreadyListed) {
const $container = $('<div>').addClass('comrade-container comrade-info');
const $header = $('<div>').addClass('comrade-header').append($('<strong>').text('Informative:'));
const $content = $('<div>').addClass('comrade-content').append($('<span>').append(`Surname not currently listed at `, $('<a>').attr({
href: mw.util.getUrl(customTarget),
target: '_blank'
}).text(customTarget), "; 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',
'font-family': 'monospace'
}));
const $copyBtn = $('<button>').text('Copy').click(function() {
navigator.clipboard.writeText(snippet).then(() => {
const $this = $(this);
$this.text('Copied!').prop('disabled', true);
setTimeout(() => $this.text('Copy').prop('disabled', false), 1500);
});
});
$snippetWrapper.append($instructionSpan, $copyBtn);
$content.append($snippetWrapper);
if (!hasShortDesc) $content.append($('<div>').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);
}
}
} else {
await checkNameList(name, 'surname', currentTitle, hasShortDesc, isOrphan);
}
}
}
}
} catch (err) {
console.error("Comrade error:", err);
}
});
}
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}`).click(() => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
})();
94jid3vn3r3prawmeswhuvtrcvk8aq1
737804
737803
2026-04-13T07:35:53Z
Sam Sailor
26820
Test
737804
javascript
text/javascript
(function() {
var Comrade = Comrade || {};
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').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-" + btoa(unescape(encodeURIComponent(redirTitle))).replace(/=/g, "");
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}]]`;
// Pass the customWikitext through to the creation function
const $btn = $('<button>').text('Create redirect').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);
}
}
function checkChemicalRedirect(wikitext, currentTitle) {
if (!/\{\{\s*Chembox Properties/i.test(wikitext)) {
console.log("Comrade: No 'Chembox Properties' template found.");
return;
}
let formula = "";
const formulaMatch = wikitext.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
formula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
console.log("Comrade: Found explicit Formula parameter:", formula);
} else {
console.log("Comrade: No explicit formula. Searching for individual elements...");
const chemboxSection = wikitext.match(/\{\{\s*Chembox Properties[\s\S]*?\}\}/i);
if (chemboxSection) {
const content = chemboxSection[0];
console.log("Comrade: Chembox content extracted:", content);
// Comprehensive list extracted from [[Template:Chembox Elements/molecular formula]]
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+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch) {
console.log(`Comrade: Match found for ${el}:`, elMatch[0]);
if (elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
}
});
console.log("Comrade: Final element data object:", data);
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
// If carbon exists, C then H
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
// Alphabetical for everything else
const remaining = elementsPresent.filter(el => {
if (hasC && (el === "C" || el === "H")) return false;
return true;
}).sort();
// Special case: If NO carbon, H must be sorted alphabetically
remaining.forEach(el => {
formulaArray.push(el + data[el]);
});
formula = formulaArray.join('');
console.log("Comrade: Generated formula from elements:", formula);
}
} else {
console.log("Comrade: Failed to extract Chembox Properties section content.");
}
}
if (formula && formula !== currentTitle) {
console.log(`Comrade: Success! Triggering redirect for ${formula}`);
checkAndRenderRedirect(formula, currentTitle, "R from chemical formula", "Chemical formula");
} else {
console.log("Comrade: Formula matches current title or is empty. No redirect needed.");
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
// Try to get the URL from Wikidata (P856)
if (qid) {
try {
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
}
});
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
console.log("Comrade: Found domain candidate in Wikidata:", domainCandidate);
}
} catch (e) {
console.log("Comrade: Wikidata API call failed.");
}
}
// Fallback to Infobox URL if Wikidata is empty
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
if (domainCandidate) console.log("Comrade: Found domain candidate in Infobox:", domainCandidate);
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
// Only proceed if it's a root domain (pathname is "/")
if (domain.includes(".") && url.pathname === "/") {
console.log(`Comrade: Verifying if ${domainCandidate} is live via Citation API...`);
// The live check, CORS-friendly proxy
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
$.ajax({
url: citationURL,
method: 'GET',
success: function() {
console.log(`Comrade: URL is live. Proceeding with redirect for: ${domain}`);
checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
},
error: function(xhr) {
console.log(`Comrade: URL failed live check (Status: ${xhr.status}). Skipping redirect.`);
}
});
} else {
console.log(`Comrade: ${domain} rejected (not a root domain or missing TLD).`);
}
} catch (e) {
console.log("Comrade: Error parsing URL object.");
}
} else {
console.log("Comrade: No domain candidate found for this article.");
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan) {
console.log(`[Comrade] Checking ${type} list for: ${name}`);
const api = new mw.Api();
// Added lowercase version of the type to the candidates
const candidates = [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
// Set to keep track of titles we've already checked to avoid redundant API calls
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
console.log(`[Comrade] Querying: ${title}`);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
// redirects: 1 // Follow redirects to find the actual list page
redirects: 0 // Do not follow redirects
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const resolvedTitle = page.title;
// Check for specific name templates
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
// Check for disambiguation pages with name parameters
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
console.log(`[Comrade] Results for ${resolvedTitle} - Match: ${isNameMatch}, Dab: ${isNameDab}`);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) {
console.log(`[Comrade] ${currentTitle} is already listed on ${resolvedTitle}`);
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:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; should it be?"));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not currently listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; 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').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();
// Use custom wikitext if provided (for Long Name), otherwise use standard template
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
return api.postWithToken('csrf', {
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
}).done(() => {
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
});
}
async function performDeorphan() {
const api = new mw.Api();
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);
}
function replaceMI(text) {
const miStart = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miStart.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;
miStart.lastIndex = lastIndex;
}
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.postWithToken('csrf', {
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName} (Comrade)`,
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'
});
console.error("Comrade save error:", err);
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgUserId") !== 19244234 || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") {
return;
} {
$(document).ready(async function() {
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').click(() => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
checkChemicalRedirect(wikitext, currentTitle);
checkDomainRedirect(qid, currentTitle);
if (qid) {
const wikiData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
const entity = wikiData.entities[qid];
const isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
let targetSortName = "";
if (dsMatch && isHuman) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
const givenNameIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
let wdGivens = [];
if (givenNameIds.length > 0) {
const nameData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: givenNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
wdGivens = givenNameIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean);
}
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
const referenceName = entity.labels?.en?.value || currentTitle;
const fixCasing = (part) => {
return part.split(' ').map(word => {
const regex = new RegExp(`\\b${word}\\b`, 'i');
const match = referenceName.match(regex);
if (match) return match[0];
const partsOfTitle = referenceName.split(' ');
const closest = partsOfTitle.find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase()));
return closest || word;
}).join(' ');
};
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixCasing(lastPart)}, ${fixCasing(firstPart)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field (with case fix).");
} else if (wdGivens.length > 0 && lastPart.split(' ').length > 1) {
const lastParts = lastPart.split(' ');
const misplacedGiven = lastParts.find(part => wdGivens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastParts.filter(p => p !== misplacedGiven).join(' ');
const newGiven = `${misplacedGiven} ${firstPart}`.trim();
targetSortName = `${fixCasing(newSurname)}, ${fixCasing(newGiven)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
} else {
const cLast = fixCasing(lastPart);
const cFirst = fixCasing(firstPart);
if (cLast !== lastPart || cFirst !== firstPart) {
targetSortName = `${cLast}, ${cFirst}`;
renderSortNudge(targetSortName, "Case mismatch relative to article title/Wikidata.");
}
}
} else {
const parts = currentDS.split(' ');
if (parts.length >= 2) {
const last = parts.pop();
const first = parts.join(' ');
const ref = entity.labels?.en?.value || currentTitle;
const fix = (p) => p.split(' ').map(w => (ref.match(new RegExp(`\\b${w}\\b`, 'i')) || [w])[0]).join(' ');
targetSortName = `${fix(last)}, ${fix(first)}`;
renderSortNudge(targetSortName, 'Structure should be "Last, First".');
}
}
} else if (isHuman) {
const cleanTitle = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const parts = stripDiacritics(cleanTitle).split(' ');
if (parts.length >= 2) {
const last = parts.pop();
const first = parts.join(' ');
targetSortName = `${last}, ${first}`;
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).click(() => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
}
// --- Long name extraction (using wikitext) ---
const leadLine = wikitext.split('\n').find(line => line.includes("'''"));
const boldMatch = leadLine ? leadLine.match(/'''([^']+)'''/) : null;
if (boldMatch && isHuman) {
// Clean nicknames in quotes "" and parentheses () and normalize spaces
let longName = boldMatch[1].replace(/\s*("[^"]*"|\([^)]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
// Strip disambiguators from currentTitle for a fair length comparison
const baseTitle = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
if (longName.length > baseTitle.length) {
const nameParts = longName.split(' ');
if (nameParts.length > 1) {
const surname = nameParts.pop();
const givenNames = nameParts.join(' ');
const sortKey = `${surname}, ${givenNames}`;
// Format the special redirect wikitext for the long name
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
// Check if redirect exists and render if missing
checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
}
}
// --- Sort name ---
if (isHuman && targetSortName) {
let targetPage = currentTitle;
let rTemplate = "R from sort name";
let labelSuffix = "";
// If the article has a parenthetical disambiguator, find basename
if (currentTitle.includes(' (')) {
targetPage = currentTitle.split(' (')[0];
rTemplate = "R from ambiguous sort name";
labelSuffix = " (ambiguous)";
}
let rCat = rTemplate;
if (targetSortName.includes(',')) {
const parts = targetSortName.split(',');
const lI = parts[0].trim().charAt(0).toUpperCase();
const fI = parts[1].trim().charAt(0).toUpperCase();
if (lI && fI) rCat = `${rTemplate}|${lI}|${fI}`;
}
// Only run checkAndRender once per person
checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name" + labelSuffix);
}
// --- Name list checking via Wikidata P734/P735 ---
if (isHuman) {
const familyNameClaims = entity.claims?.P734 || [];
const givenNameClaims = entity.claims?.P735 || [];
const isKorean = entity.claims?.P172?.some(c => c.mainsnak?.datavalue?.value?.id === "Q14972") || entity.claims?.P27?.some(c => ["Q884", "Q424"].includes(c.mainsnak?.datavalue?.value?.id));
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const getNameLabels = async (claims) => {
const ids = claims.map(c => c.mainsnak.datavalue.value.id);
if (ids.length === 0) return [];
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: ids.join('|'),
props: 'labels',
languages: 'en',
languagefallback: true,
format: 'json',
origin: '*'
}
});
return Object.values(res.entities).map(e => e.labels?.en?.value).filter(Boolean);
};
const [allFamilyNames, allGivenNames] = await Promise.all([
getNameLabels(familyNameClaims),
getNameLabels(givenNameClaims)
]);
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
allFamilyNames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
allGivenNames.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) {
finalGivenNames.add(gn);
}
});
if (finalFamilyNames.size === 0 || finalGivenNames.size === 0) {
const titleParts = tClean.split(' ');
if (titleParts.length >= 2) {
if (finalFamilyNames.size === 0) finalFamilyNames.add(titleParts[titleParts.length - 1]);
if (finalGivenNames.size === 0) finalGivenNames.add(titleParts[0]);
}
}
const isOrphan = isOrphanTagged && backLinkCount < 1;
for (const name of finalGivenNames) {
await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan);
}
for (const name of finalFamilyNames) {
let customTarget = null;
if (isKorean) {
if (name === "Lee") {
customTarget = "List of people with the Korean family name Lee";
} else if (name === "Kim") {
customTarget = "List of people with the Korean family name Kim";
}
}
if (customTarget) {
const listRes = await api.get({
action: 'query',
prop: 'revisions',
titles: customTarget,
rvprop: 'content',
formatversion: 2
});
const listPage = listRes.query.pages[0];
if (listPage && !listPage.missing) {
const listText = listPage.revisions[0].content;
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(listText);
if (!alreadyListed) {
const $container = $('<div>').addClass('comrade-container comrade-info');
const $header = $('<div>').addClass('comrade-header').append($('<strong>').text('Informative:'));
const $content = $('<div>').addClass('comrade-content').append($('<span>').append(`Surname not currently listed at `, $('<a>').attr({
href: mw.util.getUrl(customTarget),
target: '_blank'
}).text(customTarget), "; 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',
'font-family': 'monospace'
}));
const $copyBtn = $('<button>').text('Copy').click(function() {
navigator.clipboard.writeText(snippet).then(() => {
const $this = $(this);
$this.text('Copied!').prop('disabled', true);
setTimeout(() => $this.text('Copy').prop('disabled', false), 1500);
});
});
$snippetWrapper.append($instructionSpan, $copyBtn);
$content.append($snippetWrapper);
if (!hasShortDesc) $content.append($('<div>').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);
}
}
} else {
await checkNameList(name, 'surname', currentTitle, hasShortDesc, isOrphan);
}
}
}
}
} catch (err) {
console.error("Comrade error:", err);
}
});
}
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}`).click(() => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
})();
nguh44tzqtabpggjkh1bsnvvfdfur7w
737805
737804
2026-04-13T08:21:40Z
Sam Sailor
26820
Test
737805
javascript
text/javascript
(function() {
var Comrade = Comrade || {};
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').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-" + btoa(unescape(encodeURIComponent(redirTitle))).replace(/=/g, "");
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}]]`;
// Pass the customWikitext through to the creation function
const $btn = $('<button>').text('Create redirect').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);
}
}
function checkChemicalRedirect(wikitext, currentTitle) {
if (!/\{\{\s*Chembox Properties/i.test(wikitext)) {
console.log("Comrade: No 'Chembox Properties' template found.");
return;
}
let formula = "";
const formulaMatch = wikitext.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
formula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
console.log("Comrade: Found explicit Formula parameter:", formula);
} else {
console.log("Comrade: No explicit formula. Searching for individual elements...");
const chemboxSection = wikitext.match(/\{\{\s*Chembox Properties[\s\S]*?\}\}/i);
if (chemboxSection) {
const content = chemboxSection[0];
console.log("Comrade: Chembox content extracted:", content);
// Comprehensive list extracted from [[Template:Chembox Elements/molecular formula]]
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+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch) {
console.log(`Comrade: Match found for ${el}:`, elMatch[0]);
if (elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
}
});
console.log("Comrade: Final element data object:", data);
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
// If carbon exists, C then H
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
// Alphabetical for everything else
const remaining = elementsPresent.filter(el => {
if (hasC && (el === "C" || el === "H")) return false;
return true;
}).sort();
// Special case: If NO carbon, H must be sorted alphabetically
remaining.forEach(el => {
formulaArray.push(el + data[el]);
});
formula = formulaArray.join('');
console.log("Comrade: Generated formula from elements:", formula);
}
} else {
console.log("Comrade: Failed to extract Chembox Properties section content.");
}
}
if (formula && formula !== currentTitle) {
console.log(`Comrade: Success! Triggering redirect for ${formula}`);
checkAndRenderRedirect(formula, currentTitle, "R from chemical formula", "Chemical formula");
} else {
console.log("Comrade: Formula matches current title or is empty. No redirect needed.");
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
// Try to get the URL from Wikidata (P856)
if (qid) {
try {
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
}
});
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
console.log("Comrade: Found domain candidate in Wikidata:", domainCandidate);
}
} catch (e) {
console.log("Comrade: Wikidata API call failed.");
}
}
// Fallback to Infobox URL if Wikidata is empty
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
if (domainCandidate) console.log("Comrade: Found domain candidate in Infobox:", domainCandidate);
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
// Only proceed if it's a root domain (pathname is "/")
if (domain.includes(".") && url.pathname === "/") {
console.log(`Comrade: Verifying if ${domainCandidate} is live via Citation API...`);
// The live check, CORS-friendly proxy
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
$.ajax({
url: citationURL,
method: 'GET',
success: function() {
console.log(`Comrade: URL is live. Proceeding with redirect for: ${domain}`);
checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
},
error: function(xhr) {
console.log(`Comrade: URL failed live check (Status: ${xhr.status}). Skipping redirect.`);
}
});
} else {
console.log(`Comrade: ${domain} rejected (not a root domain or missing TLD).`);
}
} catch (e) {
console.log("Comrade: Error parsing URL object.");
}
} else {
console.log("Comrade: No domain candidate found for this article.");
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan) {
console.log(`[Comrade] Checking ${type} list for: ${name}`);
const api = new mw.Api();
// Added lowercase version of the type to the candidates
const candidates = [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
// Set to keep track of titles we've already checked to avoid redundant API calls
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
console.log(`[Comrade] Querying: ${title}`);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
// redirects: 1 // Follow redirects to find the actual list page
redirects: 0 // Do not follow redirects
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const resolvedTitle = page.title;
// Check for specific name templates
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
// Check for disambiguation pages with name parameters
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
console.log(`[Comrade] Results for ${resolvedTitle} - Match: ${isNameMatch}, Dab: ${isNameDab}`);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) {
console.log(`[Comrade] ${currentTitle} is already listed on ${resolvedTitle}`);
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:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; should it be?"));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not currently listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; 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').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();
// Use custom wikitext if provided (for Long Name), otherwise use standard template
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
return api.postWithToken('csrf', {
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
}).done(() => {
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
});
}
async function performDeorphan() {
const api = new mw.Api();
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);
}
function replaceMI(text) {
const miStart = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miStart.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;
miStart.lastIndex = lastIndex;
}
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.postWithToken('csrf', {
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName} (Comrade)`,
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'
});
console.error("Comrade save error:", err);
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgUserId") !== 19244234 || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") {
return;
} {
$(document).ready(async function() {
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').click(() => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
checkChemicalRedirect(wikitext, currentTitle);
checkDomainRedirect(qid, currentTitle);
if (qid) {
const wikiData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
const entity = wikiData.entities[qid];
const isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
let targetSortName = "";
if (dsMatch && isHuman) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
const givenNameIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
let wdGivens = [];
if (givenNameIds.length > 0) {
const nameData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: givenNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
wdGivens = givenNameIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean);
}
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
const referenceName = entity.labels?.en?.value || currentTitle;
const fixCasing = (part) => {
return part.split(' ').map(word => {
const regex = new RegExp(`\\b${word}\\b`, 'i');
const match = referenceName.match(regex);
if (match) return match[0];
const partsOfTitle = referenceName.split(' ');
const closest = partsOfTitle.find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase()));
return closest || word;
}).join(' ');
};
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixCasing(lastPart)}, ${fixCasing(firstPart)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field (with case fix).");
} else if (wdGivens.length > 0 && lastPart.split(' ').length > 1) {
const lastParts = lastPart.split(' ');
const misplacedGiven = lastParts.find(part => wdGivens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastParts.filter(p => p !== misplacedGiven).join(' ');
const newGiven = `${misplacedGiven} ${firstPart}`.trim();
targetSortName = `${fixCasing(newSurname)}, ${fixCasing(newGiven)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
} else {
const cLast = fixCasing(lastPart);
const cFirst = fixCasing(firstPart);
if (cLast !== lastPart || cFirst !== firstPart) {
targetSortName = `${cLast}, ${cFirst}`;
renderSortNudge(targetSortName, "Case mismatch relative to article title/Wikidata.");
}
}
} else {
const parts = currentDS.split(' ');
if (parts.length >= 2) {
const last = parts.pop();
const first = parts.join(' ');
const ref = entity.labels?.en?.value || currentTitle;
const fix = (p) => p.split(' ').map(w => (ref.match(new RegExp(`\\b${w}\\b`, 'i')) || [w])[0]).join(' ');
targetSortName = `${fix(last)}, ${fix(first)}`;
renderSortNudge(targetSortName, 'Structure should be "Last, First".');
}
}
} else if (isHuman) {
const cleanTitle = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const parts = stripDiacritics(cleanTitle).split(' ');
if (parts.length >= 2) {
const last = parts.pop();
const first = parts.join(' ');
targetSortName = `${last}, ${first}`;
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).click(() => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
}
// --- Long name extraction (using wikitext) ---
const leadLine = wikitext.split('\n').find(line => line.includes("'''"));
const boldMatch = leadLine ? leadLine.match(/'''([^']+)'''/) : null;
if (boldMatch && isHuman) {
// Clean nicknames in quotes "" and parentheses () and normalize spaces
let longName = boldMatch[1].replace(/\s*("[^"]*"|\([^)]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
// Strip disambiguators from currentTitle for a fair length comparison
const baseTitle = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
if (longName.length > baseTitle.length) {
const nameParts = longName.split(' ');
if (nameParts.length > 1) {
const surname = nameParts.pop();
const givenNames = nameParts.join(' ');
const sortKey = `${surname}, ${givenNames}`;
// Format the special redirect wikitext for the long name
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
// Check if redirect exists and render if missing
checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
}
}
// --- Sort name ---
if (isHuman && targetSortName) {
let targetPage = currentTitle;
let rTemplate = "R from sort name";
let labelSuffix = "";
// If the article has a parenthetical disambiguator, find basename
if (currentTitle.includes(' (')) {
targetPage = currentTitle.split(' (')[0];
rTemplate = "R from ambiguous sort name";
labelSuffix = " (ambiguous)";
}
let rCat = rTemplate;
if (targetSortName.includes(',')) {
const parts = targetSortName.split(',');
const lI = parts[0].trim().charAt(0).toUpperCase();
const fI = parts[1].trim().charAt(0).toUpperCase();
if (lI && fI) rCat = `${rTemplate}|${lI}|${fI}`;
}
// Only run checkAndRender once per person
checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name" + labelSuffix);
}
// --- Name list checking via Wikidata P734/P735 ---
if (isHuman) {
const familyNameClaims = entity.claims?.P734 || [];
const givenNameClaims = entity.claims?.P735 || [];
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const getNameLabels = async (claims) => {
const ids = claims.map(c => c.mainsnak.datavalue.value.id);
if (ids.length === 0) return [];
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: ids.join('|'),
props: 'labels',
languages: 'en',
languagefallback: true,
format: 'json',
origin: '*'
}
});
return Object.values(res.entities).map(e => e.labels?.en?.value).filter(Boolean);
};
const [allFamilyNames, allGivenNames] = await Promise.all([
getNameLabels(familyNameClaims),
getNameLabels(givenNameClaims)
]);
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
allFamilyNames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
allGivenNames.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) {
finalGivenNames.add(gn);
}
});
if (finalFamilyNames.size === 0 || finalGivenNames.size === 0) {
const titleParts = tClean.split(' ');
if (titleParts.length >= 2) {
if (finalFamilyNames.size === 0) finalFamilyNames.add(titleParts[titleParts.length - 1]);
if (finalGivenNames.size === 0) finalGivenNames.add(titleParts[0]);
}
}
const isOrphan = isOrphanTagged && backLinkCount < 1;
// Helper to probe for the best available list title by priority
const getPriorityTarget = async (name, type) => {
let titlesToTry = [];
if (type === 'surname') {
titlesToTry = [`List of people with surname ${name}`, `List of people with the Korean family name ${name}`, `List of people with the Chinese family name ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`,
name
];
} else {
titlesToTry = [`List of people with given name ${name}`, `List of people named ${name}`, `${name} (given name)`,
name
];
}
const listRes = await api.get({
action: 'query',
titles: titlesToTry.join('|'),
formatversion: 2
});
const pages = listRes.query.pages;
return titlesToTry.find(t => {
const p = pages.find(pg => pg.title === t);
return p && !p.missing;
});
};
// Check given names
for (const name of finalGivenNames) {
const target = await getPriorityTarget(name, 'given');
if (target) {
await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
}
// Check family names
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);
}
});
}
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}`).click(() => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
})();
1pyhvtc2vc1jedw5mysxne43wdgfcbl
737806
737805
2026-04-13T08:57:52Z
Sam Sailor
26820
Test
737806
javascript
text/javascript
(function() {
var Comrade = Comrade || {};
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').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-" + btoa(unescape(encodeURIComponent(redirTitle))).replace(/=/g, "");
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}]]`;
// Pass the customWikitext through to the creation function
const $btn = $('<button>').text('Create redirect').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);
}
}
function checkChemicalRedirect(wikitext, currentTitle) {
if (!/\{\{\s*Chembox Properties/i.test(wikitext)) {
console.log("Comrade: No 'Chembox Properties' template found.");
return;
}
let formula = "";
const formulaMatch = wikitext.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
formula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
console.log("Comrade: Found explicit Formula parameter:", formula);
} else {
console.log("Comrade: No explicit formula. Searching for individual elements...");
const chemboxSection = wikitext.match(/\{\{\s*Chembox Properties[\s\S]*?\}\}/i);
if (chemboxSection) {
const content = chemboxSection[0];
console.log("Comrade: Chembox content extracted:", content);
// Comprehensive list extracted from [[Template:Chembox Elements/molecular formula]]
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+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch) {
console.log(`Comrade: Match found for ${el}:`, elMatch[0]);
if (elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
}
});
console.log("Comrade: Final element data object:", data);
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
// If carbon exists, C then H
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
// Alphabetical for everything else
const remaining = elementsPresent.filter(el => {
if (hasC && (el === "C" || el === "H")) return false;
return true;
}).sort();
// Special case: If NO carbon, H must be sorted alphabetically
remaining.forEach(el => {
formulaArray.push(el + data[el]);
});
formula = formulaArray.join('');
console.log("Comrade: Generated formula from elements:", formula);
}
} else {
console.log("Comrade: Failed to extract Chembox Properties section content.");
}
}
if (formula && formula !== currentTitle) {
console.log(`Comrade: Success! Triggering redirect for ${formula}`);
checkAndRenderRedirect(formula, currentTitle, "R from chemical formula", "Chemical formula");
} else {
console.log("Comrade: Formula matches current title or is empty. No redirect needed.");
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
// Try to get the URL from Wikidata (P856)
if (qid) {
try {
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
}
});
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
console.log("Comrade: Found domain candidate in Wikidata:", domainCandidate);
}
} catch (e) {
console.log("Comrade: Wikidata API call failed.");
}
}
// Fallback to Infobox URL if Wikidata is empty
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
if (domainCandidate) console.log("Comrade: Found domain candidate in Infobox:", domainCandidate);
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
// Only proceed if it's a root domain (pathname is "/")
if (domain.includes(".") && url.pathname === "/") {
console.log(`Comrade: Verifying if ${domainCandidate} is live via Citation API...`);
// The live check, CORS-friendly proxy
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
$.ajax({
url: citationURL,
method: 'GET',
success: function() {
console.log(`Comrade: URL is live. Proceeding with redirect for: ${domain}`);
checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
},
error: function(xhr) {
console.log(`Comrade: URL failed live check (Status: ${xhr.status}). Skipping redirect.`);
}
});
} else {
console.log(`Comrade: ${domain} rejected (not a root domain or missing TLD).`);
}
} catch (e) {
console.log("Comrade: Error parsing URL object.");
}
} else {
console.log("Comrade: No domain candidate found for this article.");
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan) {
console.log(`[Comrade] Checking ${type} list for: ${name}`);
const api = new mw.Api();
// Added lowercase version of the type to the candidates
const candidates = [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
// Set to keep track of titles we've already checked to avoid redundant API calls
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
console.log(`[Comrade] Querying: ${title}`);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
// redirects: 1 // Follow redirects to find the actual list page
redirects: 0 // Do not follow redirects
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const resolvedTitle = page.title;
// Check for specific name templates
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
// Check for disambiguation pages with name parameters
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
console.log(`[Comrade] Results for ${resolvedTitle} - Match: ${isNameMatch}, Dab: ${isNameDab}`);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) {
console.log(`[Comrade] ${currentTitle} is already listed on ${resolvedTitle}`);
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:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; should it be?"));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not currently listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; 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').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();
// Use custom wikitext if provided (for Long Name), otherwise use standard template
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
return api.postWithToken('csrf', {
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
}).done(() => {
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
});
}
function getSmartSortKey(fullName, entity) {
const countryIds = entity.claims?.P27?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const birthYear = parseInt(entity.claims?.P569?.[0]?.mainsnak?.datavalue?.value?.time?.match(/[+-](\d{4})/)?.[1]) || 2026;
// Eliminate saints
if (fullName.startsWith("Saint ")) {
return fullName.replace("Saint ", "");
}
// Arabic names (modern vs historical)
// Particles: Abu, Abd, Abdel, Abdul, ben, bin, bint
const arabicParticles = /\b(Abu|Abd|Abdel|Abdul|ben|bin|bint)\b/i;
if (fullName.match(arabicParticles)) {
if (birthYear > 1900) {
// Sort as Western: "Osama bin Laden" -> "Bin Laden, Osama"
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; // Historical: sort as written
}
}
// East Asian (family name first)
// Q148 (China), Q184 (Korea), Q1884 (S. Korea), Q424 (N. Korea), Q881 (Vietnam), Q17 (Japan)
const eastAsianQids = ["Q148", "Q184", "Q884", "Q424", "Q881", "Q17"];
const isEastAsian = countryIds.some(id => eastAsianQids.includes(id));
if (isEastAsian) {
// Japanese birth year check
if (countryIds.includes("Q17") && birthYear > 1885) {
// Modern Japanese: Western order
return westernSort(fullName);
}
// Others: Mao Zedong -> Mao, Zedong
const parts = fullName.split(' ');
return `${parts[0]}, ${parts.slice(1).join(' ')}`;
}
// Default Western sort
return westernSort(fullName);
}
function westernSort(name) {
// Handle O'Neill -> ONeill
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();
// Handle Jr, III, etc.
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();
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);
}
function replaceMI(text) {
const miStart = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miStart.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;
miStart.lastIndex = lastIndex;
}
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.postWithToken('csrf', {
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName} (Comrade)`,
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'
});
console.error("Comrade save error:", err);
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgUserId") !== 19244234 || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") {
return;
} {
$(document).ready(async function() {
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').click(() => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
checkChemicalRedirect(wikitext, currentTitle);
checkDomainRedirect(qid, currentTitle);
if (qid) {
const wikiData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
const entity = wikiData.entities[qid];
const isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
let targetSortName = "";
if (dsMatch && isHuman) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
const givenNameIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
let wdGivens = [];
if (givenNameIds.length > 0) {
const nameData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: givenNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
wdGivens = givenNameIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean);
}
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
const referenceName = entity.labels?.en?.value || currentTitle;
const fixCasing = (part) => {
return part.split(' ').map(word => {
const regex = new RegExp(`\\b${word}\\b`, 'i');
const match = referenceName.match(regex);
if (match) return match[0];
const partsOfTitle = referenceName.split(' ');
const closest = partsOfTitle.find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase()));
return closest || word;
}).join(' ');
};
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixCasing(lastPart)}, ${fixCasing(firstPart)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field (with case fix).");
} else if (wdGivens.length > 0 && lastPart.split(' ').length > 1) {
const lastParts = lastPart.split(' ');
const misplacedGiven = lastParts.find(part => wdGivens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastParts.filter(p => p !== misplacedGiven).join(' ');
const newGiven = `${misplacedGiven} ${firstPart}`.trim();
targetSortName = `${fixCasing(newSurname)}, ${fixCasing(newGiven)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
} else {
const cLast = fixCasing(lastPart);
const cFirst = fixCasing(firstPart);
if (cLast !== lastPart || cFirst !== firstPart) {
targetSortName = `${cLast}, ${cFirst}`;
renderSortNudge(targetSortName, "Case mismatch relative to article title/Wikidata.");
}
}
} else {
const parts = currentDS.split(' ');
if (parts.length >= 2) {
const last = parts.pop();
const first = parts.join(' ');
const ref = entity.labels?.en?.value || currentTitle;
const fix = (p) => p.split(' ').map(w => (ref.match(new RegExp(`\\b${w}\\b`, 'i')) || [w])[0]).join(' ');
targetSortName = `${fix(last)}, ${fix(first)}`;
renderSortNudge(targetSortName, 'Structure should be "Last, First".');
}
}
} else if (isHuman) {
const cleanTitle = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const parts = stripDiacritics(cleanTitle).split(' ');
if (parts.length >= 2) {
const last = parts.pop();
const first = parts.join(' ');
targetSortName = `${last}, ${first}`;
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).click(() => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
}
// --- Long name extraction (using wikitext) ---
const leadLine = wikitext.split('\n').find(line => line.includes("'''"));
const boldMatch = leadLine ? leadLine.match(/'''(.+?)'''/) : null;
if (boldMatch && isHuman) {
// Clean nicknames in quotes "" and parentheses () and normalize spaces
let longName = boldMatch[1].replace(/\s*("[^"]*"|\([^)\n]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
// Strip disambiguators from currentTitle for a fair length comparison
const baseTitle = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
if (longName.length > baseTitle.length) {
const nameParts = longName.split(' ');
if (nameParts.length > 1) {
const sortKey = getSmartSortKey(longName, entity);
// Format the special redirect wikitext for the long name
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
// Check if redirect exists and render if missing
checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
}
}
// --- Sort name ---
if (isHuman && targetSortName) {
let targetPage = currentTitle;
let rTemplate = "R from sort name";
let labelSuffix = "";
// If the article has a parenthetical disambiguator, find basename
if (currentTitle.includes(' (')) {
targetPage = currentTitle.split(' (')[0];
rTemplate = "R from ambiguous sort name";
labelSuffix = " (ambiguous)";
}
let rCat = rTemplate;
if (targetSortName.includes(',')) {
const parts = targetSortName.split(',');
const lI = parts[0].trim().charAt(0).toUpperCase();
const fI = parts[1].trim().charAt(0).toUpperCase();
if (lI && fI) rCat = `${rTemplate}|${lI}|${fI}`;
}
// Only run checkAndRender once per person
checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name" + labelSuffix);
}
// --- Name list checking via Wikidata P734/P735 ---
if (isHuman) {
const familyNameClaims = entity.claims?.P734 || [];
const givenNameClaims = entity.claims?.P735 || [];
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const getNameLabels = async (claims) => {
const ids = claims.map(c => c.mainsnak.datavalue.value.id);
if (ids.length === 0) return [];
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: ids.join('|'),
props: 'labels',
languages: 'en',
languagefallback: true,
format: 'json',
origin: '*'
}
});
return Object.values(res.entities).map(e => e.labels?.en?.value).filter(Boolean);
};
const [allFamilyNames, allGivenNames] = await Promise.all([
getNameLabels(familyNameClaims),
getNameLabels(givenNameClaims)
]);
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
allFamilyNames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
allGivenNames.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) {
finalGivenNames.add(gn);
}
});
if (finalFamilyNames.size === 0 || finalGivenNames.size === 0) {
const titleParts = tClean.split(' ');
if (titleParts.length >= 2) {
if (finalFamilyNames.size === 0) finalFamilyNames.add(titleParts[titleParts.length - 1]);
if (finalGivenNames.size === 0) finalGivenNames.add(titleParts[0]);
}
}
const isOrphan = isOrphanTagged && backLinkCount < 1;
// Helper to probe for the best available list title by priority
const getPriorityTarget = async (name, type) => {
let titlesToTry = [];
if (type === 'surname') {
titlesToTry = [`List of people with surname ${name}`, `List of people with the Korean family name ${name}`, `List of people with the Chinese family name ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`,
name
];
} else {
titlesToTry = [`List of people with given name ${name}`, `List of people named ${name}`, `${name} (given name)`,
name
];
}
const listRes = await api.get({
action: 'query',
titles: titlesToTry.join('|'),
formatversion: 2
});
const pages = listRes.query.pages;
return titlesToTry.find(t => {
const p = pages.find(pg => pg.title === t);
return p && !p.missing;
});
};
// Check given names
for (const name of finalGivenNames) {
const target = await getPriorityTarget(name, 'given');
if (target) {
await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
}
// Check family names
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);
}
});
}
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}`).click(() => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
})();
h0jvlyiy5kzr64424ltro0d1urpohn2
737807
737806
2026-04-13T09:12:03Z
Sam Sailor
26820
Test
737807
javascript
text/javascript
(function() {
var Comrade = Comrade || {};
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').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-" + btoa(unescape(encodeURIComponent(redirTitle))).replace(/=/g, "");
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}]]`;
// Pass the customWikitext through to the creation function
const $btn = $('<button>').text('Create redirect').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);
}
}
function checkChemicalRedirect(wikitext, currentTitle) {
if (!/\{\{\s*Chembox Properties/i.test(wikitext)) {
console.log("Comrade: No 'Chembox Properties' template found.");
return;
}
let formula = "";
const formulaMatch = wikitext.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
formula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
console.log("Comrade: Found explicit Formula parameter:", formula);
} else {
console.log("Comrade: No explicit formula. Searching for individual elements...");
const chemboxSection = wikitext.match(/\{\{\s*Chembox Properties[\s\S]*?\}\}/i);
if (chemboxSection) {
const content = chemboxSection[0];
console.log("Comrade: Chembox content extracted:", content);
// Comprehensive list extracted from [[Template:Chembox Elements/molecular formula]]
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+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch) {
console.log(`Comrade: Match found for ${el}:`, elMatch[0]);
if (elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
}
});
console.log("Comrade: Final element data object:", data);
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
// If carbon exists, C then H
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
// Alphabetical for everything else
const remaining = elementsPresent.filter(el => {
if (hasC && (el === "C" || el === "H")) return false;
return true;
}).sort();
// Special case: If NO carbon, H must be sorted alphabetically
remaining.forEach(el => {
formulaArray.push(el + data[el]);
});
formula = formulaArray.join('');
console.log("Comrade: Generated formula from elements:", formula);
}
} else {
console.log("Comrade: Failed to extract Chembox Properties section content.");
}
}
if (formula && formula !== currentTitle) {
console.log(`Comrade: Success! Triggering redirect for ${formula}`);
checkAndRenderRedirect(formula, currentTitle, "R from chemical formula", "Chemical formula");
} else {
console.log("Comrade: Formula matches current title or is empty. No redirect needed.");
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
// Try to get the URL from Wikidata (P856)
if (qid) {
try {
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
}
});
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
console.log("Comrade: Found domain candidate in Wikidata:", domainCandidate);
}
} catch (e) {
console.log("Comrade: Wikidata API call failed.");
}
}
// Fallback to Infobox URL if Wikidata is empty
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
if (domainCandidate) console.log("Comrade: Found domain candidate in Infobox:", domainCandidate);
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
// Only proceed if it's a root domain (pathname is "/")
if (domain.includes(".") && url.pathname === "/") {
console.log(`Comrade: Verifying if ${domainCandidate} is live via Citation API...`);
// The live check, CORS-friendly proxy
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
$.ajax({
url: citationURL,
method: 'GET',
success: function() {
console.log(`Comrade: URL is live. Proceeding with redirect for: ${domain}`);
checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
},
error: function(xhr) {
console.log(`Comrade: URL failed live check (Status: ${xhr.status}). Skipping redirect.`);
}
});
} else {
console.log(`Comrade: ${domain} rejected (not a root domain or missing TLD).`);
}
} catch (e) {
console.log("Comrade: Error parsing URL object.");
}
} else {
console.log("Comrade: No domain candidate found for this article.");
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan) {
console.log(`[Comrade] Checking ${type} list for: ${name}`);
const api = new mw.Api();
// Added lowercase version of the type to the candidates
const candidates = [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
// Set to keep track of titles we've already checked to avoid redundant API calls
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
console.log(`[Comrade] Querying: ${title}`);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
// redirects: 1 // Follow redirects to find the actual list page
redirects: 0 // Do not follow redirects
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const resolvedTitle = page.title;
// Check for specific name templates
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
// Check for disambiguation pages with name parameters
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
console.log(`[Comrade] Results for ${resolvedTitle} - Match: ${isNameMatch}, Dab: ${isNameDab}`);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) {
console.log(`[Comrade] ${currentTitle} is already listed on ${resolvedTitle}`);
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:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; should it be?"));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not currently listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; 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').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();
// Use custom wikitext if provided (for Long Name), otherwise use standard template
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
return api.postWithToken('csrf', {
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
}).done(() => {
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
});
}
function getSmartSortKey(fullName, entity) {
const countryIds = entity.claims?.P27?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
// Include P17 (country) for historical figures
const locationIds = entity.claims?.P17?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
const allCountryIds = [...countryIds, ...locationIds];
const birthYear = parseInt(entity.claims?.P569?.[0]?.mainsnak?.datavalue?.value?.time?.match(/[+-](\d{4})/)?.[1]) || 2026;
// Eliminate saints
if (fullName.startsWith("Saint ")) {
return fullName.replace("Saint ", "");
}
// Arabic names (modern vs historical)
// Particles: Abu, Abd, Abdel, Abdul, ben, bin, bint
const arabicParticles = /\b(Abu|Abd|Abdel|Abdul|ben|bin|bint)\b/i;
if (fullName.match(arabicParticles)) {
if (birthYear > 1900) {
// Sort as Western: "Osama bin Laden" -> "Bin Laden, Osama"
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; // Historical: sort as written
}
}
// East Asian (family name first)
// Q148 (China), Q184 (Korea), Q1884 (S. Korea), Q424 (N. Korea), Q881 (Vietnam), Q17 (Japan)
const eastAsianQids = ["Q148", "Q184", "Q884", "Q424", "Q881", "Q17"];
const isEastAsian = allCountryIds.some(id => eastAsianQids.includes(id));
if (isEastAsian) {
// Japanese birth year check
if (allCountryIds.includes("Q17") && birthYear > 1885) {
// Modern Japanese: Western order
return westernSort(fullName);
}
// Others: Mao Zedong -> Mao, Zedong
const parts = fullName.split(' ');
if (parts.length > 1) {
return `${parts[0]}, ${parts.slice(1).join(' ')}`;
}
}
// Default Western sort
return westernSort(fullName);
}
function westernSort(name) {
// Handle O'Neill -> ONeill
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();
// Handle Jr, III, etc.
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();
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);
}
function replaceMI(text) {
const miStart = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miStart.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;
miStart.lastIndex = lastIndex;
}
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.postWithToken('csrf', {
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName} (Comrade)`,
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'
});
console.error("Comrade save error:", err);
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgUserId") !== 19244234 || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") {
return;
} {
$(document).ready(async function() {
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').click(() => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
checkChemicalRedirect(wikitext, currentTitle);
checkDomainRedirect(qid, currentTitle);
if (qid) {
const wikiData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
const entity = wikiData.entities[qid];
const isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
let targetSortName = "";
if (dsMatch && isHuman) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
const givenNameIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
let wdGivens = [];
if (givenNameIds.length > 0) {
const nameData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: givenNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
wdGivens = givenNameIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean);
}
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
const referenceName = entity.labels?.en?.value || currentTitle;
const fixCasing = (part) => {
return part.split(' ').map(word => {
const regex = new RegExp(`\\b${word}\\b`, 'i');
const match = referenceName.match(regex);
if (match) return match[0];
const partsOfTitle = referenceName.split(' ');
const closest = partsOfTitle.find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase()));
return closest || word;
}).join(' ');
};
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixCasing(lastPart)}, ${fixCasing(firstPart)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field (with case fix).");
} else if (wdGivens.length > 0 && lastPart.split(' ').length > 1) {
const lastParts = lastPart.split(' ');
const misplacedGiven = lastParts.find(part => wdGivens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastParts.filter(p => p !== misplacedGiven).join(' ');
const newGiven = `${misplacedGiven} ${firstPart}`.trim();
targetSortName = `${fixCasing(newSurname)}, ${fixCasing(newGiven)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
} else {
const cLast = fixCasing(lastPart);
const cFirst = fixCasing(firstPart);
if (cLast !== lastPart || cFirst !== firstPart) {
targetSortName = `${cLast}, ${cFirst}`;
renderSortNudge(targetSortName, "Case mismatch relative to article title/Wikidata.");
}
}
} else {
const parts = currentDS.split(' ');
if (parts.length >= 2) {
const last = parts.pop();
const first = parts.join(' ');
const ref = entity.labels?.en?.value || currentTitle;
const fix = (p) => p.split(' ').map(w => (ref.match(new RegExp(`\\b${w}\\b`, 'i')) || [w])[0]).join(' ');
targetSortName = `${fix(last)}, ${fix(first)}`;
renderSortNudge(targetSortName, 'Structure should be "Last, First".');
}
}
} else if (isHuman) {
const cleanTitle = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const parts = stripDiacritics(cleanTitle).split(' ');
if (parts.length >= 2) {
const last = parts.pop();
const first = parts.join(' ');
targetSortName = `${last}, ${first}`;
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).click(() => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
}
// --- Long name extraction (using wikitext) ---
const leadLine = wikitext.split('\n').find(line => line.includes("'''"));
const boldMatch = leadLine ? leadLine.match(/'''(.+?)'''/) : null;
if (boldMatch && isHuman) {
// Clean nicknames in quotes "" and parentheses () and normalize spaces
let longName = boldMatch[1].replace(/\s*("[^"]*"|\([^)\n]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
// Strip disambiguators from currentTitle for a fair length comparison
const baseTitle = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
if (longName.length > baseTitle.length) {
const nameParts = longName.split(' ');
if (nameParts.length > 1) {
const sortKey = getSmartSortKey(longName, entity);
// Format the special redirect wikitext for the long name
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
// Check if redirect exists and render if missing
checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
}
}
// --- Sort name ---
if (isHuman && targetSortName) {
let targetPage = currentTitle;
let rTemplate = "R from sort name";
let labelSuffix = "";
// If the article has a parenthetical disambiguator, find basename
if (currentTitle.includes(' (')) {
targetPage = currentTitle.split(' (')[0];
rTemplate = "R from ambiguous sort name";
labelSuffix = " (ambiguous)";
}
let rCat = rTemplate;
if (targetSortName.includes(',')) {
const parts = targetSortName.split(',');
const lI = parts[0].trim().charAt(0).toUpperCase();
const fI = parts[1].trim().charAt(0).toUpperCase();
if (lI && fI) rCat = `${rTemplate}|${lI}|${fI}`;
}
// Only run checkAndRender once per person
checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name" + labelSuffix);
}
// --- Name list checking via Wikidata P734/P735 ---
if (isHuman) {
const familyNameClaims = entity.claims?.P734 || [];
const givenNameClaims = entity.claims?.P735 || [];
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const getNameLabels = async (claims) => {
const ids = claims.map(c => c.mainsnak.datavalue.value.id);
if (ids.length === 0) return [];
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: ids.join('|'),
props: 'labels',
languages: 'en',
languagefallback: true,
format: 'json',
origin: '*'
}
});
return Object.values(res.entities).map(e => e.labels?.en?.value).filter(Boolean);
};
const [allFamilyNames, allGivenNames] = await Promise.all([
getNameLabels(familyNameClaims),
getNameLabels(givenNameClaims)
]);
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
allFamilyNames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
allGivenNames.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) {
finalGivenNames.add(gn);
}
});
if (finalFamilyNames.size === 0 || finalGivenNames.size === 0) {
const titleParts = tClean.split(' ');
if (titleParts.length >= 2) {
if (finalFamilyNames.size === 0) finalFamilyNames.add(titleParts[titleParts.length - 1]);
if (finalGivenNames.size === 0) finalGivenNames.add(titleParts[0]);
}
}
const isOrphan = isOrphanTagged && backLinkCount < 1;
// Helper to probe for the best available list title by priority
const getPriorityTarget = async (name, type) => {
let titlesToTry = [];
if (type === 'surname') {
titlesToTry = [`List of people with surname ${name}`, `List of people with the Korean family name ${name}`, `List of people with the Chinese family name ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`,
name
];
} else {
titlesToTry = [`List of people with given name ${name}`, `List of people named ${name}`, `${name} (given name)`,
name
];
}
const listRes = await api.get({
action: 'query',
titles: titlesToTry.join('|'),
formatversion: 2
});
const pages = listRes.query.pages;
return titlesToTry.find(t => {
const p = pages.find(pg => pg.title === t);
return p && !p.missing;
});
};
// Check given names
for (const name of finalGivenNames) {
const target = await getPriorityTarget(name, 'given');
if (target) {
await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
}
// Check family names
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);
}
});
}
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}`).click(() => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
})();
n7cizoijcqukvt9zd3jdyf25rjj53qb
737808
737807
2026-04-13T09:18:18Z
Sam Sailor
26820
Test
737808
javascript
text/javascript
(function() {
var Comrade = Comrade || {};
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').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-" + btoa(unescape(encodeURIComponent(redirTitle))).replace(/=/g, "");
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}]]`;
// Pass the customWikitext through to the creation function
const $btn = $('<button>').text('Create redirect').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);
}
}
function checkChemicalRedirect(wikitext, currentTitle) {
if (!/\{\{\s*Chembox Properties/i.test(wikitext)) {
console.log("Comrade: No 'Chembox Properties' template found.");
return;
}
let formula = "";
const formulaMatch = wikitext.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
formula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
console.log("Comrade: Found explicit Formula parameter:", formula);
} else {
console.log("Comrade: No explicit formula. Searching for individual elements...");
const chemboxSection = wikitext.match(/\{\{\s*Chembox Properties[\s\S]*?\}\}/i);
if (chemboxSection) {
const content = chemboxSection[0];
console.log("Comrade: Chembox content extracted:", content);
// Comprehensive list extracted from [[Template:Chembox Elements/molecular formula]]
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+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch) {
console.log(`Comrade: Match found for ${el}:`, elMatch[0]);
if (elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
}
});
console.log("Comrade: Final element data object:", data);
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
// If carbon exists, C then H
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
// Alphabetical for everything else
const remaining = elementsPresent.filter(el => {
if (hasC && (el === "C" || el === "H")) return false;
return true;
}).sort();
// Special case: If NO carbon, H must be sorted alphabetically
remaining.forEach(el => {
formulaArray.push(el + data[el]);
});
formula = formulaArray.join('');
console.log("Comrade: Generated formula from elements:", formula);
}
} else {
console.log("Comrade: Failed to extract Chembox Properties section content.");
}
}
if (formula && formula !== currentTitle) {
console.log(`Comrade: Success! Triggering redirect for ${formula}`);
checkAndRenderRedirect(formula, currentTitle, "R from chemical formula", "Chemical formula");
} else {
console.log("Comrade: Formula matches current title or is empty. No redirect needed.");
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
// Try to get the URL from Wikidata (P856)
if (qid) {
try {
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
}
});
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
console.log("Comrade: Found domain candidate in Wikidata:", domainCandidate);
}
} catch (e) {
console.log("Comrade: Wikidata API call failed.");
}
}
// Fallback to Infobox URL if Wikidata is empty
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
if (domainCandidate) console.log("Comrade: Found domain candidate in Infobox:", domainCandidate);
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
// Only proceed if it's a root domain (pathname is "/")
if (domain.includes(".") && url.pathname === "/") {
console.log(`Comrade: Verifying if ${domainCandidate} is live via Citation API...`);
// The live check, CORS-friendly proxy
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
$.ajax({
url: citationURL,
method: 'GET',
success: function() {
console.log(`Comrade: URL is live. Proceeding with redirect for: ${domain}`);
checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
},
error: function(xhr) {
console.log(`Comrade: URL failed live check (Status: ${xhr.status}). Skipping redirect.`);
}
});
} else {
console.log(`Comrade: ${domain} rejected (not a root domain or missing TLD).`);
}
} catch (e) {
console.log("Comrade: Error parsing URL object.");
}
} else {
console.log("Comrade: No domain candidate found for this article.");
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan) {
console.log(`[Comrade] Checking ${type} list for: ${name}`);
const api = new mw.Api();
// Added lowercase version of the type to the candidates
const candidates = [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
// Set to keep track of titles we've already checked to avoid redundant API calls
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
console.log(`[Comrade] Querying: ${title}`);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
// redirects: 1 // Follow redirects to find the actual list page
redirects: 0 // Do not follow redirects
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const resolvedTitle = page.title;
// Check for specific name templates
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
// Check for disambiguation pages with name parameters
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
console.log(`[Comrade] Results for ${resolvedTitle} - Match: ${isNameMatch}, Dab: ${isNameDab}`);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) {
console.log(`[Comrade] ${currentTitle} is already listed on ${resolvedTitle}`);
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:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; should it be?"));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not currently listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; 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').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();
// Use custom wikitext if provided (for Long Name), otherwise use standard template
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
return api.postWithToken('csrf', {
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
}).done(() => {
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
});
}
function getSmartSortKey(fullName, entity) {
const countryIds = entity.claims?.P27?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
// Include P17 (country) for historical figures
const locationIds = entity.claims?.P17?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
// Broaden search to birth/burial places for historical figures
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]) || 2026;
// Eliminate saints
if (fullName.startsWith("Saint ")) {
return fullName.replace("Saint ", "");
}
// Arabic names (modern vs historical)
// Particles: Abu, Abd, Abdel, Abdul, ben, bin, bint
const arabicParticles = /\b(Abu|Abd|Abdel|Abdul|ben|bin|bint)\b/i;
if (fullName.match(arabicParticles)) {
if (birthYear > 1900) {
// Sort as Western: "Osama bin Laden" -> "Bin Laden, Osama"
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; // Historical: sort as written
}
}
// East Asian (family name first)
// Q148 (China), Q184 (Korea), Q1884 (S. Korea), Q424 (N. Korea), Q881 (Vietnam), Q17 (Japan)
const eastAsianQids = ["Q148", "Q184", "Q884", "Q424", "Q881", "Q17"];
const isEastAsian = allRelevantIds.some(id => eastAsianQids.includes(id));
if (isEastAsian) {
// Japanese birth year check
if (allRelevantIds.includes("Q17") && birthYear > 1885) {
// Modern Japanese: Western order
return westernSort(fullName);
}
// Others: Mao Zedong -> Mao, Zedong
const parts = fullName.split(' ');
if (parts.length > 1) {
return `${parts[0]}, ${parts.slice(1).join(' ')}`;
}
}
// Default Western sort
return westernSort(fullName);
}
function westernSort(name) {
// Handle O'Neill -> ONeill
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();
// Handle Jr, III, etc.
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();
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);
}
function replaceMI(text) {
const miStart = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miStart.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;
miStart.lastIndex = lastIndex;
}
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.postWithToken('csrf', {
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName} (Comrade)`,
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'
});
console.error("Comrade save error:", err);
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgUserId") !== 19244234 || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") {
return;
} {
$(document).ready(async function() {
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').click(() => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
checkChemicalRedirect(wikitext, currentTitle);
checkDomainRedirect(qid, currentTitle);
if (qid) {
const wikiData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
const entity = wikiData.entities[qid];
const isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
let targetSortName = "";
if (dsMatch && isHuman) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
const givenNameIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
let wdGivens = [];
if (givenNameIds.length > 0) {
const nameData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: givenNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
wdGivens = givenNameIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean);
}
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
const referenceName = entity.labels?.en?.value || currentTitle;
const fixCasing = (part) => {
return part.split(' ').map(word => {
const regex = new RegExp(`\\b${word}\\b`, 'i');
const match = referenceName.match(regex);
if (match) return match[0];
const partsOfTitle = referenceName.split(' ');
const closest = partsOfTitle.find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase()));
return closest || word;
}).join(' ');
};
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixCasing(lastPart)}, ${fixCasing(firstPart)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field (with case fix).");
} else if (wdGivens.length > 0 && lastPart.split(' ').length > 1) {
const lastParts = lastPart.split(' ');
const misplacedGiven = lastParts.find(part => wdGivens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastParts.filter(p => p !== misplacedGiven).join(' ');
const newGiven = `${misplacedGiven} ${firstPart}`.trim();
targetSortName = `${fixCasing(newSurname)}, ${fixCasing(newGiven)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
} else {
const cLast = fixCasing(lastPart);
const cFirst = fixCasing(firstPart);
if (cLast !== lastPart || cFirst !== firstPart) {
targetSortName = `${cLast}, ${cFirst}`;
renderSortNudge(targetSortName, "Case mismatch relative to article title/Wikidata.");
}
}
} else {
const parts = currentDS.split(' ');
if (parts.length >= 2) {
const last = parts.pop();
const first = parts.join(' ');
const ref = entity.labels?.en?.value || currentTitle;
const fix = (p) => p.split(' ').map(w => (ref.match(new RegExp(`\\b${w}\\b`, 'i')) || [w])[0]).join(' ');
targetSortName = `${fix(last)}, ${fix(first)}`;
renderSortNudge(targetSortName, 'Structure should be "Last, First".');
}
}
} else if (isHuman) {
const cleanTitle = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const parts = stripDiacritics(cleanTitle).split(' ');
if (parts.length >= 2) {
const last = parts.pop();
const first = parts.join(' ');
targetSortName = `${last}, ${first}`;
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).click(() => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
}
// --- Long name extraction (using wikitext) ---
const leadLine = wikitext.split('\n').find(line => line.includes("'''"));
const boldMatch = leadLine ? leadLine.match(/'''(.+?)'''/) : null;
if (boldMatch && isHuman) {
// Clean nicknames in quotes "" and parentheses () and normalize spaces
let longName = boldMatch[1].replace(/\s*("[^"]*"|\([^)\n]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
// Strip disambiguators from currentTitle for a fair length comparison
const baseTitle = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
if (longName.length > baseTitle.length) {
const nameParts = longName.split(' ');
if (nameParts.length > 1) {
const sortKey = getSmartSortKey(longName, entity);
// Format the special redirect wikitext for the long name
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
// Check if redirect exists and render if missing
checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
}
}
// --- Sort name ---
if (isHuman && targetSortName) {
let targetPage = currentTitle;
let rTemplate = "R from sort name";
let labelSuffix = "";
// If the article has a parenthetical disambiguator, find basename
if (currentTitle.includes(' (')) {
targetPage = currentTitle.split(' (')[0];
rTemplate = "R from ambiguous sort name";
labelSuffix = " (ambiguous)";
}
let rCat = rTemplate;
if (targetSortName.includes(',')) {
const parts = targetSortName.split(',');
const lI = parts[0].trim().charAt(0).toUpperCase();
const fI = parts[1].trim().charAt(0).toUpperCase();
if (lI && fI) rCat = `${rTemplate}|${lI}|${fI}`;
}
// Only run checkAndRender once per person
checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name" + labelSuffix);
}
// --- Name list checking via Wikidata P734/P735 ---
if (isHuman) {
const familyNameClaims = entity.claims?.P734 || [];
const givenNameClaims = entity.claims?.P735 || [];
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const getNameLabels = async (claims) => {
const ids = claims.map(c => c.mainsnak.datavalue.value.id);
if (ids.length === 0) return [];
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: ids.join('|'),
props: 'labels',
languages: 'en',
languagefallback: true,
format: 'json',
origin: '*'
}
});
return Object.values(res.entities).map(e => e.labels?.en?.value).filter(Boolean);
};
const [allFamilyNames, allGivenNames] = await Promise.all([
getNameLabels(familyNameClaims),
getNameLabels(givenNameClaims)
]);
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
allFamilyNames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
allGivenNames.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) {
finalGivenNames.add(gn);
}
});
if (finalFamilyNames.size === 0 || finalGivenNames.size === 0) {
const titleParts = tClean.split(' ');
if (titleParts.length >= 2) {
if (finalFamilyNames.size === 0) finalFamilyNames.add(titleParts[titleParts.length - 1]);
if (finalGivenNames.size === 0) finalGivenNames.add(titleParts[0]);
}
}
const isOrphan = isOrphanTagged && backLinkCount < 1;
// Helper to probe for the best available list title by priority
const getPriorityTarget = async (name, type) => {
let titlesToTry = [];
if (type === 'surname') {
titlesToTry = [`List of people with surname ${name}`, `List of people with the Korean family name ${name}`, `List of people with the Chinese family name ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`,
name
];
} else {
titlesToTry = [`List of people with given name ${name}`, `List of people named ${name}`, `${name} (given name)`,
name
];
}
const listRes = await api.get({
action: 'query',
titles: titlesToTry.join('|'),
formatversion: 2
});
const pages = listRes.query.pages;
return titlesToTry.find(t => {
const p = pages.find(pg => pg.title === t);
return p && !p.missing;
});
};
// Check given names
for (const name of finalGivenNames) {
const target = await getPriorityTarget(name, 'given');
if (target) {
await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
}
// Check family names
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);
}
});
}
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}`).click(() => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
})();
pwfqpg8fg308akgwqyib6p7vdub28tq
737809
737808
2026-04-13T09:22:59Z
Sam Sailor
26820
Test
737809
javascript
text/javascript
(function() {
var Comrade = Comrade || {};
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').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-" + btoa(unescape(encodeURIComponent(redirTitle))).replace(/=/g, "");
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}]]`;
// Pass the customWikitext through to the creation function
const $btn = $('<button>').text('Create redirect').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);
}
}
function checkChemicalRedirect(wikitext, currentTitle) {
if (!/\{\{\s*Chembox Properties/i.test(wikitext)) {
console.log("Comrade: No 'Chembox Properties' template found.");
return;
}
let formula = "";
const formulaMatch = wikitext.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
formula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
console.log("Comrade: Found explicit Formula parameter:", formula);
} else {
console.log("Comrade: No explicit formula. Searching for individual elements...");
const chemboxSection = wikitext.match(/\{\{\s*Chembox Properties[\s\S]*?\}\}/i);
if (chemboxSection) {
const content = chemboxSection[0];
console.log("Comrade: Chembox content extracted:", content);
// Comprehensive list extracted from [[Template:Chembox Elements/molecular formula]]
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+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch) {
console.log(`Comrade: Match found for ${el}:`, elMatch[0]);
if (elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
}
});
console.log("Comrade: Final element data object:", data);
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
// If carbon exists, C then H
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
// Alphabetical for everything else
const remaining = elementsPresent.filter(el => {
if (hasC && (el === "C" || el === "H")) return false;
return true;
}).sort();
// Special case: If NO carbon, H must be sorted alphabetically
remaining.forEach(el => {
formulaArray.push(el + data[el]);
});
formula = formulaArray.join('');
console.log("Comrade: Generated formula from elements:", formula);
}
} else {
console.log("Comrade: Failed to extract Chembox Properties section content.");
}
}
if (formula && formula !== currentTitle) {
console.log(`Comrade: Success! Triggering redirect for ${formula}`);
checkAndRenderRedirect(formula, currentTitle, "R from chemical formula", "Chemical formula");
} else {
console.log("Comrade: Formula matches current title or is empty. No redirect needed.");
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
// Try to get the URL from Wikidata (P856)
if (qid) {
try {
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
}
});
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
console.log("Comrade: Found domain candidate in Wikidata:", domainCandidate);
}
} catch (e) {
console.log("Comrade: Wikidata API call failed.");
}
}
// Fallback to Infobox URL if Wikidata is empty
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
if (domainCandidate) console.log("Comrade: Found domain candidate in Infobox:", domainCandidate);
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
// Only proceed if it's a root domain (pathname is "/")
if (domain.includes(".") && url.pathname === "/") {
console.log(`Comrade: Verifying if ${domainCandidate} is live via Citation API...`);
// The live check, CORS-friendly proxy
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
$.ajax({
url: citationURL,
method: 'GET',
success: function() {
console.log(`Comrade: URL is live. Proceeding with redirect for: ${domain}`);
checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
},
error: function(xhr) {
console.log(`Comrade: URL failed live check (Status: ${xhr.status}). Skipping redirect.`);
}
});
} else {
console.log(`Comrade: ${domain} rejected (not a root domain or missing TLD).`);
}
} catch (e) {
console.log("Comrade: Error parsing URL object.");
}
} else {
console.log("Comrade: No domain candidate found for this article.");
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan) {
console.log(`[Comrade] Checking ${type} list for: ${name}`);
const api = new mw.Api();
// Added lowercase version of the type to the candidates
const candidates = [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
// Set to keep track of titles we've already checked to avoid redundant API calls
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
console.log(`[Comrade] Querying: ${title}`);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
// redirects: 1 // Follow redirects to find the actual list page
redirects: 0 // Do not follow redirects
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const resolvedTitle = page.title;
// Check for specific name templates
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
// Check for disambiguation pages with name parameters
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
console.log(`[Comrade] Results for ${resolvedTitle} - Match: ${isNameMatch}, Dab: ${isNameDab}`);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) {
console.log(`[Comrade] ${currentTitle} is already listed on ${resolvedTitle}`);
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:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; should it be?"));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not currently listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; 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').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();
// Use custom wikitext if provided (for Long Name), otherwise use standard template
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
return api.postWithToken('csrf', {
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
}).done(() => {
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
});
}
function getSmartSortKey(fullName, entity) {
const countryIds = entity.claims?.P27?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
// Include P17 (country) for historical figures
const locationIds = entity.claims?.P17?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
// Broaden search to birth/burial places for historical figures
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]) || 2026;
// Eliminate saints
if (fullName.startsWith("Saint ")) {
return fullName.replace("Saint ", "");
}
// Arabic names (modern vs historical)
// Particles: Abu, Abd, Abdel, Abdul, ben, bin, bint
const arabicParticles = /\b(Abu|Abd|Abdel|Abdul|ben|bin|bint)\b/i;
if (fullName.match(arabicParticles)) {
if (birthYear > 1900) {
// Sort as Western: "Osama bin Laden" -> "Bin Laden, Osama"
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; // Historical: sort as written
}
}
// East Asian (family name first)
// Q148 (China), Q184 (Korea), Q1884 (S. Korea), Q424 (N. Korea), Q881 (Vietnam), Q17 (Japan)
const eastAsianQids = ["Q148", "Q184", "Q884", "Q424", "Q881", "Q17"];
const isEastAsian = allRelevantIds.some(id => eastAsianQids.includes(id));
if (isEastAsian) {
// Japanese birth year check
if (allRelevantIds.includes("Q17") && birthYear > 1885) {
// Modern Japanese: Western order
return westernSort(fullName);
}
// Others: Mao Zedong -> Mao, Zedong
const parts = fullName.split(' ');
if (parts.length > 1) {
return `${parts[0]}, ${parts.slice(1).join(' ')}`;
}
}
// Default Western sort
return westernSort(fullName);
}
function westernSort(name) {
// Handle O'Neill -> ONeill
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();
// Handle Jr, III, etc.
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();
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);
}
function replaceMI(text) {
const miStart = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miStart.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;
miStart.lastIndex = lastIndex;
}
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.postWithToken('csrf', {
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName} (Comrade)`,
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'
});
console.error("Comrade save error:", err);
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgUserId") !== 19244234 || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") {
return;
} {
$(document).ready(async function() {
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').click(() => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
checkChemicalRedirect(wikitext, currentTitle);
checkDomainRedirect(qid, currentTitle);
if (qid) {
const wikiData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
const entity = wikiData.entities[qid];
const isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
let targetSortName = "";
if (dsMatch && isHuman) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
const givenNameIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
let wdGivens = [];
if (givenNameIds.length > 0) {
const nameData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: givenNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
wdGivens = givenNameIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean);
}
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
const referenceName = entity.labels?.en?.value || currentTitle;
const fixCasing = (part) => {
return part.split(' ').map(word => {
const regex = new RegExp(`\\b${word}\\b`, 'i');
const match = referenceName.match(regex);
if (match) return match[0];
const partsOfTitle = referenceName.split(' ');
const closest = partsOfTitle.find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase()));
return closest || word;
}).join(' ');
};
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixCasing(lastPart)}, ${fixCasing(firstPart)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field (with case fix).");
} else if (wdGivens.length > 0 && lastPart.split(' ').length > 1) {
const lastParts = lastPart.split(' ');
const misplacedGiven = lastParts.find(part => wdGivens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastParts.filter(p => p !== misplacedGiven).join(' ');
const newGiven = `${misplacedGiven} ${firstPart}`.trim();
targetSortName = `${fixCasing(newSurname)}, ${fixCasing(newGiven)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
} else {
const cLast = fixCasing(lastPart);
const cFirst = fixCasing(firstPart);
if (cLast !== lastPart || cFirst !== firstPart) {
targetSortName = `${cLast}, ${cFirst}`;
renderSortNudge(targetSortName, "Case mismatch relative to article title/Wikidata.");
}
}
} else {
const parts = currentDS.split(' ');
if (parts.length >= 2) {
const last = parts.pop();
const first = parts.join(' ');
const ref = entity.labels?.en?.value || currentTitle;
const fix = (p) => p.split(' ').map(w => (ref.match(new RegExp(`\\b${w}\\b`, 'i')) || [w])[0]).join(' ');
targetSortName = `${fix(last)}, ${fix(first)}`;
renderSortNudge(targetSortName, 'Structure should be "Last, First".');
}
}
} else if (isHuman) {
const cleanTitle = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const parts = stripDiacritics(cleanTitle).split(' ');
if (parts.length >= 2) {
const last = parts.pop();
const first = parts.join(' ');
targetSortName = `${last}, ${first}`;
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).click(() => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
}
// --- Long name extraction (using wikitext) ---
const leadLine = wikitext.split('\n').find(line => line.includes("'''"));
const boldMatch = leadLine ? leadLine.match(/'''(.+?)'''/) : null;
if (boldMatch && isHuman) {
// Clean nicknames in quotes "" and parentheses () and normalize spaces
let longName = boldMatch[1].replace(/\s*("[^"]*"|\([^)\n]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
// Strip disambiguators from currentTitle for a fair length comparison
const baseTitle = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
if (longName.length > baseTitle.length) {
const nameParts = longName.split(' ');
if (nameParts.length > 1) {
// Check for family name hatnote to detect name order (e.g., Chinese, Korean)
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
let sortKey;
if (hasFamilyNameHatnote) {
// If hatnote exists, title is already "Surname Givenname", so sort as "Surname, Givenname"
sortKey = `${nameParts[0]}, ${nameParts.slice(1).join(' ')}`;
} else {
// Fallback to Wikidata-based smart logic for Western/Arabic/Other patterns
sortKey = getSmartSortKey(longName, entity);
}
// Format the special redirect wikitext for the long name
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
// Check if redirect exists and render if missing
checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
}
}
// --- Sort name ---
if (isHuman && targetSortName) {
let targetPage = currentTitle;
let rTemplate = "R from sort name";
let labelSuffix = "";
// If the article has a parenthetical disambiguator, find basename
if (currentTitle.includes(' (')) {
targetPage = currentTitle.split(' (')[0];
rTemplate = "R from ambiguous sort name";
labelSuffix = " (ambiguous)";
}
let rCat = rTemplate;
if (targetSortName.includes(',')) {
const parts = targetSortName.split(',');
const lI = parts[0].trim().charAt(0).toUpperCase();
const fI = parts[1].trim().charAt(0).toUpperCase();
if (lI && fI) rCat = `${rTemplate}|${lI}|${fI}`;
}
// Only run checkAndRender once per person
checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name" + labelSuffix);
}
// --- Name list checking via Wikidata P734/P735 ---
if (isHuman) {
const familyNameClaims = entity.claims?.P734 || [];
const givenNameClaims = entity.claims?.P735 || [];
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const getNameLabels = async (claims) => {
const ids = claims.map(c => c.mainsnak.datavalue.value.id);
if (ids.length === 0) return [];
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: ids.join('|'),
props: 'labels',
languages: 'en',
languagefallback: true,
format: 'json',
origin: '*'
}
});
return Object.values(res.entities).map(e => e.labels?.en?.value).filter(Boolean);
};
const [allFamilyNames, allGivenNames] = await Promise.all([
getNameLabels(familyNameClaims),
getNameLabels(givenNameClaims)
]);
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
allFamilyNames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
allGivenNames.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) {
finalGivenNames.add(gn);
}
});
if (finalFamilyNames.size === 0 || finalGivenNames.size === 0) {
const titleParts = tClean.split(' ');
if (titleParts.length >= 2) {
if (finalFamilyNames.size === 0) finalFamilyNames.add(titleParts[titleParts.length - 1]);
if (finalGivenNames.size === 0) finalGivenNames.add(titleParts[0]);
}
}
const isOrphan = isOrphanTagged && backLinkCount < 1;
// Helper to probe for the best available list title by priority
const getPriorityTarget = async (name, type) => {
let titlesToTry = [];
if (type === 'surname') {
titlesToTry = [`List of people with surname ${name}`, `List of people with the Korean family name ${name}`, `List of people with the Chinese family name ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`,
name
];
} else {
titlesToTry = [`List of people with given name ${name}`, `List of people named ${name}`, `${name} (given name)`,
name
];
}
const listRes = await api.get({
action: 'query',
titles: titlesToTry.join('|'),
formatversion: 2
});
const pages = listRes.query.pages;
return titlesToTry.find(t => {
const p = pages.find(pg => pg.title === t);
return p && !p.missing;
});
};
// Check given names
for (const name of finalGivenNames) {
const target = await getPriorityTarget(name, 'given');
if (target) {
await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
}
// Check family names
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);
}
});
}
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}`).click(() => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
})();
kn6befqirkltxqsjwzw235cjiqnuxpr
737810
737809
2026-04-13T09:35:48Z
Sam Sailor
26820
Test
737810
javascript
text/javascript
(function() {
var Comrade = Comrade || {};
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').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-" + btoa(unescape(encodeURIComponent(redirTitle))).replace(/=/g, "");
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}]]`;
// Pass the customWikitext through to the creation function
const $btn = $('<button>').text('Create redirect').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);
}
}
function checkChemicalRedirect(wikitext, currentTitle) {
if (!/\{\{\s*Chembox Properties/i.test(wikitext)) {
console.log("Comrade: No 'Chembox Properties' template found.");
return;
}
let formula = "";
const formulaMatch = wikitext.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
formula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
console.log("Comrade: Found explicit Formula parameter:", formula);
} else {
console.log("Comrade: No explicit formula. Searching for individual elements...");
const chemboxSection = wikitext.match(/\{\{\s*Chembox Properties[\s\S]*?\}\}/i);
if (chemboxSection) {
const content = chemboxSection[0];
console.log("Comrade: Chembox content extracted:", content);
// Comprehensive list extracted from [[Template:Chembox Elements/molecular formula]]
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+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch) {
console.log(`Comrade: Match found for ${el}:`, elMatch[0]);
if (elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
}
});
console.log("Comrade: Final element data object:", data);
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
// If carbon exists, C then H
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
// Alphabetical for everything else
const remaining = elementsPresent.filter(el => {
if (hasC && (el === "C" || el === "H")) return false;
return true;
}).sort();
// Special case: If NO carbon, H must be sorted alphabetically
remaining.forEach(el => {
formulaArray.push(el + data[el]);
});
formula = formulaArray.join('');
console.log("Comrade: Generated formula from elements:", formula);
}
} else {
console.log("Comrade: Failed to extract Chembox Properties section content.");
}
}
if (formula && formula !== currentTitle) {
console.log(`Comrade: Success! Triggering redirect for ${formula}`);
checkAndRenderRedirect(formula, currentTitle, "R from chemical formula", "Chemical formula");
} else {
console.log("Comrade: Formula matches current title or is empty. No redirect needed.");
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
// Try to get the URL from Wikidata (P856)
if (qid) {
try {
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
}
});
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
console.log("Comrade: Found domain candidate in Wikidata:", domainCandidate);
}
} catch (e) {
console.log("Comrade: Wikidata API call failed.");
}
}
// Fallback to Infobox URL if Wikidata is empty
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
if (domainCandidate) console.log("Comrade: Found domain candidate in Infobox:", domainCandidate);
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
// Only proceed if it's a root domain (pathname is "/")
if (domain.includes(".") && url.pathname === "/") {
console.log(`Comrade: Verifying if ${domainCandidate} is live via Citation API...`);
// The live check, CORS-friendly proxy
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
$.ajax({
url: citationURL,
method: 'GET',
success: function() {
console.log(`Comrade: URL is live. Proceeding with redirect for: ${domain}`);
checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
},
error: function(xhr) {
console.log(`Comrade: URL failed live check (Status: ${xhr.status}). Skipping redirect.`);
}
});
} else {
console.log(`Comrade: ${domain} rejected (not a root domain or missing TLD).`);
}
} catch (e) {
console.log("Comrade: Error parsing URL object.");
}
} else {
console.log("Comrade: No domain candidate found for this article.");
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan) {
console.log(`[Comrade] Checking ${type} list for: ${name}`);
const api = new mw.Api();
// Added lowercase version of the type to the candidates
const candidates = [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
// Set to keep track of titles we've already checked to avoid redundant API calls
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
console.log(`[Comrade] Querying: ${title}`);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
// redirects: 1 // Follow redirects to find the actual list page
redirects: 0 // Do not follow redirects
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const resolvedTitle = page.title;
// Check for specific name templates
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
// Check for disambiguation pages with name parameters
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
console.log(`[Comrade] Results for ${resolvedTitle} - Match: ${isNameMatch}, Dab: ${isNameDab}`);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) {
console.log(`[Comrade] ${currentTitle} is already listed on ${resolvedTitle}`);
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:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; should it be?"));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not currently listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; 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').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();
// Use custom wikitext if provided (for Long Name), otherwise use standard template
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
return api.postWithToken('csrf', {
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
}).done(() => {
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
});
}
function getSmartSortKey(fullName, entity) {
const countryIds = entity.claims?.P27?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
// Include P17 (country) for historical figures
const locationIds = entity.claims?.P17?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
// Broaden search to birth/burial places for historical figures
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]) || 2026;
// Eliminate saints
if (fullName.startsWith("Saint ")) {
return fullName.replace("Saint ", "");
}
// Arabic names (modern vs historical)
// Particles: Abu, Abd, Abdel, Abdul, ben, bin, bint
const arabicParticles = /\b(Abu|Abd|Abdel|Abdul|ben|bin|bint)\b/i;
if (fullName.match(arabicParticles)) {
if (birthYear > 1900) {
// Sort as Western: "Osama bin Laden" -> "Bin Laden, Osama"
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; // Historical: sort as written
}
}
// East Asian (family name first)
// Q148 (China), Q184 (Korea), Q1884 (S. Korea), Q424 (N. Korea), Q881 (Vietnam), Q17 (Japan)
const eastAsianQids = ["Q148", "Q184", "Q884", "Q424", "Q881", "Q17"];
const isEastAsian = allRelevantIds.some(id => eastAsianQids.includes(id));
if (isEastAsian) {
// Japanese birth year check
if (allRelevantIds.includes("Q17") && birthYear > 1885) {
// Modern Japanese: Western order
return westernSort(fullName);
}
// Others: Mao Zedong -> Mao, Zedong
const parts = fullName.split(' ');
if (parts.length > 1) {
return `${parts[0]}, ${parts.slice(1).join(' ')}`;
}
}
// Default Western sort
return westernSort(fullName);
}
function westernSort(name) {
// Handle O'Neill -> ONeill
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();
// Handle Jr, III, etc.
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();
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);
}
function replaceMI(text) {
const miStart = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miStart.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;
miStart.lastIndex = lastIndex;
}
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.postWithToken('csrf', {
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName} (Comrade)`,
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'
});
console.error("Comrade save error:", err);
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgUserId") !== 19244234 || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") {
return;
} {
$(document).ready(async function() {
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').click(() => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
checkChemicalRedirect(wikitext, currentTitle);
checkDomainRedirect(qid, currentTitle);
if (qid) {
const wikiData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
const entity = wikiData.entities[qid];
const isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
let targetSortName = "";
if (dsMatch && isHuman) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
const givenNameIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
let wdGivens = [];
if (givenNameIds.length > 0) {
const nameData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: givenNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
wdGivens = givenNameIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean);
}
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
const referenceName = entity.labels?.en?.value || currentTitle;
const fixCasing = (part) => {
return part.split(' ').map(word => {
const regex = new RegExp(`\\b${word}\\b`, 'i');
const match = referenceName.match(regex);
if (match) return match[0];
const partsOfTitle = referenceName.split(' ');
const closest = partsOfTitle.find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase()));
return closest || word;
}).join(' ');
};
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixCasing(lastPart)}, ${fixCasing(firstPart)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field (with case fix).");
} else if (wdGivens.length > 0 && lastPart.split(' ').length > 1) {
const lastParts = lastPart.split(' ');
const misplacedGiven = lastParts.find(part => wdGivens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastParts.filter(p => p !== misplacedGiven).join(' ');
const newGiven = `${misplacedGiven} ${firstPart}`.trim();
targetSortName = `${fixCasing(newSurname)}, ${fixCasing(newGiven)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
} else {
const cLast = fixCasing(lastPart);
const cFirst = fixCasing(firstPart);
if (cLast !== lastPart || cFirst !== firstPart) {
targetSortName = `${cLast}, ${cFirst}`;
renderSortNudge(targetSortName, "Case mismatch relative to article title/Wikidata.");
}
}
} else {
const parts = currentDS.split(' ');
if (parts.length >= 2) {
const last = parts.pop();
const first = parts.join(' ');
const ref = entity.labels?.en?.value || currentTitle;
const fix = (p) => p.split(' ').map(w => (ref.match(new RegExp(`\\b${w}\\b`, 'i')) || [w])[0]).join(' ');
targetSortName = `${fix(last)}, ${fix(first)}`;
renderSortNudge(targetSortName, 'Structure should be "Last, First".');
}
}
} else if (isHuman) {
// Logic for when DEFAULTSORT is missing entirely
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const nameParts = tClean.split(' ');
if (nameParts.length >= 2) {
let suggestedSort;
if (hasFamilyNameHatnote) {
// If hatnote exists, title is already "Surname Givenname"
suggestedSort = `${nameParts[0]}, ${nameParts.slice(1).join(' ')}`;
} else {
// Use your helper function for Western/other names
suggestedSort = getSmartSortKey(tClean, entity);
}
// Use 'targetSortName' so the variable is available for the redirect logic later
targetSortName = suggestedSort;
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).click(() => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
}
// --- Long name extraction (using wikitext) ---
const leadLine = wikitext.split('\n').find(line => line.includes("'''"));
const boldMatch = leadLine ? leadLine.match(/'''(.+?)'''/) : null;
if (boldMatch && isHuman) {
// Clean nicknames in quotes "" and parentheses () and normalize spaces
let longName = boldMatch[1].replace(/\s*("[^"]*"|\([^)\n]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
// Strip disambiguators from currentTitle for a fair length comparison
const baseTitle = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
if (longName.length > baseTitle.length) {
const nameParts = longName.split(' ');
if (nameParts.length > 1) {
// Check for family name hatnote to detect name order (e.g., Chinese, Korean)
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
let sortKey;
if (hasFamilyNameHatnote) {
// If hatnote exists, title is already "Surname Givenname", so sort as "Surname, Givenname"
sortKey = `${nameParts[0]}, ${nameParts.slice(1).join(' ')}`;
} else {
// Fallback to Wikidata-based smart logic for Western/Arabic/Other patterns
sortKey = getSmartSortKey(longName, entity);
}
// Format the special redirect wikitext for the long name
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
// Check if redirect exists and render if missing
checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
}
}
// --- Sort name ---
if (isHuman && targetSortName) {
let targetPage = currentTitle;
let rTemplate = "R from sort name";
let labelSuffix = "";
// If the article has a parenthetical disambiguator, find basename
if (currentTitle.includes(' (')) {
targetPage = currentTitle.split(' (')[0];
rTemplate = "R from ambiguous sort name";
labelSuffix = " (ambiguous)";
}
let rCat = rTemplate;
if (targetSortName.includes(',')) {
const parts = targetSortName.split(',');
const lI = parts[0].trim().charAt(0).toUpperCase();
const fI = parts[1].trim().charAt(0).toUpperCase();
if (lI && fI) rCat = `${rTemplate}|${lI}|${fI}`;
}
// Only run checkAndRender once per person
checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name" + labelSuffix);
}
// --- Name list checking via Wikidata P734/P735 ---
if (isHuman) {
const familyNameClaims = entity.claims?.P734 || [];
const givenNameClaims = entity.claims?.P735 || [];
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const getNameLabels = async (claims) => {
const ids = claims.map(c => c.mainsnak.datavalue.value.id);
if (ids.length === 0) return [];
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: ids.join('|'),
props: 'labels',
languages: 'en',
languagefallback: true,
format: 'json',
origin: '*'
}
});
return Object.values(res.entities).map(e => e.labels?.en?.value).filter(Boolean);
};
const [allFamilyNames, allGivenNames] = await Promise.all([
getNameLabels(familyNameClaims),
getNameLabels(givenNameClaims)
]);
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
allFamilyNames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
allGivenNames.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) {
finalGivenNames.add(gn);
}
});
if (finalFamilyNames.size === 0 || finalGivenNames.size === 0) {
const titleParts = tClean.split(' ');
if (titleParts.length >= 2) {
if (finalFamilyNames.size === 0) finalFamilyNames.add(titleParts[titleParts.length - 1]);
if (finalGivenNames.size === 0) finalGivenNames.add(titleParts[0]);
}
}
const isOrphan = isOrphanTagged && backLinkCount < 1;
// Helper to probe for the best available list title by priority
const getPriorityTarget = async (name, type) => {
let titlesToTry = [];
if (type === 'surname') {
titlesToTry = [`List of people with surname ${name}`, `List of people with the Korean family name ${name}`, `List of people with the Chinese family name ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`,
name
];
} else {
titlesToTry = [`List of people with given name ${name}`, `List of people named ${name}`, `${name} (given name)`,
name
];
}
const listRes = await api.get({
action: 'query',
titles: titlesToTry.join('|'),
formatversion: 2
});
const pages = listRes.query.pages;
return titlesToTry.find(t => {
const p = pages.find(pg => pg.title === t);
return p && !p.missing;
});
};
// Check given names
for (const name of finalGivenNames) {
const target = await getPriorityTarget(name, 'given');
if (target) {
await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
}
// Check family names
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);
}
});
}
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}`).click(() => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
})();
80xhi11my9disf9kwb2oc1w5ktz9g5j
737811
737810
2026-04-13T09:40:54Z
Sam Sailor
26820
Test
737811
javascript
text/javascript
(function() {
var Comrade = Comrade || {};
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').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-" + btoa(unescape(encodeURIComponent(redirTitle))).replace(/=/g, "");
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}]]`;
// Pass the customWikitext through to the creation function
const $btn = $('<button>').text('Create redirect').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);
}
}
function checkChemicalRedirect(wikitext, currentTitle) {
if (!/\{\{\s*Chembox Properties/i.test(wikitext)) {
console.log("Comrade: No 'Chembox Properties' template found.");
return;
}
let formula = "";
const formulaMatch = wikitext.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
formula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
console.log("Comrade: Found explicit Formula parameter:", formula);
} else {
console.log("Comrade: No explicit formula. Searching for individual elements...");
const chemboxSection = wikitext.match(/\{\{\s*Chembox Properties[\s\S]*?\}\}/i);
if (chemboxSection) {
const content = chemboxSection[0];
console.log("Comrade: Chembox content extracted:", content);
// Comprehensive list extracted from [[Template:Chembox Elements/molecular formula]]
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+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch) {
console.log(`Comrade: Match found for ${el}:`, elMatch[0]);
if (elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
}
});
console.log("Comrade: Final element data object:", data);
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
// If carbon exists, C then H
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
// Alphabetical for everything else
const remaining = elementsPresent.filter(el => {
if (hasC && (el === "C" || el === "H")) return false;
return true;
}).sort();
// Special case: If NO carbon, H must be sorted alphabetically
remaining.forEach(el => {
formulaArray.push(el + data[el]);
});
formula = formulaArray.join('');
console.log("Comrade: Generated formula from elements:", formula);
}
} else {
console.log("Comrade: Failed to extract Chembox Properties section content.");
}
}
if (formula && formula !== currentTitle) {
console.log(`Comrade: Success! Triggering redirect for ${formula}`);
checkAndRenderRedirect(formula, currentTitle, "R from chemical formula", "Chemical formula");
} else {
console.log("Comrade: Formula matches current title or is empty. No redirect needed.");
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
// Try to get the URL from Wikidata (P856)
if (qid) {
try {
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
}
});
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
console.log("Comrade: Found domain candidate in Wikidata:", domainCandidate);
}
} catch (e) {
console.log("Comrade: Wikidata API call failed.");
}
}
// Fallback to Infobox URL if Wikidata is empty
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
if (domainCandidate) console.log("Comrade: Found domain candidate in Infobox:", domainCandidate);
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
// Only proceed if it's a root domain (pathname is "/")
if (domain.includes(".") && url.pathname === "/") {
console.log(`Comrade: Verifying if ${domainCandidate} is live via Citation API...`);
// The live check, CORS-friendly proxy
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
$.ajax({
url: citationURL,
method: 'GET',
success: function() {
console.log(`Comrade: URL is live. Proceeding with redirect for: ${domain}`);
checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
},
error: function(xhr) {
console.log(`Comrade: URL failed live check (Status: ${xhr.status}). Skipping redirect.`);
}
});
} else {
console.log(`Comrade: ${domain} rejected (not a root domain or missing TLD).`);
}
} catch (e) {
console.log("Comrade: Error parsing URL object.");
}
} else {
console.log("Comrade: No domain candidate found for this article.");
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan) {
console.log(`[Comrade] Checking ${type} list for: ${name}`);
const api = new mw.Api();
// Added lowercase version of the type to the candidates
const candidates = [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
// Set to keep track of titles we've already checked to avoid redundant API calls
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
console.log(`[Comrade] Querying: ${title}`);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
// redirects: 1 // Follow redirects to find the actual list page
redirects: 0 // Do not follow redirects
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const resolvedTitle = page.title;
// Check for specific name templates
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
// Check for disambiguation pages with name parameters
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
console.log(`[Comrade] Results for ${resolvedTitle} - Match: ${isNameMatch}, Dab: ${isNameDab}`);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) {
console.log(`[Comrade] ${currentTitle} is already listed on ${resolvedTitle}`);
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:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; should it be?"));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not currently listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; 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').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();
// Use custom wikitext if provided (for Long Name), otherwise use standard template
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
return api.postWithToken('csrf', {
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
}).done(() => {
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
});
}
function getSmartSortKey(fullName, entity) {
const countryIds = entity.claims?.P27?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
// Include P17 (country) for historical figures
const locationIds = entity.claims?.P17?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
// Broaden search to birth/burial places for historical figures
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]) || 2026;
// Eliminate saints
if (fullName.startsWith("Saint ")) {
return fullName.replace("Saint ", "");
}
// Arabic names (modern vs historical)
// Particles: Abu, Abd, Abdel, Abdul, ben, bin, bint
const arabicParticles = /\b(Abu|Abd|Abdel|Abdul|ben|bin|bint)\b/i;
if (fullName.match(arabicParticles)) {
if (birthYear > 1900) {
// Sort as Western: "Osama bin Laden" -> "Bin Laden, Osama"
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; // Historical: sort as written
}
}
// East Asian (family name first)
// Q148 (China), Q184 (Korea), Q1884 (S. Korea), Q424 (N. Korea), Q881 (Vietnam), Q17 (Japan)
const eastAsianQids = ["Q148", "Q184", "Q884", "Q424", "Q881", "Q17"];
const isEastAsian = allRelevantIds.some(id => eastAsianQids.includes(id));
if (isEastAsian) {
// Japanese birth year check
if (allRelevantIds.includes("Q17") && birthYear > 1885) {
// Modern Japanese: Western order
return westernSort(fullName);
}
// Others: Mao Zedong -> Mao, Zedong
const parts = fullName.split(' ');
if (parts.length > 1) {
return `${parts[0]}, ${parts.slice(1).join(' ')}`;
}
}
// Default Western sort
return westernSort(fullName);
}
function westernSort(name) {
// Handle O'Neill -> ONeill
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();
// Handle Jr, III, etc.
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();
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);
}
function replaceMI(text) {
const miStart = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miStart.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;
miStart.lastIndex = lastIndex;
}
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.postWithToken('csrf', {
action: 'edit',
title: title,
text: content,
summary: `Setting DEFAULTSORT to ${newName} (Comrade)`,
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'
});
console.error("Comrade save error:", err);
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgUserId") !== 19244234 || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") {
return;
} {
$(document).ready(async function() {
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').click(() => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
checkChemicalRedirect(wikitext, currentTitle);
checkDomainRedirect(qid, currentTitle);
if (qid) {
const wikiData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
const entity = wikiData.entities[qid];
const isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
let targetSortName = "";
if (dsMatch && isHuman) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
const givenNameIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
let wdGivens = [];
if (givenNameIds.length > 0) {
const nameData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: givenNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
wdGivens = givenNameIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean);
}
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
const referenceName = entity.labels?.en?.value || currentTitle;
const fixCasing = (part) => {
return part.split(' ').map(word => {
const regex = new RegExp(`\\b${word}\\b`, 'i');
const match = referenceName.match(regex);
if (match) return match[0];
const partsOfTitle = referenceName.split(' ');
const closest = partsOfTitle.find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase()));
return closest || word;
}).join(' ');
};
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixCasing(lastPart)}, ${fixCasing(firstPart)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field (with case fix).");
} else if (wdGivens.length > 0 && lastPart.split(' ').length > 1) {
const lastParts = lastPart.split(' ');
const misplacedGiven = lastParts.find(part => wdGivens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastParts.filter(p => p !== misplacedGiven).join(' ');
const newGiven = `${misplacedGiven} ${firstPart}`.trim();
targetSortName = `${fixCasing(newSurname)}, ${fixCasing(newGiven)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
} else {
const cLast = fixCasing(lastPart);
const cFirst = fixCasing(firstPart);
if (cLast !== lastPart || cFirst !== firstPart) {
targetSortName = `${cLast}, ${cFirst}`;
renderSortNudge(targetSortName, "Case mismatch relative to article title/Wikidata.");
}
}
} else {
const parts = currentDS.split(' ');
if (parts.length >= 2) {
const last = parts.pop();
const first = parts.join(' ');
const ref = entity.labels?.en?.value || currentTitle;
const fix = (p) => p.split(' ').map(w => (ref.match(new RegExp(`\\b${w}\\b`, 'i')) || [w])[0]).join(' ');
targetSortName = `${fix(last)}, ${fix(first)}`;
renderSortNudge(targetSortName, 'Structure should be "Last, First".');
}
}
} else if (isHuman) {
// Logic for when DEFAULTSORT is missing entirely
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const nameParts = tClean.split(' ');
if (nameParts.length >= 2) {
let suggestedSort;
if (hasFamilyNameHatnote) {
// If hatnote exists, title is already "Surname Givenname"
suggestedSort = `${nameParts[0]}, ${nameParts.slice(1).join(' ')}`;
} else {
// Use your helper function for Western/other names
suggestedSort = getSmartSortKey(tClean, entity);
}
// Use 'targetSortName' so the variable is available for the redirect logic later
targetSortName = suggestedSort;
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).click(() => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
}
// --- Long name extraction (using wikitext) ---
const leadLine = wikitext.split('\n').find(line => line.includes("'''"));
const boldMatch = leadLine ? leadLine.match(/'''(.+?)'''/) : null;
if (boldMatch && isHuman) {
// Clean nicknames in quotes "" and parentheses () and normalize spaces
let longName = boldMatch[1].replace(/\s*("[^"]*"|\([^)\n]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
// Strip disambiguators from currentTitle for a fair length comparison
const baseTitle = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
if (longName.length > baseTitle.length) {
const nameParts = longName.split(' ');
if (nameParts.length > 1) {
// Check for family name hatnote to detect name order (e.g., Chinese, Korean)
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
let sortKey;
if (hasFamilyNameHatnote) {
// If hatnote exists, title is already "Surname Givenname", so sort as "Surname, Givenname"
sortKey = `${nameParts[0]}, ${nameParts.slice(1).join(' ')}`;
} else {
// Fallback to Wikidata-based smart logic for Western/Arabic/Other patterns
sortKey = getSmartSortKey(longName, entity);
}
// Format the special redirect wikitext for the long name
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
// Check if redirect exists and render if missing
checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
}
}
// --- Sort name ---
if (isHuman && targetSortName) {
let targetPage = currentTitle;
let rTemplate = "R from sort name";
let labelSuffix = "";
// If the article has a parenthetical disambiguator, find basename
if (currentTitle.includes(' (')) {
targetPage = currentTitle.split(' (')[0];
rTemplate = "R from ambiguous sort name";
labelSuffix = " (ambiguous)";
}
let rCat = rTemplate;
if (targetSortName.includes(',')) {
const parts = targetSortName.split(',');
const lI = parts[0].trim().charAt(0).toUpperCase();
const fI = parts[1].trim().charAt(0).toUpperCase();
if (lI && fI) rCat = `${rTemplate}|${lI}|${fI}`;
}
// Only run checkAndRender once per person
checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name" + labelSuffix);
}
// --- Name list checking via Wikidata P734/P735 ---
if (isHuman) {
const familyNameClaims = entity.claims?.P734 || [];
const givenNameClaims = entity.claims?.P735 || [];
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const getNameLabels = async (claims) => {
const ids = claims.map(c => c.mainsnak.datavalue.value.id);
if (ids.length === 0) return [];
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: ids.join('|'),
props: 'labels',
languages: 'en',
languagefallback: true,
format: 'json',
origin: '*'
}
});
return Object.values(res.entities).map(e => e.labels?.en?.value).filter(Boolean);
};
const [allFamilyNames, allGivenNames] = await Promise.all([
getNameLabels(familyNameClaims),
getNameLabels(givenNameClaims)
]);
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
allFamilyNames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
allGivenNames.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) {
finalGivenNames.add(gn);
}
});
if (finalFamilyNames.size === 0 || finalGivenNames.size === 0) {
const titleParts = tClean.split(' ');
if (titleParts.length >= 2) {
if (finalFamilyNames.size === 0) finalFamilyNames.add(titleParts[titleParts.length - 1]);
if (finalGivenNames.size === 0) finalGivenNames.add(titleParts[0]);
}
}
const isOrphan = isOrphanTagged && backLinkCount < 1;
// Helper to probe for the best available list title by priority
const getPriorityTarget = async (name, type) => {
let titlesToTry = [];
if (type === 'surname') {
titlesToTry = [`List of people with surname ${name}`, `List of people with the Korean family name ${name}`, `List of people with the Chinese family name ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`];
} else {
titlesToTry = [`List of people with given name ${name}`, `List of people named ${name}`, `${name} (given name)`];
}
// Only add the bare name as a fallback if it's not a common pronoun/short word
const stopList = ['he', 'she', 'it', 'they', 'an', 'a', 'the'];
if (name.length > 2 && !stopList.includes(name.toLowerCase())) {
titlesToTry.push(name);
}
const listRes = await api.get({
action: 'query',
titles: titlesToTry.join('|'),
formatversion: 2
});
const pages = listRes.query.pages;
// Return the first one that exists, following the priority in titlesToTry
return titlesToTry.find(t => {
const p = pages.find(pg => pg.title === t);
return p && !p.missing;
});
};
// Check given names
for (const name of finalGivenNames) {
const target = await getPriorityTarget(name, 'given');
if (target) {
await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
}
// Check family names
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);
}
});
}
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}`).click(() => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
})();
lxnn3aonj1l8wvls890akzhapqlu0gq
737812
737811
2026-04-13T09:47:52Z
Sam Sailor
26820
Test
737812
javascript
text/javascript
(function() {
var Comrade = Comrade || {};
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').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-" + btoa(unescape(encodeURIComponent(redirTitle))).replace(/=/g, "");
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}]]`;
// Pass the customWikitext through to the creation function
const $btn = $('<button>').text('Create redirect').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);
}
}
function checkChemicalRedirect(wikitext, currentTitle) {
if (!/\{\{\s*Chembox Properties/i.test(wikitext)) {
console.log("Comrade: No 'Chembox Properties' template found.");
return;
}
let formula = "";
const formulaMatch = wikitext.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
formula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
console.log("Comrade: Found explicit Formula parameter:", formula);
} else {
console.log("Comrade: No explicit formula. Searching for individual elements...");
const chemboxSection = wikitext.match(/\{\{\s*Chembox Properties[\s\S]*?\}\}/i);
if (chemboxSection) {
const content = chemboxSection[0];
console.log("Comrade: Chembox content extracted:", content);
// Comprehensive list extracted from [[Template:Chembox Elements/molecular formula]]
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+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch) {
console.log(`Comrade: Match found for ${el}:`, elMatch[0]);
if (elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
}
});
console.log("Comrade: Final element data object:", data);
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
// If carbon exists, C then H
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
// Alphabetical for everything else
const remaining = elementsPresent.filter(el => {
if (hasC && (el === "C" || el === "H")) return false;
return true;
}).sort();
// Special case: If NO carbon, H must be sorted alphabetically
remaining.forEach(el => {
formulaArray.push(el + data[el]);
});
formula = formulaArray.join('');
console.log("Comrade: Generated formula from elements:", formula);
}
} else {
console.log("Comrade: Failed to extract Chembox Properties section content.");
}
}
if (formula && formula !== currentTitle) {
console.log(`Comrade: Success! Triggering redirect for ${formula}`);
checkAndRenderRedirect(formula, currentTitle, "R from chemical formula", "Chemical formula");
} else {
console.log("Comrade: Formula matches current title or is empty. No redirect needed.");
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
// Try to get the URL from Wikidata (P856)
if (qid) {
try {
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
}
});
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
console.log("Comrade: Found domain candidate in Wikidata:", domainCandidate);
}
} catch (e) {
console.log("Comrade: Wikidata API call failed.");
}
}
// Fallback to Infobox URL if Wikidata is empty
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
if (domainCandidate) console.log("Comrade: Found domain candidate in Infobox:", domainCandidate);
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
// Only proceed if it's a root domain (pathname is "/")
if (domain.includes(".") && url.pathname === "/") {
console.log(`Comrade: Verifying if ${domainCandidate} is live via Citation API...`);
// The live check, CORS-friendly proxy
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
$.ajax({
url: citationURL,
method: 'GET',
success: function() {
console.log(`Comrade: URL is live. Proceeding with redirect for: ${domain}`);
checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
},
error: function(xhr) {
console.log(`Comrade: URL failed live check (Status: ${xhr.status}). Skipping redirect.`);
}
});
} else {
console.log(`Comrade: ${domain} rejected (not a root domain or missing TLD).`);
}
} catch (e) {
console.log("Comrade: Error parsing URL object.");
}
} else {
console.log("Comrade: No domain candidate found for this article.");
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan) {
console.log(`[Comrade] Checking ${type} list for: ${name}`);
const api = new mw.Api();
// Added lowercase version of the type to the candidates
const candidates = [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
// Set to keep track of titles we've already checked to avoid redundant API calls
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
console.log(`[Comrade] Querying: ${title}`);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
// redirects: 1 // Follow redirects to find the actual list page
redirects: 0 // Do not follow redirects
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const resolvedTitle = page.title;
// Check for specific name templates
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
// Check for disambiguation pages with name parameters
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
console.log(`[Comrade] Results for ${resolvedTitle} - Match: ${isNameMatch}, Dab: ${isNameDab}`);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) {
console.log(`[Comrade] ${currentTitle} is already listed on ${resolvedTitle}`);
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:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; should it be?"));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not currently listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; 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').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();
// Use custom wikitext if provided (for Long Name), otherwise use standard template
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
return api.postWithToken('csrf', {
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
}).done(() => {
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
});
}
function getSmartSortKey(fullName, entity) {
const countryIds = entity.claims?.P27?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
// Include P17 (country) for historical figures
const locationIds = entity.claims?.P17?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
// Broaden search to birth/burial places for historical figures
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]) || 2026;
// Eliminate saints
if (fullName.startsWith("Saint ")) {
return fullName.replace("Saint ", "");
}
// Arabic names (modern vs historical)
// Particles: Abu, Abd, Abdel, Abdul, ben, bin, bint
const arabicParticles = /\b(Abu|Abd|Abdel|Abdul|ben|bin|bint)\b/i;
if (fullName.match(arabicParticles)) {
if (birthYear > 1900) {
// Sort as Western: "Osama bin Laden" -> "Bin Laden, Osama"
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; // Historical: sort as written
}
}
// East Asian (family name first)
// Q148 (China), Q184 (Korea), Q1884 (S. Korea), Q424 (N. Korea), Q881 (Vietnam), Q17 (Japan)
const eastAsianQids = ["Q148", "Q184", "Q884", "Q424", "Q881", "Q17"];
const isEastAsian = allRelevantIds.some(id => eastAsianQids.includes(id));
if (isEastAsian) {
// Japanese birth year check
if (allRelevantIds.includes("Q17") && birthYear > 1885) {
// Modern Japanese: Western order
return westernSort(fullName);
}
// Others: Mao Zedong -> Mao, Zedong
const parts = fullName.split(' ');
if (parts.length > 1) {
return `${parts[0]}, ${parts.slice(1).join(' ')}`;
}
}
// Default Western sort
return westernSort(fullName);
}
function westernSort(name) {
// Handle O'Neill -> ONeill
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();
// Handle Jr, III, etc.
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();
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);
}
function replaceMI(text) {
const miStart = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miStart.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;
miStart.lastIndex = lastIndex;
}
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.postWithToken('csrf', {
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'
});
console.error("Comrade save error:", err);
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgUserId") !== 19244234 || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") {
return;
} {
$(document).ready(async function() {
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').click(() => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
checkChemicalRedirect(wikitext, currentTitle);
checkDomainRedirect(qid, currentTitle);
if (qid) {
const wikiData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
const entity = wikiData.entities[qid];
const isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
let targetSortName = "";
if (dsMatch && isHuman) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
const givenNameIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
let wdGivens = [];
if (givenNameIds.length > 0) {
const nameData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: givenNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
wdGivens = givenNameIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean);
}
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
const referenceName = entity.labels?.en?.value || currentTitle;
const fixCasing = (part) => {
return part.split(' ').map(word => {
const regex = new RegExp(`\\b${word}\\b`, 'i');
const match = referenceName.match(regex);
if (match) return match[0];
const partsOfTitle = referenceName.split(' ');
const closest = partsOfTitle.find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase()));
return closest || word;
}).join(' ');
};
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixCasing(lastPart)}, ${fixCasing(firstPart)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field (with case fix).");
} else if (wdGivens.length > 0 && lastPart.split(' ').length > 1) {
const lastParts = lastPart.split(' ');
const misplacedGiven = lastParts.find(part => wdGivens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastParts.filter(p => p !== misplacedGiven).join(' ');
const newGiven = `${misplacedGiven} ${firstPart}`.trim();
targetSortName = `${fixCasing(newSurname)}, ${fixCasing(newGiven)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
} else {
const cLast = fixCasing(lastPart);
const cFirst = fixCasing(firstPart);
if (cLast !== lastPart || cFirst !== firstPart) {
targetSortName = `${cLast}, ${cFirst}`;
renderSortNudge(targetSortName, "Case mismatch relative to article title/Wikidata.");
}
}
} else {
const parts = currentDS.split(' ');
if (parts.length >= 2) {
const last = parts.pop();
const first = parts.join(' ');
const ref = entity.labels?.en?.value || currentTitle;
const fix = (p) => p.split(' ').map(w => (ref.match(new RegExp(`\\b${w}\\b`, 'i')) || [w])[0]).join(' ');
targetSortName = `${fix(last)}, ${fix(first)}`;
renderSortNudge(targetSortName, 'Structure should be "Last, First".');
}
}
} else if (isHuman) {
// Logic for when DEFAULTSORT is missing entirely
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const nameParts = tClean.split(' ');
if (nameParts.length >= 2) {
let suggestedSort;
if (hasFamilyNameHatnote) {
// If hatnote exists, title is already "Surname Givenname"
suggestedSort = `${nameParts[0]}, ${nameParts.slice(1).join(' ')}`;
} else {
// Use your helper function for Western/other names
suggestedSort = getSmartSortKey(tClean, entity);
}
// Use 'targetSortName' so the variable is available for the redirect logic later
targetSortName = suggestedSort;
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).click(() => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
}
// --- Long name extraction (using wikitext) ---
const leadLine = wikitext.split('\n').find(line => line.includes("'''"));
const boldMatch = leadLine ? leadLine.match(/'''(.+?)'''/) : null;
if (boldMatch && isHuman) {
// Clean nicknames in quotes "" and parentheses () and normalize spaces
let longName = boldMatch[1].replace(/\s*("[^"]*"|\([^)\n]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
// Strip disambiguators from currentTitle for a fair length comparison
const baseTitle = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
if (longName.length > baseTitle.length) {
const nameParts = longName.split(' ');
if (nameParts.length > 1) {
// Check for family name hatnote to detect name order (e.g., Chinese, Korean)
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
let sortKey;
if (hasFamilyNameHatnote) {
// If hatnote exists, title is already "Surname Givenname", so sort as "Surname, Givenname"
sortKey = `${nameParts[0]}, ${nameParts.slice(1).join(' ')}`;
} else {
// Fallback to Wikidata-based smart logic for Western/Arabic/Other patterns
sortKey = getSmartSortKey(longName, entity);
}
// Format the special redirect wikitext for the long name
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
// Check if redirect exists and render if missing
checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
}
}
// --- Sort name ---
if (isHuman && targetSortName) {
let targetPage = currentTitle;
let rTemplate = "R from sort name";
let labelSuffix = "";
// If the article has a parenthetical disambiguator, find basename
if (currentTitle.includes(' (')) {
targetPage = currentTitle.split(' (')[0];
rTemplate = "R from ambiguous sort name";
labelSuffix = " (ambiguous)";
}
let rCat = rTemplate;
if (targetSortName.includes(',')) {
const parts = targetSortName.split(',');
const lI = parts[0].trim().charAt(0).toUpperCase();
const fI = parts[1].trim().charAt(0).toUpperCase();
if (lI && fI) rCat = `${rTemplate}|${lI}|${fI}`;
}
// Only run checkAndRender once per person
checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name" + labelSuffix);
}
// --- Name list checking via Wikidata P734/P735 ---
if (isHuman) {
const familyNameClaims = entity.claims?.P734 || [];
const givenNameClaims = entity.claims?.P735 || [];
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const getNameLabels = async (claims) => {
const ids = claims.map(c => c.mainsnak.datavalue.value.id);
if (ids.length === 0) return [];
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: ids.join('|'),
props: 'labels',
languages: 'en',
languagefallback: true,
format: 'json',
origin: '*'
},
dataType: 'json'
});
return Object.values(res.entities).map(e => e.labels?.en?.value).filter(Boolean);
};
const [allFamilyNames, allGivenNames] = await Promise.all([
getNameLabels(familyNameClaims),
getNameLabels(givenNameClaims)
]);
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
allFamilyNames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
allGivenNames.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) {
finalGivenNames.add(gn);
}
});
if (finalFamilyNames.size === 0 || finalGivenNames.size === 0) {
const titleParts = tClean.split(' ');
if (titleParts.length >= 2) {
if (finalFamilyNames.size === 0) finalFamilyNames.add(titleParts[titleParts.length - 1]);
if (finalGivenNames.size === 0) finalGivenNames.add(titleParts[0]);
}
}
const isOrphan = isOrphanTagged && backLinkCount < 1;
// Helper to probe for the best available list title by priority
const getPriorityTarget = async (name, type) => {
let titlesToTry = [];
if (type === 'surname') {
titlesToTry = [`List of people with surname ${name}`, `List of people with the Korean family name ${name}`, `List of people with the Chinese family name ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`];
} else {
titlesToTry = [`List of people with given name ${name}`, `List of people named ${name}`, `${name} (given name)`];
}
// Only add the bare name as a fallback if it's not a common pronoun/short word
const stopList = ['he', 'she', 'it', 'they', 'an', 'a', 'the'];
if (name.length > 2 && !stopList.includes(name.toLowerCase())) {
titlesToTry.push(name);
}
const listRes = await api.get({
action: 'query',
titles: titlesToTry.join('|'),
formatversion: 2
});
const pages = listRes.query.pages;
// Return the first one that exists, following the priority in titlesToTry
return titlesToTry.find(t => {
const p = pages.find(pg => pg.title === t);
return p && !p.missing;
});
};
// Check given names
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
for (const name of finalGivenNames) {
// Skip if this name is part of the primary surname to avoid false positives (e.g. Kim)
const titleParts = tClean.split(' ');
if (hasFamilyNameHatnote && name.toLowerCase() === titleParts[0].toLowerCase()) continue;
if (!hasFamilyNameHatnote && name.toLowerCase() === titleParts[titleParts.length - 1].toLowerCase()) continue;
const target = await getPriorityTarget(name, 'given');
if (target) {
await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
}
// Check family names
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);
}
});
}
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}`).click(() => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
})();
t5fo4ftnyvqb2rc4zg2d9laf5d34vz8
737813
737812
2026-04-13T10:04:39Z
Sam Sailor
26820
Test
737813
javascript
text/javascript
(function() {
var Comrade = Comrade || {};
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').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-" + btoa(unescape(encodeURIComponent(redirTitle))).replace(/=/g, "");
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}]]`;
// Pass the customWikitext through to the creation function
const $btn = $('<button>').text('Create redirect').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);
}
}
function checkChemicalRedirect(wikitext, currentTitle) {
if (!/\{\{\s*Chembox Properties/i.test(wikitext)) {
console.log("Comrade: No 'Chembox Properties' template found.");
return;
}
let formula = "";
const formulaMatch = wikitext.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
formula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
console.log("Comrade: Found explicit Formula parameter:", formula);
} else {
console.log("Comrade: No explicit formula. Searching for individual elements...");
const chemboxSection = wikitext.match(/\{\{\s*Chembox Properties[\s\S]*?\}\}/i);
if (chemboxSection) {
const content = chemboxSection[0];
console.log("Comrade: Chembox content extracted:", content);
// Comprehensive list extracted from [[Template:Chembox Elements/molecular formula]]
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+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch) {
console.log(`Comrade: Match found for ${el}:`, elMatch[0]);
if (elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
}
});
console.log("Comrade: Final element data object:", data);
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
// If carbon exists, C then H
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
// Alphabetical for everything else
const remaining = elementsPresent.filter(el => {
if (hasC && (el === "C" || el === "H")) return false;
return true;
}).sort();
// Special case: If NO carbon, H must be sorted alphabetically
remaining.forEach(el => {
formulaArray.push(el + data[el]);
});
formula = formulaArray.join('');
console.log("Comrade: Generated formula from elements:", formula);
}
} else {
console.log("Comrade: Failed to extract Chembox Properties section content.");
}
}
if (formula && formula !== currentTitle) {
console.log(`Comrade: Success! Triggering redirect for ${formula}`);
checkAndRenderRedirect(formula, currentTitle, "R from chemical formula", "Chemical formula");
} else {
console.log("Comrade: Formula matches current title or is empty. No redirect needed.");
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
// Try to get the URL from Wikidata (P856)
if (qid) {
try {
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
}
});
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
console.log("Comrade: Found domain candidate in Wikidata:", domainCandidate);
}
} catch (e) {
console.log("Comrade: Wikidata API call failed.");
}
}
// Fallback to Infobox URL if Wikidata is empty
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
if (domainCandidate) console.log("Comrade: Found domain candidate in Infobox:", domainCandidate);
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
// Only proceed if it's a root domain (pathname is "/")
if (domain.includes(".") && url.pathname === "/") {
console.log(`Comrade: Verifying if ${domainCandidate} is live via Citation API...`);
// The live check, CORS-friendly proxy
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
$.ajax({
url: citationURL,
method: 'GET',
success: function() {
console.log(`Comrade: URL is live. Proceeding with redirect for: ${domain}`);
checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
},
error: function(xhr) {
console.log(`Comrade: URL failed live check (Status: ${xhr.status}). Skipping redirect.`);
}
});
} else {
console.log(`Comrade: ${domain} rejected (not a root domain or missing TLD).`);
}
} catch (e) {
console.log("Comrade: Error parsing URL object.");
}
} else {
console.log("Comrade: No domain candidate found for this article.");
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan) {
console.log(`[Comrade] Checking ${type} list for: ${name}`);
const api = new mw.Api();
// Added lowercase version of the type to the candidates
const candidates = [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
// Set to keep track of titles we've already checked to avoid redundant API calls
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
console.log(`[Comrade] Querying: ${title}`);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
// redirects: 1 // Follow redirects to find the actual list page
redirects: 0 // Do not follow redirects
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const resolvedTitle = page.title;
// Check for specific name templates
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
// Check for disambiguation pages with name parameters
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
console.log(`[Comrade] Results for ${resolvedTitle} - Match: ${isNameMatch}, Dab: ${isNameDab}`);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) {
console.log(`[Comrade] ${currentTitle} is already listed on ${resolvedTitle}`);
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:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; should it be?"));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not currently listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; 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').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();
// Use custom wikitext if provided (for Long Name), otherwise use standard template
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
return api.postWithToken('csrf', {
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
}).done(() => {
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
});
}
function getSmartSortKey(fullName, entity) {
const countryIds = entity.claims?.P27?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
// Include P17 (country) for historical figures
const locationIds = entity.claims?.P17?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
// Broaden search to birth/burial places for historical figures
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]) || 2026;
// Eliminate saints
if (fullName.startsWith("Saint ")) {
return fullName.replace("Saint ", "");
}
// Arabic names (modern vs historical)
// Particles: Abu, Abd, Abdel, Abdul, ben, bin, bint
const arabicParticles = /\b(Abu|Abd|Abdel|Abdul|ben|bin|bint)\b/i;
if (fullName.match(arabicParticles)) {
if (birthYear > 1900) {
// Sort as Western: "Osama bin Laden" -> "Bin Laden, Osama"
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; // Historical: sort as written
}
}
// East Asian (family name first)
// Q148 (China), Q184 (Korea), Q1884 (S. Korea), Q424 (N. Korea), Q881 (Vietnam), Q17 (Japan)
const eastAsianQids = ["Q148", "Q184", "Q884", "Q424", "Q881", "Q17"];
const isEastAsian = allRelevantIds.some(id => eastAsianQids.includes(id));
if (isEastAsian) {
// Japanese birth year check
if (allRelevantIds.includes("Q17") && birthYear > 1885) {
// Modern Japanese: Western order
return westernSort(fullName);
}
// Others: Mao Zedong -> Mao, Zedong
const parts = fullName.split(' ');
if (parts.length > 1) {
return `${parts[0]}, ${parts.slice(1).join(' ')}`;
}
}
// Default Western sort
return westernSort(fullName);
}
function westernSort(name) {
// Handle O'Neill -> ONeill
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();
// Handle Jr, III, etc.
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();
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);
}
function replaceMI(text) {
const miStart = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miStart.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;
miStart.lastIndex = lastIndex;
}
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.postWithToken('csrf', {
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'
});
console.error("Comrade save error:", err);
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgUserId") !== 19244234 || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") {
return;
} {
$(document).ready(async function() {
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').click(() => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
checkChemicalRedirect(wikitext, currentTitle);
checkDomainRedirect(qid, currentTitle);
if (qid) {
const wikiData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
const entity = wikiData.entities[qid];
const isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
let targetSortName = "";
if (dsMatch && isHuman) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
const givenNameIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
let wdGivens = [];
if (givenNameIds.length > 0) {
const nameData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: givenNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
wdGivens = givenNameIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean);
}
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
const referenceName = entity.labels?.en?.value || currentTitle;
const fixCasing = (part) => {
return part.split(' ').map(word => {
const regex = new RegExp(`\\b${word}\\b`, 'i');
const match = referenceName.match(regex);
if (match) return match[0];
const partsOfTitle = referenceName.split(' ');
const closest = partsOfTitle.find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase()));
return closest || word;
}).join(' ');
};
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixCasing(lastPart)}, ${fixCasing(firstPart)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field (with case fix).");
} else if (wdGivens.length > 0 && lastPart.split(' ').length > 1) {
const lastParts = lastPart.split(' ');
const misplacedGiven = lastParts.find(part => wdGivens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastParts.filter(p => p !== misplacedGiven).join(' ');
const newGiven = `${misplacedGiven} ${firstPart}`.trim();
targetSortName = `${fixCasing(newSurname)}, ${fixCasing(newGiven)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
} else {
const cLast = fixCasing(lastPart);
const cFirst = fixCasing(firstPart);
if (cLast !== lastPart || cFirst !== firstPart) {
targetSortName = `${cLast}, ${cFirst}`;
renderSortNudge(targetSortName, "Case mismatch relative to article title/Wikidata.");
}
}
} else {
const parts = currentDS.split(' ');
if (parts.length >= 2) {
const last = parts.pop();
const first = parts.join(' ');
const ref = entity.labels?.en?.value || currentTitle;
const fix = (p) => p.split(' ').map(w => (ref.match(new RegExp(`\\b${w}\\b`, 'i')) || [w])[0]).join(' ');
targetSortName = `${fix(last)}, ${fix(first)}`;
renderSortNudge(targetSortName, 'Structure should be "Last, First".');
}
}
} else if (isHuman) {
// Logic for when DEFAULTSORT is missing entirely
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const nameParts = tClean.split(' ');
// Define primarySurname early so it can be used for name list filtering later
const primarySurname = hasFamilyNameHatnote ? nameParts[0] : nameParts[nameParts.length - 1];
if (nameParts.length >= 2) {
let suggestedSort;
if (hasFamilyNameHatnote) {
// If hatnote exists, title is already "Surname Givenname"
suggestedSort = `${nameParts[0]}, ${nameParts.slice(1).join(' ')}`;
} else {
// Use your helper function for Western/other names
suggestedSort = getSmartSortKey(tClean, entity);
}
// Use 'targetSortName' so the variable is available for the redirect logic later
targetSortName = suggestedSort;
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).click(() => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
}
// --- Long name extraction (using wikitext) ---
const leadLine = wikitext.split('\n').find(line => line.includes("'''"));
const boldMatch = leadLine ? leadLine.match(/'''(.+?)'''/) : null;
if (boldMatch && isHuman) {
// Clean nicknames in quotes "" and parentheses () and normalize spaces
let longName = boldMatch[1].replace(/\s*("[^"]*"|\([^)\n]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
// Strip disambiguators from currentTitle for a fair length comparison
const baseTitle = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
if (longName.length > baseTitle.length) {
const nameParts = longName.split(' ');
if (nameParts.length > 1) {
// Check for family name hatnote to detect name order (e.g., Chinese, Korean)
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
let sortKey;
if (hasFamilyNameHatnote) {
// If hatnote exists, title is already "Surname Givenname", so sort as "Surname, Givenname"
sortKey = `${nameParts[0]}, ${nameParts.slice(1).join(' ')}`;
} else {
// Fallback to Wikidata-based smart logic for Western/Arabic/Other patterns
sortKey = getSmartSortKey(longName, entity);
}
// Format the special redirect wikitext for the long name
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
// Check if redirect exists and render if missing
checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
}
}
// --- Sort name ---
if (isHuman && targetSortName) {
let targetPage = currentTitle;
let rTemplate = "R from sort name";
let labelSuffix = "";
// If the article has a parenthetical disambiguator, find basename
if (currentTitle.includes(' (')) {
targetPage = currentTitle.split(' (')[0];
rTemplate = "R from ambiguous sort name";
labelSuffix = " (ambiguous)";
}
let rCat = rTemplate;
if (targetSortName.includes(',')) {
const parts = targetSortName.split(',');
const lI = parts[0].trim().charAt(0).toUpperCase();
const fI = parts[1].trim().charAt(0).toUpperCase();
if (lI && fI) rCat = `${rTemplate}|${lI}|${fI}`;
}
// Only run checkAndRender once per person
checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name" + labelSuffix);
}
// --- Name list checking via Wikidata P734/P735 ---
if (isHuman) {
const familyNameClaims = entity.claims?.P734 || [];
const givenNameClaims = entity.claims?.P735 || [];
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const getNameLabels = async (claims) => {
const ids = claims.map(c => c.mainsnak.datavalue.value.id);
if (ids.length === 0) return [];
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: ids.join('|'),
props: 'labels',
languages: 'en',
languagefallback: true,
format: 'json',
origin: '*'
},
dataType: 'json'
});
return Object.values(res.entities).map(e => e.labels?.en?.value).filter(Boolean);
};
const [allFamilyNames, allGivenNames] = await Promise.all([
getNameLabels(familyNameClaims),
getNameLabels(givenNameClaims)
]);
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
allFamilyNames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
allGivenNames.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) {
finalGivenNames.add(gn);
}
});
if (finalFamilyNames.size === 0 || finalGivenNames.size === 0) {
const titleParts = tClean.split(' ');
if (titleParts.length >= 2) {
if (finalFamilyNames.size === 0) finalFamilyNames.add(titleParts[titleParts.length - 1]);
if (finalGivenNames.size === 0) finalGivenNames.add(titleParts[0]);
}
}
const isOrphan = isOrphanTagged && backLinkCount < 1;
// Helper to probe for the best available list title by priority
const getPriorityTarget = async (name, type) => {
let titlesToTry = [];
if (type === 'surname') {
titlesToTry = [`List of people with surname ${name}`, `List of people with the Korean family name ${name}`, `List of people with the Chinese family name ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`];
} else {
titlesToTry = [`List of people with given name ${name}`, `List of people named ${name}`, `${name} (given name)`];
}
// Only add the bare name as a fallback if it's not a common pronoun/short word
const stopList = ['he', 'she', 'it', 'they', 'an', 'a', 'the'];
if (name.length > 2 && !stopList.includes(name.toLowerCase())) {
titlesToTry.push(name);
}
const listRes = await api.get({
action: 'query',
titles: titlesToTry.join('|'),
formatversion: 2
});
const pages = listRes.query.pages;
// Return the first one that exists, following the priority in titlesToTry
return titlesToTry.find(t => {
const p = pages.find(pg => pg.title === t);
return p && !p.missing;
});
};
// Check given names
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
for (const name of finalGivenNames) {
// Skip if the name is serving as the primary surname to avoid pronoun/ambiguous name false positives
if (name.toLowerCase() === primarySurname.toLowerCase()) {
continue;
}
// Skip if this name is part of the primary surname to avoid false positives (e.g. Kim)
const titleParts = tClean.split(' ');
if (hasFamilyNameHatnote && name.toLowerCase() === titleParts[0].toLowerCase()) continue;
if (!hasFamilyNameHatnote && name.toLowerCase() === titleParts[titleParts.length - 1].toLowerCase()) continue;
const target = await getPriorityTarget(name, 'given');
if (target) {
await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
}
// Check family names
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);
}
});
}
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}`).click(() => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
})();
1o3nzuo99gs8wxawagcqtma1ufagy88
737814
737813
2026-04-13T10:14:15Z
Sam Sailor
26820
Test
737814
javascript
text/javascript
(function() {
var Comrade = Comrade || {};
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').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-" + btoa(unescape(encodeURIComponent(redirTitle))).replace(/=/g, "");
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}]]`;
// Pass the customWikitext through to the creation function
const $btn = $('<button>').text('Create redirect').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);
}
}
function checkChemicalRedirect(wikitext, currentTitle) {
if (!/\{\{\s*Chembox Properties/i.test(wikitext)) {
console.log("Comrade: No 'Chembox Properties' template found.");
return;
}
let formula = "";
const formulaMatch = wikitext.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
formula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
console.log("Comrade: Found explicit Formula parameter:", formula);
} else {
console.log("Comrade: No explicit formula. Searching for individual elements...");
const chemboxSection = wikitext.match(/\{\{\s*Chembox Properties[\s\S]*?\}\}/i);
if (chemboxSection) {
const content = chemboxSection[0];
console.log("Comrade: Chembox content extracted:", content);
// Comprehensive list extracted from [[Template:Chembox Elements/molecular formula]]
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+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch) {
console.log(`Comrade: Match found for ${el}:`, elMatch[0]);
if (elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
}
});
console.log("Comrade: Final element data object:", data);
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
// If carbon exists, C then H
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
// Alphabetical for everything else
const remaining = elementsPresent.filter(el => {
if (hasC && (el === "C" || el === "H")) return false;
return true;
}).sort();
// Special case: If NO carbon, H must be sorted alphabetically
remaining.forEach(el => {
formulaArray.push(el + data[el]);
});
formula = formulaArray.join('');
console.log("Comrade: Generated formula from elements:", formula);
}
} else {
console.log("Comrade: Failed to extract Chembox Properties section content.");
}
}
if (formula && formula !== currentTitle) {
console.log(`Comrade: Success! Triggering redirect for ${formula}`);
checkAndRenderRedirect(formula, currentTitle, "R from chemical formula", "Chemical formula");
} else {
console.log("Comrade: Formula matches current title or is empty. No redirect needed.");
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
// Try to get the URL from Wikidata (P856)
if (qid) {
try {
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
}
});
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
console.log("Comrade: Found domain candidate in Wikidata:", domainCandidate);
}
} catch (e) {
console.log("Comrade: Wikidata API call failed.");
}
}
// Fallback to Infobox URL if Wikidata is empty
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
if (domainCandidate) console.log("Comrade: Found domain candidate in Infobox:", domainCandidate);
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
// Only proceed if it's a root domain (pathname is "/")
if (domain.includes(".") && url.pathname === "/") {
console.log(`Comrade: Verifying if ${domainCandidate} is live via Citation API...`);
// The live check, CORS-friendly proxy
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
$.ajax({
url: citationURL,
method: 'GET',
success: function() {
console.log(`Comrade: URL is live. Proceeding with redirect for: ${domain}`);
checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
},
error: function(xhr) {
console.log(`Comrade: URL failed live check (Status: ${xhr.status}). Skipping redirect.`);
}
});
} else {
console.log(`Comrade: ${domain} rejected (not a root domain or missing TLD).`);
}
} catch (e) {
console.log("Comrade: Error parsing URL object.");
}
} else {
console.log("Comrade: No domain candidate found for this article.");
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan) {
console.log(`[Comrade] Checking ${type} list for: ${name}`);
const api = new mw.Api();
// Added lowercase version of the type to the candidates
const candidates = [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
// Set to keep track of titles we've already checked to avoid redundant API calls
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
console.log(`[Comrade] Querying: ${title}`);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
// redirects: 1 // Follow redirects to find the actual list page
redirects: 0 // Do not follow redirects
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const resolvedTitle = page.title;
// Check for specific name templates
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
// Check for disambiguation pages with name parameters
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
console.log(`[Comrade] Results for ${resolvedTitle} - Match: ${isNameMatch}, Dab: ${isNameDab}`);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) {
console.log(`[Comrade] ${currentTitle} is already listed on ${resolvedTitle}`);
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:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; should it be?"));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not currently listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; 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').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();
// Use custom wikitext if provided (for Long Name), otherwise use standard template
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
return api.postWithToken('csrf', {
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
}).done(() => {
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
});
}
function getSmartSortKey(fullName, entity) {
const countryIds = entity.claims?.P27?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
// Include P17 (country) for historical figures
const locationIds = entity.claims?.P17?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
// Broaden search to birth/burial places for historical figures
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]) || 2026;
// Eliminate saints
if (fullName.startsWith("Saint ")) {
return fullName.replace("Saint ", "");
}
// Arabic names (modern vs historical)
// Particles: Abu, Abd, Abdel, Abdul, ben, bin, bint
const arabicParticles = /\b(Abu|Abd|Abdel|Abdul|ben|bin|bint)\b/i;
if (fullName.match(arabicParticles)) {
if (birthYear > 1900) {
// Sort as Western: "Osama bin Laden" -> "Bin Laden, Osama"
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; // Historical: sort as written
}
}
// East Asian (family name first)
// Q148 (China), Q184 (Korea), Q1884 (S. Korea), Q424 (N. Korea), Q881 (Vietnam), Q17 (Japan)
const eastAsianQids = ["Q148", "Q184", "Q884", "Q424", "Q881", "Q17"];
const isEastAsian = allRelevantIds.some(id => eastAsianQids.includes(id));
if (isEastAsian) {
// Japanese birth year check
if (allRelevantIds.includes("Q17") && birthYear > 1885) {
// Modern Japanese: Western order
return westernSort(fullName);
}
// Others: Mao Zedong -> Mao, Zedong
const parts = fullName.split(' ');
if (parts.length > 1) {
return `${parts[0]}, ${parts.slice(1).join(' ')}`;
}
}
// Default Western sort
return westernSort(fullName);
}
function westernSort(name) {
// Handle O'Neill -> ONeill
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();
// Handle Jr, III, etc.
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();
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);
}
function replaceMI(text) {
const miStart = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miStart.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;
miStart.lastIndex = lastIndex;
}
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.postWithToken('csrf', {
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'
});
console.error("Comrade save error:", err);
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgUserId") !== 19244234 || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") {
return;
} {
$(document).ready(async function() {
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').click(() => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
checkChemicalRedirect(wikitext, currentTitle);
checkDomainRedirect(qid, currentTitle);
if (qid) {
const wikiData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
const entity = wikiData.entities[qid];
const isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
let targetSortName = "";
if (dsMatch && isHuman) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
// Detect if existing DEFAULTSORT is swapped (e.g., for Ye Guofu)
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const nameParts = tClean.split(' ');
const primarySurname = hasFamilyNameHatnote ? nameParts[0] : nameParts[nameParts.length - 1];
if (currentDS.includes(',')) {
const dsSurname = currentDS.split(',')[0].trim();
if (dsSurname.toLowerCase() !== primarySurname.toLowerCase()) {
const suggestedSort = hasFamilyNameHatnote ? `${nameParts[0]}, ${nameParts.slice(1).join(' ')}` : getSmartSortKey(tClean, entity);
const $dsWarning = $('<div>').addClass('comrade-container comrade-warning');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Fix to: {{DEFAULTSORT:${suggestedSort}}}`).click(() => performSortCorrection(suggestedSort, "replace"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Appears swapped. `, $btn));
$dsWarning.append($header);
appendDismiss($dsWarning);
}
} // End of swap check
const givenNameIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
let wdGivens = [];
if (givenNameIds.length > 0) {
const nameData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: givenNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
wdGivens = givenNameIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean);
}
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
const referenceName = entity.labels?.en?.value || currentTitle;
const fixCasing = (part) => {
return part.split(' ').map(word => {
const regex = new RegExp(`\\b${word}\\b`, 'i');
const match = referenceName.match(regex);
if (match) return match[0];
const partsOfTitle = referenceName.split(' ');
const closest = partsOfTitle.find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase()));
return closest || word;
}).join(' ');
};
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixCasing(lastPart)}, ${fixCasing(firstPart)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field (with case fix).");
} else if (wdGivens.length > 0 && lastPart.split(' ').length > 1) {
const lastParts = lastPart.split(' ');
const misplacedGiven = lastParts.find(part => wdGivens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastParts.filter(p => p !== misplacedGiven).join(' ');
const newGiven = `${misplacedGiven} ${firstPart}`.trim();
targetSortName = `${fixCasing(newSurname)}, ${fixCasing(newGiven)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
} else {
const cLast = fixCasing(lastPart);
const cFirst = fixCasing(firstPart);
if (cLast !== lastPart || cFirst !== firstPart) {
targetSortName = `${cLast}, ${cFirst}`;
renderSortNudge(targetSortName, "Case mismatch relative to article title/Wikidata.");
}
}
} else {
const parts = currentDS.split(' ');
if (parts.length >= 2) {
const last = parts.pop();
const first = parts.join(' ');
const ref = entity.labels?.en?.value || currentTitle;
const fix = (p) => p.split(' ').map(w => (ref.match(new RegExp(`\\b${w}\\b`, 'i')) || [w])[0]).join(' ');
targetSortName = `${fix(last)}, ${fix(first)}`;
renderSortNudge(targetSortName, 'Structure should be "Last, First".');
}
}
} else if (isHuman) {
// Logic for when DEFAULTSORT is missing entirely
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const nameParts = tClean.split(' ');
// Define primarySurname early so it can be used for name list filtering later
const primarySurname = hasFamilyNameHatnote ? nameParts[0] : nameParts[nameParts.length - 1];
if (nameParts.length >= 2) {
let suggestedSort;
if (hasFamilyNameHatnote) {
// If hatnote exists, title is already "Surname Givenname"
suggestedSort = `${nameParts[0]}, ${nameParts.slice(1).join(' ')}`;
} else {
// Use your helper function for Western/other names
suggestedSort = getSmartSortKey(tClean, entity);
}
// Use 'targetSortName' so the variable is available for the redirect logic later
targetSortName = suggestedSort;
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).click(() => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
}
// --- Long name extraction (using wikitext) ---
const leadLine = wikitext.split('\n').find(line => line.includes("'''"));
const boldMatch = leadLine ? leadLine.match(/'''(.+?)'''/) : null;
if (boldMatch && isHuman) {
// Clean nicknames in quotes "" and parentheses () and normalize spaces
let longName = boldMatch[1].replace(/\s*("[^"]*"|\([^)\n]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
// Strip disambiguators from currentTitle for a fair length comparison
const baseTitle = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
if (longName.length > baseTitle.length) {
const nameParts = longName.split(' ');
if (nameParts.length > 1) {
// Check for family name hatnote to detect name order (e.g., Chinese, Korean)
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
let sortKey;
if (hasFamilyNameHatnote) {
// If hatnote exists, title is already "Surname Givenname", so sort as "Surname, Givenname"
sortKey = `${nameParts[0]}, ${nameParts.slice(1).join(' ')}`;
} else {
// Fallback to Wikidata-based smart logic for Western/Arabic/Other patterns
sortKey = getSmartSortKey(longName, entity);
}
// Format the special redirect wikitext for the long name
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
// Check if redirect exists and render if missing
checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
}
}
// --- Sort name ---
if (isHuman && targetSortName) {
let targetPage = currentTitle;
let rTemplate = "R from sort name";
let labelSuffix = "";
// If the article has a parenthetical disambiguator, find basename
if (currentTitle.includes(' (')) {
targetPage = currentTitle.split(' (')[0];
rTemplate = "R from ambiguous sort name";
labelSuffix = " (ambiguous)";
}
let rCat = rTemplate;
if (targetSortName.includes(',')) {
const parts = targetSortName.split(',');
const lI = parts[0].trim().charAt(0).toUpperCase();
const fI = parts[1].trim().charAt(0).toUpperCase();
if (lI && fI) rCat = `${rTemplate}|${lI}|${fI}`;
}
// Only run checkAndRender once per person
checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name" + labelSuffix);
}
// --- Name list checking via Wikidata P734/P735 ---
if (isHuman) {
const familyNameClaims = entity.claims?.P734 || [];
const givenNameClaims = entity.claims?.P735 || [];
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const getNameLabels = async (claims) => {
const ids = claims.map(c => c.mainsnak.datavalue.value.id);
if (ids.length === 0) return [];
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: ids.join('|'),
props: 'labels',
languages: 'en',
languagefallback: true,
format: 'json',
origin: '*'
},
dataType: 'json'
});
return Object.values(res.entities).map(e => e.labels?.en?.value).filter(Boolean);
};
const [allFamilyNames, allGivenNames] = await Promise.all([
getNameLabels(familyNameClaims),
getNameLabels(givenNameClaims)
]);
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
allFamilyNames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
allGivenNames.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) {
finalGivenNames.add(gn);
}
});
if (finalFamilyNames.size === 0 || finalGivenNames.size === 0) {
const titleParts = tClean.split(' ');
if (titleParts.length >= 2) {
if (finalFamilyNames.size === 0) finalFamilyNames.add(titleParts[titleParts.length - 1]);
if (finalGivenNames.size === 0) finalGivenNames.add(titleParts[0]);
}
}
const isOrphan = isOrphanTagged && backLinkCount < 1;
// Helper to probe for the best available list title by priority
const getPriorityTarget = async (name, type) => {
let titlesToTry = [];
if (type === 'surname') {
titlesToTry = [`List of people with surname ${name}`, `List of people with the Korean family name ${name}`, `List of people with the Chinese family name ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`];
} else {
titlesToTry = [`List of people with given name ${name}`, `List of people named ${name}`, `${name} (given name)`];
}
// Only add the bare name as a fallback if it's not a common pronoun/short word
const stopList = ['he', 'she', 'it', 'they', 'an', 'a', 'the'];
if (name.length > 2 && !stopList.includes(name.toLowerCase())) {
titlesToTry.push(name);
}
const listRes = await api.get({
action: 'query',
titles: titlesToTry.join('|'),
formatversion: 2
});
const pages = listRes.query.pages;
// Return the first one that exists, following the priority in titlesToTry
return titlesToTry.find(t => {
const p = pages.find(pg => pg.title === t);
return p && !p.missing;
});
};
// Check given names
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
for (const name of finalGivenNames) {
// Skip if the name is serving as the primary surname to avoid pronoun/ambiguous name false positives
if (name.toLowerCase() === primarySurname.toLowerCase()) {
continue;
}
// Skip if this name is part of the primary surname to avoid false positives (e.g. Kim)
const titleParts = tClean.split(' ');
if (hasFamilyNameHatnote && name.toLowerCase() === titleParts[0].toLowerCase()) continue;
if (!hasFamilyNameHatnote && name.toLowerCase() === titleParts[titleParts.length - 1].toLowerCase()) continue;
const target = await getPriorityTarget(name, 'given');
if (target) {
await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
}
// Check family names
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);
}
});
}
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}`).click(() => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
})();
45tlvapmy4wr4cvp3we37pw52zayy5x
737815
737814
2026-04-13T10:22:49Z
Sam Sailor
26820
Test
737815
javascript
text/javascript
(function() {
var Comrade = Comrade || {};
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').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-" + btoa(unescape(encodeURIComponent(redirTitle))).replace(/=/g, "");
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}]]`;
// Pass the customWikitext through to the creation function
const $btn = $('<button>').text('Create redirect').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);
}
}
function checkChemicalRedirect(wikitext, currentTitle) {
if (!/\{\{\s*Chembox Properties/i.test(wikitext)) {
console.log("Comrade: No 'Chembox Properties' template found.");
return;
}
let formula = "";
const formulaMatch = wikitext.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
formula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
console.log("Comrade: Found explicit Formula parameter:", formula);
} else {
console.log("Comrade: No explicit formula. Searching for individual elements...");
const chemboxSection = wikitext.match(/\{\{\s*Chembox Properties[\s\S]*?\}\}/i);
if (chemboxSection) {
const content = chemboxSection[0];
console.log("Comrade: Chembox content extracted:", content);
// Comprehensive list extracted from [[Template:Chembox Elements/molecular formula]]
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+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch) {
console.log(`Comrade: Match found for ${el}:`, elMatch[0]);
if (elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
}
});
console.log("Comrade: Final element data object:", data);
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
// If carbon exists, C then H
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
// Alphabetical for everything else
const remaining = elementsPresent.filter(el => {
if (hasC && (el === "C" || el === "H")) return false;
return true;
}).sort();
// Special case: If NO carbon, H must be sorted alphabetically
remaining.forEach(el => {
formulaArray.push(el + data[el]);
});
formula = formulaArray.join('');
console.log("Comrade: Generated formula from elements:", formula);
}
} else {
console.log("Comrade: Failed to extract Chembox Properties section content.");
}
}
if (formula && formula !== currentTitle) {
console.log(`Comrade: Success! Triggering redirect for ${formula}`);
checkAndRenderRedirect(formula, currentTitle, "R from chemical formula", "Chemical formula");
} else {
console.log("Comrade: Formula matches current title or is empty. No redirect needed.");
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
// Try to get the URL from Wikidata (P856)
if (qid) {
try {
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
}
});
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
console.log("Comrade: Found domain candidate in Wikidata:", domainCandidate);
}
} catch (e) {
console.log("Comrade: Wikidata API call failed.");
}
}
// Fallback to Infobox URL if Wikidata is empty
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
if (domainCandidate) console.log("Comrade: Found domain candidate in Infobox:", domainCandidate);
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
// Only proceed if it's a root domain (pathname is "/")
if (domain.includes(".") && url.pathname === "/") {
console.log(`Comrade: Verifying if ${domainCandidate} is live via Citation API...`);
// The live check, CORS-friendly proxy
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
$.ajax({
url: citationURL,
method: 'GET',
success: function() {
console.log(`Comrade: URL is live. Proceeding with redirect for: ${domain}`);
checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
},
error: function(xhr) {
console.log(`Comrade: URL failed live check (Status: ${xhr.status}). Skipping redirect.`);
}
});
} else {
console.log(`Comrade: ${domain} rejected (not a root domain or missing TLD).`);
}
} catch (e) {
console.log("Comrade: Error parsing URL object.");
}
} else {
console.log("Comrade: No domain candidate found for this article.");
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan) {
console.log(`[Comrade] Checking ${type} list for: ${name}`);
const api = new mw.Api();
// Added lowercase version of the type to the candidates
const candidates = [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
// Set to keep track of titles we've already checked to avoid redundant API calls
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
console.log(`[Comrade] Querying: ${title}`);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
// redirects: 1 // Follow redirects to find the actual list page
redirects: 0 // Do not follow redirects
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const resolvedTitle = page.title;
// Check for specific name templates
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
// Check for disambiguation pages with name parameters
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
console.log(`[Comrade] Results for ${resolvedTitle} - Match: ${isNameMatch}, Dab: ${isNameDab}`);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) {
console.log(`[Comrade] ${currentTitle} is already listed on ${resolvedTitle}`);
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:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; should it be?"));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not currently listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; 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').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();
// Use custom wikitext if provided (for Long Name), otherwise use standard template
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
return api.postWithToken('csrf', {
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
}).done(() => {
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
});
}
function getSmartSortKey(fullName, entity) {
const countryIds = entity.claims?.P27?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
// Include P17 (country) for historical figures
const locationIds = entity.claims?.P17?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
// Broaden search to birth/burial places for historical figures
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]) || 2026;
// Eliminate saints
if (fullName.startsWith("Saint ")) {
return fullName.replace("Saint ", "");
}
// Arabic names (modern vs historical)
// Particles: Abu, Abd, Abdel, Abdul, ben, bin, bint
const arabicParticles = /\b(Abu|Abd|Abdel|Abdul|ben|bin|bint)\b/i;
if (fullName.match(arabicParticles)) {
if (birthYear > 1900) {
// Sort as Western: "Osama bin Laden" -> "Bin Laden, Osama"
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; // Historical: sort as written
}
}
// East Asian (family name first)
// Q148 (China), Q184 (Korea), Q1884 (S. Korea), Q424 (N. Korea), Q881 (Vietnam), Q17 (Japan)
const eastAsianQids = ["Q148", "Q184", "Q884", "Q424", "Q881", "Q17"];
const isEastAsian = allRelevantIds.some(id => eastAsianQids.includes(id));
if (isEastAsian) {
// Japanese birth year check
if (allRelevantIds.includes("Q17") && birthYear > 1885) {
// Modern Japanese: Western order
return westernSort(fullName);
}
// Others: Mao Zedong -> Mao, Zedong
const parts = fullName.split(' ');
if (parts.length > 1) {
return `${parts[0]}, ${parts.slice(1).join(' ')}`;
}
}
// Default Western sort
return westernSort(fullName);
}
function westernSort(name) {
// Handle O'Neill -> ONeill
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();
// Handle Jr, III, etc.
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();
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);
}
function replaceMI(text) {
const miStart = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miStart.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;
miStart.lastIndex = lastIndex;
}
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.postWithToken('csrf', {
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'
});
console.error("Comrade save error:", err);
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgUserId") !== 19244234 || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") {
return;
} {
$(document).ready(async function() {
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').click(() => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
checkChemicalRedirect(wikitext, currentTitle);
checkDomainRedirect(qid, currentTitle);
if (qid) {
const wikiData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
const entity = wikiData.entities[qid];
const isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
let targetSortName = "";
if (dsMatch && isHuman) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
// Detect if existing DEFAULTSORT is swapped (e.g., for Ye Guofu)
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const nameParts = tClean.split(' ');
const primarySurname = hasFamilyNameHatnote ? nameParts[0] : nameParts[nameParts.length - 1];
if (currentDS.includes(',')) {
const dsSurname = currentDS.split(',')[0].trim();
if (dsSurname.toLowerCase() !== primarySurname.toLowerCase()) {
const suggestedSort = hasFamilyNameHatnote ? `${nameParts[0]}, ${nameParts.slice(1).join(' ')}` : getSmartSortKey(tClean, entity);
const $dsWarning = $('<div>').addClass('comrade-container comrade-warning');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Fix to: {{DEFAULTSORT:${suggestedSort}}}`).click(() => performSortCorrection(suggestedSort, "replace"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Appears swapped (Found "${dsSurname}", expected "${primarySurname}"). `, $btn));
$dsWarning.append($header);
appendDismiss($dsWarning);
}
} // End of swap check
const givenNameIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
let wdGivens = [];
if (givenNameIds.length > 0) {
const nameData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: givenNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
wdGivens = givenNameIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean);
}
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
const referenceName = entity.labels?.en?.value || currentTitle;
const fixCasing = (part) => {
return part.split(' ').map(word => {
const regex = new RegExp(`\\b${word}\\b`, 'i');
const match = referenceName.match(regex);
if (match) return match[0];
const partsOfTitle = referenceName.split(' ');
const closest = partsOfTitle.find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase()));
return closest || word;
}).join(' ');
};
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixCasing(lastPart)}, ${fixCasing(firstPart)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field (with case fix).");
} else if (wdGivens.length > 0 && lastPart.split(' ').length > 1) {
const lastParts = lastPart.split(' ');
const misplacedGiven = lastParts.find(part => wdGivens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastParts.filter(p => p !== misplacedGiven).join(' ');
const newGiven = `${misplacedGiven} ${firstPart}`.trim();
targetSortName = `${fixCasing(newSurname)}, ${fixCasing(newGiven)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
} else {
const cLast = fixCasing(lastPart);
const cFirst = fixCasing(firstPart);
if (cLast !== lastPart || cFirst !== firstPart) {
targetSortName = `${cLast}, ${cFirst}`;
renderSortNudge(targetSortName, "Case mismatch relative to article title/Wikidata.");
}
}
} else {
const parts = currentDS.split(' ');
if (parts.length >= 2) {
const last = parts.pop();
const first = parts.join(' ');
const ref = entity.labels?.en?.value || currentTitle;
const fix = (p) => p.split(' ').map(w => (ref.match(new RegExp(`\\b${w}\\b`, 'i')) || [w])[0]).join(' ');
targetSortName = `${fix(last)}, ${fix(first)}`;
renderSortNudge(targetSortName, 'Structure should be "Last, First".');
}
}
} else if (isHuman) {
// Logic for when DEFAULTSORT is missing entirely
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const nameParts = tClean.split(' ');
// Define primarySurname early so it can be used for name list filtering later
const primarySurname = hasFamilyNameHatnote ? nameParts[0] : nameParts[nameParts.length - 1];
if (nameParts.length >= 2) {
let suggestedSort;
if (hasFamilyNameHatnote) {
// If hatnote exists, title is already "Surname Givenname"
suggestedSort = `${nameParts[0]}, ${nameParts.slice(1).join(' ')}`;
} else {
// Use your helper function for Western/other names
suggestedSort = getSmartSortKey(tClean, entity);
}
// Use 'targetSortName' so the variable is available for the redirect logic later
targetSortName = suggestedSort;
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).click(() => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
}
// --- Long name extraction (using wikitext) ---
const leadLine = wikitext.split('\n').find(line => line.includes("'''"));
const boldMatch = leadLine ? leadLine.match(/'''(.+?)'''/) : null;
if (boldMatch && isHuman) {
// Clean nicknames in quotes "" and parentheses () and normalize spaces
let longName = boldMatch[1].replace(/\s*("[^"]*"|\([^)\n]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
// Strip disambiguators from currentTitle for a fair length comparison
const baseTitle = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
if (longName.length > baseTitle.length) {
const nameParts = longName.split(' ');
if (nameParts.length > 1) {
// Check for family name hatnote to detect name order (e.g., Chinese, Korean)
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
let sortKey;
if (hasFamilyNameHatnote) {
// If hatnote exists, title is already "Surname Givenname", so sort as "Surname, Givenname"
sortKey = `${nameParts[0]}, ${nameParts.slice(1).join(' ')}`;
} else {
// Fallback to Wikidata-based smart logic for Western/Arabic/Other patterns
sortKey = getSmartSortKey(longName, entity);
}
// Format the special redirect wikitext for the long name
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
// Check if redirect exists and render if missing
checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
}
}
// --- Sort name ---
if (isHuman && targetSortName) {
let targetPage = currentTitle;
let rTemplate = "R from sort name";
let labelSuffix = "";
// If the article has a parenthetical disambiguator, find basename
if (currentTitle.includes(' (')) {
targetPage = currentTitle.split(' (')[0];
rTemplate = "R from ambiguous sort name";
labelSuffix = " (ambiguous)";
}
let rCat = rTemplate;
if (targetSortName.includes(',')) {
const parts = targetSortName.split(',');
const lI = parts[0].trim().charAt(0).toUpperCase();
const fI = parts[1].trim().charAt(0).toUpperCase();
if (lI && fI) rCat = `${rTemplate}|${lI}|${fI}`;
}
// Only run checkAndRender once per person
checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name" + labelSuffix);
}
// --- Name list checking via Wikidata P734/P735 ---
if (isHuman) {
const familyNameClaims = entity.claims?.P734 || [];
const givenNameClaims = entity.claims?.P735 || [];
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const getNameLabels = async (claims) => {
const ids = claims.map(c => c.mainsnak.datavalue.value.id);
if (ids.length === 0) return [];
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: ids.join('|'),
props: 'labels',
languages: 'en',
languagefallback: true,
format: 'json',
origin: '*'
},
dataType: 'json'
});
return Object.values(res.entities).map(e => e.labels?.en?.value).filter(Boolean);
};
const [allFamilyNames, allGivenNames] = await Promise.all([
getNameLabels(familyNameClaims),
getNameLabels(givenNameClaims)
]);
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
allFamilyNames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
allGivenNames.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) {
finalGivenNames.add(gn);
}
});
if (finalFamilyNames.size === 0 || finalGivenNames.size === 0) {
const titleParts = tClean.split(' ');
if (titleParts.length >= 2) {
if (finalFamilyNames.size === 0) finalFamilyNames.add(titleParts[titleParts.length - 1]);
if (finalGivenNames.size === 0) finalGivenNames.add(titleParts[0]);
}
}
const isOrphan = isOrphanTagged && backLinkCount < 1;
// Helper to probe for the best available list title by priority
const getPriorityTarget = async (name, type) => {
let titlesToTry = [];
if (type === 'surname') {
titlesToTry = [`List of people with surname ${name}`, `List of people with the Korean family name ${name}`, `List of people with the Chinese family name ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`];
} else {
titlesToTry = [`List of people with given name ${name}`, `List of people named ${name}`, `${name} (given name)`];
}
// Only add the bare name as a fallback if it's not a common pronoun/short word
const stopList = ['he', 'she', 'it', 'they', 'an', 'a', 'the'];
if (name.length > 2 && !stopList.includes(name.toLowerCase())) {
titlesToTry.push(name);
}
const listRes = await api.get({
action: 'query',
titles: titlesToTry.join('|'),
formatversion: 2
});
const pages = listRes.query.pages;
// Return the first one that exists, following the priority in titlesToTry
return titlesToTry.find(t => {
const p = pages.find(pg => pg.title === t);
return p && !p.missing;
});
};
// Check given names
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
for (const name of finalGivenNames) {
// Skip if the name is serving as the primary surname to avoid pronoun/ambiguous name false positives
if (name.toLowerCase() === primarySurname.toLowerCase()) {
continue;
}
// Skip if this name is part of the primary surname to avoid false positives (e.g. Kim)
const titleParts = tClean.split(' ');
if (hasFamilyNameHatnote && name.toLowerCase() === titleParts[0].toLowerCase()) continue;
if (!hasFamilyNameHatnote && name.toLowerCase() === titleParts[titleParts.length - 1].toLowerCase()) continue;
const target = await getPriorityTarget(name, 'given');
if (target) {
await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
}
// Check family names
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);
}
});
}
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}`).click(() => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
})();
2hzsp4ta97m07flbzwg4wt7ctnslqbs
737816
737815
2026-04-13T10:29:57Z
Sam Sailor
26820
Test
737816
javascript
text/javascript
(function() {
var Comrade = Comrade || {};
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').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-" + btoa(unescape(encodeURIComponent(redirTitle))).replace(/=/g, "");
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}]]`;
// Pass the customWikitext through to the creation function
const $btn = $('<button>').text('Create redirect').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);
}
}
function checkChemicalRedirect(wikitext, currentTitle) {
if (!/\{\{\s*Chembox Properties/i.test(wikitext)) {
console.log("Comrade: No 'Chembox Properties' template found.");
return;
}
let formula = "";
const formulaMatch = wikitext.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
formula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
console.log("Comrade: Found explicit Formula parameter:", formula);
} else {
console.log("Comrade: No explicit formula. Searching for individual elements...");
const chemboxSection = wikitext.match(/\{\{\s*Chembox Properties[\s\S]*?\}\}/i);
if (chemboxSection) {
const content = chemboxSection[0];
console.log("Comrade: Chembox content extracted:", content);
// Comprehensive list extracted from [[Template:Chembox Elements/molecular formula]]
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+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch) {
console.log(`Comrade: Match found for ${el}:`, elMatch[0]);
if (elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
}
});
console.log("Comrade: Final element data object:", data);
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
// If carbon exists, C then H
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
// Alphabetical for everything else
const remaining = elementsPresent.filter(el => {
if (hasC && (el === "C" || el === "H")) return false;
return true;
}).sort();
// Special case: If NO carbon, H must be sorted alphabetically
remaining.forEach(el => {
formulaArray.push(el + data[el]);
});
formula = formulaArray.join('');
console.log("Comrade: Generated formula from elements:", formula);
}
} else {
console.log("Comrade: Failed to extract Chembox Properties section content.");
}
}
if (formula && formula !== currentTitle) {
console.log(`Comrade: Success! Triggering redirect for ${formula}`);
checkAndRenderRedirect(formula, currentTitle, "R from chemical formula", "Chemical formula");
} else {
console.log("Comrade: Formula matches current title or is empty. No redirect needed.");
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
// Try to get the URL from Wikidata (P856)
if (qid) {
try {
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
}
});
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
console.log("Comrade: Found domain candidate in Wikidata:", domainCandidate);
}
} catch (e) {
console.log("Comrade: Wikidata API call failed.");
}
}
// Fallback to Infobox URL if Wikidata is empty
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
if (domainCandidate) console.log("Comrade: Found domain candidate in Infobox:", domainCandidate);
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
// Only proceed if it's a root domain (pathname is "/")
if (domain.includes(".") && url.pathname === "/") {
console.log(`Comrade: Verifying if ${domainCandidate} is live via Citation API...`);
// The live check, CORS-friendly proxy
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
$.ajax({
url: citationURL,
method: 'GET',
success: function() {
console.log(`Comrade: URL is live. Proceeding with redirect for: ${domain}`);
checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
},
error: function(xhr) {
console.log(`Comrade: URL failed live check (Status: ${xhr.status}). Skipping redirect.`);
}
});
} else {
console.log(`Comrade: ${domain} rejected (not a root domain or missing TLD).`);
}
} catch (e) {
console.log("Comrade: Error parsing URL object.");
}
} else {
console.log("Comrade: No domain candidate found for this article.");
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan) {
console.log(`[Comrade] Checking ${type} list for: ${name}`);
const api = new mw.Api();
// Added lowercase version of the type to the candidates
const candidates = [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
// Set to keep track of titles we've already checked to avoid redundant API calls
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
console.log(`[Comrade] Querying: ${title}`);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
// redirects: 1 // Follow redirects to find the actual list page
redirects: 0 // Do not follow redirects
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const resolvedTitle = page.title;
// Check for specific name templates
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
// Check for disambiguation pages with name parameters
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
console.log(`[Comrade] Results for ${resolvedTitle} - Match: ${isNameMatch}, Dab: ${isNameDab}`);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) {
console.log(`[Comrade] ${currentTitle} is already listed on ${resolvedTitle}`);
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:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; should it be?"));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not currently listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; 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').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();
// Use custom wikitext if provided (for Long Name), otherwise use standard template
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
return api.postWithToken('csrf', {
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
}).done(() => {
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
});
}
function getSmartSortKey(fullName, entity) {
const countryIds = entity.claims?.P27?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
// Include P17 (country) for historical figures
const locationIds = entity.claims?.P17?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
// Broaden search to birth/burial places for historical figures
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]) || 2026;
// Eliminate saints
if (fullName.startsWith("Saint ")) {
return fullName.replace("Saint ", "");
}
// Arabic names (modern vs historical)
// Particles: Abu, Abd, Abdel, Abdul, ben, bin, bint
const arabicParticles = /\b(Abu|Abd|Abdel|Abdul|ben|bin|bint)\b/i;
if (fullName.match(arabicParticles)) {
if (birthYear > 1900) {
// Sort as Western: "Osama bin Laden" -> "Bin Laden, Osama"
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; // Historical: sort as written
}
}
// East Asian (family name first)
// Q148 (China), Q184 (Korea), Q1884 (S. Korea), Q424 (N. Korea), Q881 (Vietnam), Q17 (Japan)
const eastAsianQids = ["Q148", "Q184", "Q884", "Q424", "Q881", "Q17"];
const isEastAsian = allRelevantIds.some(id => eastAsianQids.includes(id));
if (isEastAsian) {
// Japanese birth year check
if (allRelevantIds.includes("Q17") && birthYear > 1885) {
// Modern Japanese: Western order
return westernSort(fullName);
}
// Others: Mao Zedong -> Mao, Zedong
const parts = fullName.split(' ');
if (parts.length > 1) {
return `${parts[0]}, ${parts.slice(1).join(' ')}`;
}
}
// Default Western sort
return westernSort(fullName);
}
function westernSort(name) {
// Handle O'Neill -> ONeill
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();
// Handle Jr, III, etc.
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();
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);
}
function replaceMI(text) {
const miStart = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miStart.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;
miStart.lastIndex = lastIndex;
}
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.postWithToken('csrf', {
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'
});
console.error("Comrade save error:", err);
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgUserId") !== 19244234 || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") {
return;
} {
$(document).ready(async function() {
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').click(() => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
checkChemicalRedirect(wikitext, currentTitle);
checkDomainRedirect(qid, currentTitle);
if (qid) {
const wikiData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
const entity = wikiData.entities[qid];
const isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
let targetSortName = "";
if (dsMatch && isHuman) {
const currentDS = dsMatch[1].trim();
targetSortName = currentDS;
// Detect if existing DEFAULTSORT is swapped (e.g., for Ye Guofu)
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const nameParts = tClean.split(' ');
const primarySurname = hasFamilyNameHatnote ? nameParts[0] : nameParts[nameParts.length - 1];
if (currentDS.includes(',')) {
const dsSurname = currentDS.split(',')[0].trim();
if (stripDiacritics(dsSurname).toLowerCase() !== stripDiacritics(primarySurname).toLowerCase()) {
const suggestedSort = hasFamilyNameHatnote ? `${nameParts[0]}, ${nameParts.slice(1).join(' ')}` : getSmartSortKey(tClean, entity);
const $dsWarning = $('<div>').addClass('comrade-container comrade-warning');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Fix to: {{DEFAULTSORT:${suggestedSort}}}`).click(() => performSortCorrection(suggestedSort, "replace"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Appears swapped (Found "${dsSurname}", expected "${primarySurname}"). `, $btn));
$dsWarning.append($header);
appendDismiss($dsWarning);
}
} // End of swap check
const givenNameIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
let wdGivens = [];
if (givenNameIds.length > 0) {
const nameData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: givenNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
wdGivens = givenNameIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean);
}
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
const referenceName = entity.labels?.en?.value || currentTitle;
const fixCasing = (part) => {
return part.split(' ').map(word => {
const regex = new RegExp(`\\b${word}\\b`, 'i');
const match = referenceName.match(regex);
if (match) return match[0];
const partsOfTitle = referenceName.split(' ');
const closest = partsOfTitle.find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase()));
return closest || word;
}).join(' ');
};
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixCasing(lastPart)}, ${fixCasing(firstPart)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field (with case fix).");
} else if (wdGivens.length > 0 && lastPart.split(' ').length > 1) {
const lastParts = lastPart.split(' ');
const misplacedGiven = lastParts.find(part => wdGivens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastParts.filter(p => p !== misplacedGiven).join(' ');
const newGiven = `${misplacedGiven} ${firstPart}`.trim();
targetSortName = `${fixCasing(newSurname)}, ${fixCasing(newGiven)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
} else {
const cLast = fixCasing(lastPart);
const cFirst = fixCasing(firstPart);
if (cLast !== lastPart || cFirst !== firstPart) {
targetSortName = `${cLast}, ${cFirst}`;
renderSortNudge(targetSortName, "Case mismatch relative to article title/Wikidata.");
}
}
} else {
const parts = currentDS.split(' ');
if (parts.length >= 2) {
const last = parts.pop();
const first = parts.join(' ');
const ref = entity.labels?.en?.value || currentTitle;
const fix = (p) => p.split(' ').map(w => (ref.match(new RegExp(`\\b${w}\\b`, 'i')) || [w])[0]).join(' ');
targetSortName = `${fix(last)}, ${fix(first)}`;
renderSortNudge(targetSortName, 'Structure should be "Last, First".');
}
}
} else if (isHuman) {
// Logic for when DEFAULTSORT is missing entirely
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const nameParts = tClean.split(' ');
// Define primarySurname early so it can be used for name list filtering later
const primarySurname = hasFamilyNameHatnote ? nameParts[0] : nameParts[nameParts.length - 1];
if (nameParts.length >= 2) {
let suggestedSort;
if (hasFamilyNameHatnote) {
// If hatnote exists, title is already "Surname Givenname"
suggestedSort = `${nameParts[0]}, ${nameParts.slice(1).join(' ')}`;
} else {
// Use your helper function for Western/other names
suggestedSort = getSmartSortKey(tClean, entity);
}
// Use 'targetSortName' so the variable is available for the redirect logic later
targetSortName = suggestedSort;
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).click(() => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
}
// --- Long name extraction (using wikitext) ---
const leadLine = wikitext.split('\n').find(line => line.includes("'''"));
const boldMatch = leadLine ? leadLine.match(/'''(.+?)'''/) : null;
if (boldMatch && isHuman) {
// Clean nicknames in quotes "" and parentheses () and normalize spaces
let longName = boldMatch[1].replace(/\s*("[^"]*"|\([^)\n]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
// Strip disambiguators from currentTitle for a fair length comparison
const baseTitle = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
if (longName.length > baseTitle.length) {
const nameParts = longName.split(' ');
if (nameParts.length > 1) {
// Check for family name hatnote to detect name order (e.g., Chinese, Korean)
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
let sortKey;
if (hasFamilyNameHatnote) {
// If hatnote exists, title is already "Surname Givenname", so sort as "Surname, Givenname"
sortKey = `${nameParts[0]}, ${nameParts.slice(1).join(' ')}`;
} else {
// Fallback to Wikidata-based smart logic for Western/Arabic/Other patterns
sortKey = getSmartSortKey(longName, entity);
}
// Format the special redirect wikitext for the long name
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
// Check if redirect exists and render if missing
checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
}
}
// --- Sort name ---
if (isHuman && targetSortName) {
let targetPage = currentTitle;
let rTemplate = "R from sort name";
let labelSuffix = "";
// If the article has a parenthetical disambiguator, find basename
if (currentTitle.includes(' (')) {
targetPage = currentTitle.split(' (')[0];
rTemplate = "R from ambiguous sort name";
labelSuffix = " (ambiguous)";
}
let rCat = rTemplate;
if (targetSortName.includes(',')) {
const parts = targetSortName.split(',');
const lI = parts[0].trim().charAt(0).toUpperCase();
const fI = parts[1].trim().charAt(0).toUpperCase();
if (lI && fI) rCat = `${rTemplate}|${lI}|${fI}`;
}
// Only run checkAndRender once per person
checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name" + labelSuffix);
}
// --- Name list checking via Wikidata P734/P735 ---
if (isHuman) {
const familyNameClaims = entity.claims?.P734 || [];
const givenNameClaims = entity.claims?.P735 || [];
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const getNameLabels = async (claims) => {
const ids = claims.map(c => c.mainsnak.datavalue.value.id);
if (ids.length === 0) return [];
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: ids.join('|'),
props: 'labels',
languages: 'en',
languagefallback: true,
format: 'json',
origin: '*'
},
dataType: 'json'
});
return Object.values(res.entities).map(e => e.labels?.en?.value).filter(Boolean);
};
const [allFamilyNames, allGivenNames] = await Promise.all([
getNameLabels(familyNameClaims),
getNameLabels(givenNameClaims)
]);
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
allFamilyNames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
allGivenNames.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) {
finalGivenNames.add(gn);
}
});
if (finalFamilyNames.size === 0 || finalGivenNames.size === 0) {
const titleParts = tClean.split(' ');
if (titleParts.length >= 2) {
if (finalFamilyNames.size === 0) finalFamilyNames.add(titleParts[titleParts.length - 1]);
if (finalGivenNames.size === 0) finalGivenNames.add(titleParts[0]);
}
}
const isOrphan = isOrphanTagged && backLinkCount < 1;
// Helper to probe for the best available list title by priority
const getPriorityTarget = async (name, type) => {
let titlesToTry = [];
if (type === 'surname') {
titlesToTry = [`List of people with surname ${name}`, `List of people with the Korean family name ${name}`, `List of people with the Chinese family name ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`];
} else {
titlesToTry = [`List of people with given name ${name}`, `List of people named ${name}`, `${name} (given name)`];
}
// Only add the bare name as a fallback if it's not a common pronoun/short word
const stopList = ['he', 'she', 'it', 'they', 'an', 'a', 'the'];
if (name.length > 2 && !stopList.includes(name.toLowerCase())) {
titlesToTry.push(name);
}
const listRes = await api.get({
action: 'query',
titles: titlesToTry.join('|'),
formatversion: 2
});
const pages = listRes.query.pages;
// Return the first one that exists, following the priority in titlesToTry
return titlesToTry.find(t => {
const p = pages.find(pg => pg.title === t);
return p && !p.missing;
});
};
// Check given names
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
for (const name of finalGivenNames) {
// Skip if the name is serving as the primary surname to avoid pronoun/ambiguous name false positives
if (name.toLowerCase() === primarySurname.toLowerCase()) {
continue;
}
// Skip if this name is part of the primary surname to avoid false positives (e.g. Kim)
const titleParts = tClean.split(' ');
if (hasFamilyNameHatnote && name.toLowerCase() === titleParts[0].toLowerCase()) continue;
if (!hasFamilyNameHatnote && name.toLowerCase() === titleParts[titleParts.length - 1].toLowerCase()) continue;
const target = await getPriorityTarget(name, 'given');
if (target) {
await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
}
// Check family names
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);
}
});
}
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}`).click(() => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
})();
31uar0xfhezmco6k0fazkjzmogc86r4
737817
737816
2026-04-13T10:38:38Z
Sam Sailor
26820
Test
737817
javascript
text/javascript
(function() {
var Comrade = Comrade || {};
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').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-" + btoa(unescape(encodeURIComponent(redirTitle))).replace(/=/g, "");
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}]]`;
// Pass the customWikitext through to the creation function
const $btn = $('<button>').text('Create redirect').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);
}
}
function checkChemicalRedirect(wikitext, currentTitle) {
if (!/\{\{\s*Chembox Properties/i.test(wikitext)) {
console.log("Comrade: No 'Chembox Properties' template found.");
return;
}
let formula = "";
const formulaMatch = wikitext.match(/\|\s*Formula\s*=\s*([^|\n}]+)/i);
if (formulaMatch) {
formula = formulaMatch[1].replace(/<\/?sub>/gi, "").trim();
console.log("Comrade: Found explicit Formula parameter:", formula);
} else {
console.log("Comrade: No explicit formula. Searching for individual elements...");
const chemboxSection = wikitext.match(/\{\{\s*Chembox Properties[\s\S]*?\}\}/i);
if (chemboxSection) {
const content = chemboxSection[0];
console.log("Comrade: Chembox content extracted:", content);
// Comprehensive list extracted from [[Template:Chembox Elements/molecular formula]]
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+)`, 'i');
const elMatch = content.match(elRegex);
if (elMatch) {
console.log(`Comrade: Match found for ${el}:`, elMatch[0]);
if (elMatch[1] !== "0") {
data[el] = elMatch[1] === "1" ? "" : elMatch[1];
}
}
});
console.log("Comrade: Final element data object:", data);
const elementsPresent = Object.keys(data);
if (elementsPresent.length > 0) {
let formulaArray = [];
const hasC = "C" in data;
// If carbon exists, C then H
if (hasC) {
formulaArray.push("C" + data["C"]);
if ("H" in data) formulaArray.push("H" + data["H"]);
}
// Alphabetical for everything else
const remaining = elementsPresent.filter(el => {
if (hasC && (el === "C" || el === "H")) return false;
return true;
}).sort();
// Special case: If NO carbon, H must be sorted alphabetically
remaining.forEach(el => {
formulaArray.push(el + data[el]);
});
formula = formulaArray.join('');
console.log("Comrade: Generated formula from elements:", formula);
}
} else {
console.log("Comrade: Failed to extract Chembox Properties section content.");
}
}
if (formula && formula !== currentTitle) {
console.log(`Comrade: Success! Triggering redirect for ${formula}`);
checkAndRenderRedirect(formula, currentTitle, "R from chemical formula", "Chemical formula");
} else {
console.log("Comrade: Formula matches current title or is empty. No redirect needed.");
}
}
async function checkDomainRedirect(qid, currentTitle) {
let domainCandidate = "";
// Try to get the URL from Wikidata (P856)
if (qid) {
try {
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetclaims',
entity: qid,
property: 'P856',
format: 'json',
origin: '*'
}
});
if (res.claims?.P856) {
domainCandidate = res.claims.P856[0].mainsnak.datavalue.value;
console.log("Comrade: Found domain candidate in Wikidata:", domainCandidate);
}
} catch (e) {
console.log("Comrade: Wikidata API call failed.");
}
}
// Fallback to Infobox URL if Wikidata is empty
if (!domainCandidate) {
domainCandidate = $(".infobox .url a").last().attr("href") || $(".official-website a").first().attr("href");
if (domainCandidate) console.log("Comrade: Found domain candidate in Infobox:", domainCandidate);
}
if (domainCandidate) {
try {
const url = new URL(domainCandidate);
let domain = url.hostname.replace(/^www\d*\./, "");
// Only proceed if it's a root domain (pathname is "/")
if (domain.includes(".") && url.pathname === "/") {
console.log(`Comrade: Verifying if ${domainCandidate} is live via Citation API...`);
// The live check, CORS-friendly proxy
const citationURL = '/api/rest_v1/data/citation/mediawiki/' + encodeURIComponent(domainCandidate);
$.ajax({
url: citationURL,
method: 'GET',
success: function() {
console.log(`Comrade: URL is live. Proceeding with redirect for: ${domain}`);
checkAndRenderRedirect(domain, currentTitle, "R from domain name", "Domain");
},
error: function(xhr) {
console.log(`Comrade: URL failed live check (Status: ${xhr.status}). Skipping redirect.`);
}
});
} else {
console.log(`Comrade: ${domain} rejected (not a root domain or missing TLD).`);
}
} catch (e) {
console.log("Comrade: Error parsing URL object.");
}
} else {
console.log("Comrade: No domain candidate found for this article.");
}
}
async function checkNameList(name, type, currentTitle, hasShortDesc, isOrphan) {
console.log(`[Comrade] Checking ${type} list for: ${name}`);
const api = new mw.Api();
// Added lowercase version of the type to the candidates
const candidates = [`${name} (${type})`, `${name} (${type.toLowerCase()})`, `${name} (name)`, name];
// Set to keep track of titles we've already checked to avoid redundant API calls
const checked = new Set();
for (const title of candidates) {
if (checked.has(title)) continue;
checked.add(title);
console.log(`[Comrade] Querying: ${title}`);
const res = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
formatversion: 2,
// redirects: 1 // Follow redirects to find the actual list page
redirects: 0 // Do not follow redirects
});
const page = res.query.pages[0];
if (page && !page.missing) {
const text = page.revisions[0].content;
const resolvedTitle = page.title;
// Check for specific name templates
const isNameMatch = /\{\{(Surname|Hndis|Given[ _]name|Forename|Givenname)/i.test(text);
// Check for disambiguation pages with name parameters
const isNameDab = /\{\{(Disambiguation|Dab|Disambig)(?:[^}]*\|(surname|surnames|given[ _]name|given[ _]names)|(?:\s*\}\}))/i.test(text);
console.log(`[Comrade] Results for ${resolvedTitle} - Match: ${isNameMatch}, Dab: ${isNameDab}`);
if (isNameMatch || isNameDab) {
const escapedTitle = currentTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '[ _]');
const alreadyListed = new RegExp('(\\[\\[|\\{\\{anbl\\|[ _]*|\\|)' + escapedTitle, 'i').test(text);
if (alreadyListed) {
console.log(`[Comrade] ${currentTitle} is already listed on ${resolvedTitle}`);
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:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; should it be?"));
} else {
$container.addClass('comrade-info');
$header.append($('<strong>').text('Informative:'));
$content.append($('<span>').append(`${type.charAt(0).toUpperCase() + type.slice(1)} not currently listed at `, $('<a>').attr({
href: mw.util.getUrl(resolvedTitle),
target: '_blank'
}).text(resolvedTitle), "; 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').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();
// Use custom wikitext if provided (for Long Name), otherwise use standard template
const content = customWikitext || `#REDIRECT [[${targetTitle}]]\n\n{{Redirect category shell|\n{{${rCategory}}}\n}}`;
const summary = customSummary || `Created redirect from [[${redirTitle}]]`;
return api.postWithToken('csrf', {
action: 'edit',
title: redirTitle,
text: content,
summary: summary,
createonly: true
}).done(() => {
mw.notify("Redirect created!", {
type: 'success',
tag: 'comrade',
classes: ['comrade-success']
});
$(`#${elementId}`).fadeOut();
});
}
function getSmartSortKey(fullName, entity) {
const countryIds = entity.claims?.P27?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
// Include P17 (country) for historical figures
const locationIds = entity.claims?.P17?.map(c => c.mainsnak?.datavalue?.value?.id) || [];
// Broaden search to birth/burial places for historical figures
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]) || 2026;
// Eliminate saints
if (fullName.startsWith("Saint ")) {
return fullName.replace("Saint ", "");
}
// Arabic names (modern vs historical)
// Particles: Abu, Abd, Abdel, Abdul, ben, bin, bint
const arabicParticles = /\b(Abu|Abd|Abdel|Abdul|ben|bin|bint)\b/i;
if (fullName.match(arabicParticles)) {
if (birthYear > 1900) {
// Sort as Western: "Osama bin Laden" -> "Bin Laden, Osama"
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; // Historical: sort as written
}
}
// East Asian (family name first)
// Q148 (China), Q184 (Korea), Q1884 (S. Korea), Q424 (N. Korea), Q881 (Vietnam), Q17 (Japan)
const eastAsianQids = ["Q148", "Q184", "Q884", "Q424", "Q881", "Q17"];
const isEastAsian = allRelevantIds.some(id => eastAsianQids.includes(id));
if (isEastAsian) {
// Japanese birth year check
if (allRelevantIds.includes("Q17") && birthYear > 1885) {
// Modern Japanese: Western order
return westernSort(fullName);
}
// Others: Mao Zedong -> Mao, Zedong
const parts = fullName.split(' ');
if (parts.length > 1) {
return `${parts[0]}, ${parts.slice(1).join(' ')}`;
}
}
// Default Western sort
return westernSort(fullName);
}
function westernSort(name) {
// Handle O'Neill -> ONeill
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();
// Handle Jr, III, etc.
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();
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);
}
function replaceMI(text) {
const miStart = /\{\{Multiple[ _]issues\s*\|/gi;
let result = '';
let lastIndex = 0;
let match;
while ((match = miStart.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;
miStart.lastIndex = lastIndex;
}
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.postWithToken('csrf', {
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'
});
console.error("Comrade save error:", err);
}
}
function stripDiacritics(str) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
if (mw.config.get("wgDBname") !== "enwiki" || mw.config.get("wgUserId") !== 19244234 || mw.config.get("wgNamespaceNumber") !== 0 || mw.config.get("wgAction") !== "view") {
return;
} {
$(document).ready(async function() {
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').click(() => performDeorphan());
$header.append($('<div>').append($('<strong>').text('Status:'), ` Article has ${backLinkCount} backlink(s).`, $btn));
$container.append($header);
appendDismiss($container);
}
if (cleanTitle !== currentTitle) checkAndRenderRedirect(cleanTitle, currentTitle, "R to diacritic", "Diacritic");
checkChemicalRedirect(wikitext, currentTitle);
checkDomainRedirect(qid, currentTitle);
if (qid) {
const wikiData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: qid,
props: 'claims|labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
const entity = wikiData.entities[qid];
const isHuman = entity?.claims?.P31?.some(c => c.mainsnak?.datavalue?.value?.id === "Q5");
const hasShortDesc = /\{\{\s*[Ss]hort description/i.test(wikitext);
const dsMatch = wikitext.match(/\{\{(?:DEFAULTSORT|Defaultsort):([^}]+)\}\}/i);
let targetSortName = "";
if (dsMatch && isHuman) {
const currentDS = dsMatch[1].trim();
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
const primarySurname = hasFamilyNameHatnote ? nameParts[0] : (getSmartSortKey(tClean, entity).split(',')[0] || nameParts[nameParts.length - 1]);
targetSortName = currentDS;
const nameParts = tClean.split(' ');
if (currentDS.includes(',')) {
const dsSurname = currentDS.split(',')[0].trim();
if (stripDiacritics(dsSurname).toLowerCase() !== stripDiacritics(primarySurname).toLowerCase()) {
const suggestedSort = hasFamilyNameHatnote ? `${nameParts[0]}, ${nameParts.slice(1).join(' ')}` : getSmartSortKey(tClean, entity);
const $dsWarning = $('<div>').addClass('comrade-container comrade-warning');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Fix to: {{DEFAULTSORT:${suggestedSort}}}`).click(() => performSortCorrection(suggestedSort, "replace"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Appears swapped (Found "${dsSurname}", expected "${primarySurname}"). `, $btn));
$dsWarning.append($header);
appendDismiss($dsWarning);
}
}
const givenNameIds = entity.claims?.P735?.map(c => c.mainsnak.datavalue.value.id) || [];
let wdGivens = [];
if (givenNameIds.length > 0) {
const nameData = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: givenNameIds.join('|'),
props: 'labels',
languages: 'en',
format: 'json',
origin: '*'
},
dataType: 'json'
});
wdGivens = givenNameIds.map(id => nameData.entities[id]?.labels?.en?.value).filter(Boolean);
}
if (currentDS.includes(',')) {
let [lastPart, firstPart] = currentDS.split(',').map(s => s.trim());
const referenceName = entity.labels?.en?.value || currentTitle;
const fixCasing = (part) => {
return part.split(' ').map(word => {
const regex = new RegExp(`\\b${word}\\b`, 'i');
const match = referenceName.match(regex);
if (match) return match[0];
const partsOfTitle = referenceName.split(' ');
const closest = partsOfTitle.find(tWord => word.toLowerCase().startsWith(tWord.toLowerCase()) || tWord.toLowerCase().startsWith(word.toLowerCase()));
return closest || word;
}).join(' ');
};
if (firstPart.toLowerCase().endsWith(lastPart.toLowerCase())) {
firstPart = firstPart.substring(0, firstPart.length - lastPart.length).trim();
targetSortName = `${fixCasing(lastPart)}, ${fixCasing(firstPart)}`;
renderSortNudge(targetSortName, "Redundant surname in given name field (with case fix).");
} else if (wdGivens.length > 0 && lastPart.split(' ').length > 1) {
const lastParts = lastPart.split(' ');
const misplacedGiven = lastParts.find(part => wdGivens.some(gn => gn.toLowerCase() === part.toLowerCase()));
if (misplacedGiven) {
const newSurname = lastParts.filter(p => p !== misplacedGiven).join(' ');
const newGiven = `${misplacedGiven} ${firstPart}`.trim();
targetSortName = `${fixCasing(newSurname)}, ${fixCasing(newGiven)}`;
renderSortNudge(targetSortName, `Wikidata suggests "${misplacedGiven}" is a given name.`);
}
} else {
const cLast = fixCasing(lastPart);
const cFirst = fixCasing(firstPart);
if (cLast !== lastPart || cFirst !== firstPart) {
targetSortName = `${cLast}, ${cFirst}`;
renderSortNudge(targetSortName, "Case mismatch relative to article title/Wikidata.");
}
}
} else {
const parts = currentDS.split(' ');
if (parts.length >= 2) {
const last = parts.pop();
const first = parts.join(' ');
const ref = entity.labels?.en?.value || currentTitle;
const fix = (p) => p.split(' ').map(w => (ref.match(new RegExp(`\\b${w}\\b`, 'i')) || [w])[0]).join(' ');
targetSortName = `${fix(last)}, ${fix(first)}`;
renderSortNudge(targetSortName, 'Structure should be "Last, First".');
}
}
} else if (isHuman) {
// Logic for when DEFAULTSORT is missing entirely
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const nameParts = tClean.split(' ');
// Define primarySurname early so it can be used for name list filtering later
const primarySurname = hasFamilyNameHatnote ? nameParts[0] : nameParts[nameParts.length - 1];
if (nameParts.length >= 2) {
let suggestedSort;
if (hasFamilyNameHatnote) {
// If hatnote exists, title is already "Surname Givenname"
suggestedSort = `${nameParts[0]}, ${nameParts.slice(1).join(' ')}`;
} else {
// Use your helper function for Western/other names
suggestedSort = getSmartSortKey(tClean, entity);
}
// Use 'targetSortName' so the variable is available for the redirect logic later
targetSortName = suggestedSort;
const $dsMissing = $('<div>').addClass('comrade-container comrade-nudge');
const $header = $('<div>').addClass('comrade-header');
const $btn = $('<button>').text(`Add: {{DEFAULTSORT:${targetSortName}}}`).click(() => performSortCorrection(targetSortName, "add"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` Tag is missing. `, $btn));
$dsMissing.append($header);
appendDismiss($dsMissing);
}
}
// --- Long name extraction (using wikitext) ---
const leadLine = wikitext.split('\n').find(line => line.includes("'''"));
const boldMatch = leadLine ? leadLine.match(/'''(.+?)'''/) : null;
if (boldMatch && isHuman) {
// Clean nicknames in quotes "" and parentheses () and normalize spaces
let longName = boldMatch[1].replace(/\s*("[^"]*"|\([^)\n]*\))\s*/g, ' ').replace(/\s+/g, ' ').trim();
// Strip disambiguators from currentTitle for a fair length comparison
const baseTitle = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
if (longName.length > baseTitle.length) {
const nameParts = longName.split(' ');
if (nameParts.length > 1) {
// Check for family name hatnote to detect name order (e.g., Chinese, Korean)
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
let sortKey;
if (hasFamilyNameHatnote) {
// If hatnote exists, title is already "Surname Givenname", so sort as "Surname, Givenname"
sortKey = `${nameParts[0]}, ${nameParts.slice(1).join(' ')}`;
} else {
// Fallback to Wikidata-based smart logic for Western/Arabic/Other patterns
sortKey = getSmartSortKey(longName, entity);
}
// Format the special redirect wikitext for the long name
const rLongWikitext = `#REDIRECT [[${currentTitle}]]\n\n{{Redirect category shell|\n{{R from long name}}\n}}\n{{DEFAULTSORT:${sortKey}}}`;
// Check if redirect exists and render if missing
checkAndRenderRedirect(longName, currentTitle, null, "Long name", rLongWikitext);
}
}
}
// --- Sort name ---
if (isHuman && targetSortName) {
let targetPage = currentTitle;
let rTemplate = "R from sort name";
let labelSuffix = "";
// If the article has a parenthetical disambiguator, find basename
if (currentTitle.includes(' (')) {
targetPage = currentTitle.split(' (')[0];
rTemplate = "R from ambiguous sort name";
labelSuffix = " (ambiguous)";
}
let rCat = rTemplate;
if (targetSortName.includes(',')) {
const parts = targetSortName.split(',');
const lI = parts[0].trim().charAt(0).toUpperCase();
const fI = parts[1].trim().charAt(0).toUpperCase();
if (lI && fI) rCat = `${rTemplate}|${lI}|${fI}`;
}
// Only run checkAndRender once per person
checkAndRenderRedirect(targetSortName, targetPage, rCat, "Sort name" + labelSuffix);
}
// --- Name list checking via Wikidata P734/P735 ---
if (isHuman) {
const familyNameClaims = entity.claims?.P734 || [];
const givenNameClaims = entity.claims?.P735 || [];
const tClean = currentTitle.replace(/\s*\(.*?\)\s*/g, '').trim();
const getNameLabels = async (claims) => {
const ids = claims.map(c => c.mainsnak.datavalue.value.id);
if (ids.length === 0) return [];
const res = await $.ajax({
url: "https://www.wikidata.org/w/api.php",
data: {
action: 'wbgetentities',
ids: ids.join('|'),
props: 'labels',
languages: 'en',
languagefallback: true,
format: 'json',
origin: '*'
},
dataType: 'json'
});
return Object.values(res.entities).map(e => e.labels?.en?.value).filter(Boolean);
};
const [allFamilyNames, allGivenNames] = await Promise.all([
getNameLabels(familyNameClaims),
getNameLabels(givenNameClaims)
]);
const finalFamilyNames = new Set();
const finalGivenNames = new Set();
allFamilyNames.forEach(fn => {
if (tClean.toLowerCase().includes(fn.toLowerCase())) {
fn.split(' ').forEach(part => finalFamilyNames.add(part));
finalFamilyNames.add(fn);
}
});
allGivenNames.forEach(gn => {
if (tClean.toLowerCase().includes(gn.toLowerCase())) {
finalGivenNames.add(gn);
}
});
if (finalFamilyNames.size === 0 || finalGivenNames.size === 0) {
const titleParts = tClean.split(' ');
if (titleParts.length >= 2) {
if (finalFamilyNames.size === 0) finalFamilyNames.add(titleParts[titleParts.length - 1]);
if (finalGivenNames.size === 0) finalGivenNames.add(titleParts[0]);
}
}
const isOrphan = isOrphanTagged && backLinkCount < 1;
// Helper to probe for the best available list title by priority
const getPriorityTarget = async (name, type) => {
let titlesToTry = [];
if (type === 'surname') {
titlesToTry = [`List of people with surname ${name}`, `List of people with the Korean family name ${name}`, `List of people with the Chinese family name ${name}`, `List of people with the English surname ${name}`, `${name} (surname)`];
} else {
titlesToTry = [`List of people with given name ${name}`, `List of people named ${name}`, `${name} (given name)`];
}
// Only add the bare name as a fallback if it's not a common pronoun/short word
const stopList = ['he', 'she', 'it', 'they', 'an', 'a', 'the'];
if (name.length > 2 && !stopList.includes(name.toLowerCase())) {
titlesToTry.push(name);
}
const listRes = await api.get({
action: 'query',
titles: titlesToTry.join('|'),
formatversion: 2
});
const pages = listRes.query.pages;
// Return the first one that exists, following the priority in titlesToTry
return titlesToTry.find(t => {
const p = pages.find(pg => pg.title === t);
return p && !p.missing;
});
};
// Check given names
const hasFamilyNameHatnote = /\{\{family[ _]name[ _]hatnote/i.test(wikitext);
for (const name of finalGivenNames) {
// Skip if the name is serving as the primary surname to avoid pronoun/ambiguous name false positives
if (name.toLowerCase() === primarySurname.toLowerCase()) {
continue;
}
// Skip if this name is part of the primary surname to avoid false positives (e.g. Kim)
const titleParts = tClean.split(' ');
if (hasFamilyNameHatnote && name.toLowerCase() === titleParts[0].toLowerCase()) continue;
if (!hasFamilyNameHatnote && name.toLowerCase() === titleParts[titleParts.length - 1].toLowerCase()) continue;
const target = await getPriorityTarget(name, 'given');
if (target) {
await checkNameList(name, 'given name', currentTitle, hasShortDesc, isOrphan, target);
}
}
// Check family names
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);
}
});
}
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}`).click(() => performSortCorrection(target, "format"));
$header.append($('<div>').append($('<strong>').text('DEFAULTSORT:'), ` ${reason} `, $btn));
$dsNudge.append($header);
appendDismiss($dsNudge);
}
})();
ecivf3b8bqhlc7um4apebo6swo7c0mz
Wikipedia:Sandbox
4
107092
737782
737731
2026-04-12T21:00:22Z
Cewbot
33876
Clear the sandbox. If you want to keep it longer, please test it in [[Special:MyPage/Sandbox|personal sandbox]], you can also check the revision history of the sandbox.
737782
wikitext
text/x-wiki
<noinclude>{{Sandbox}}</noinclude>
== Please start your testing below this line ==
9v37rcaxoiwjar8n3q9n7dcsjdvcyin
Translate test/fr
0
113093
737779
737617
2026-04-12T20:35:36Z
FuzzyBot
18251
Updating to match new version of source page
737779
wikitext
text/x-wiki
<languages/>
{{int:Project-localized-name-metawiki}}
{{TNT|Hubs|banner|dev=y|admin=y}}
[[File:MediaWiki-Manual bookstyle-transparent.png|{{dir|{{pagelang}}|left|right}}|175px|Documentation de MediaWiki]]
Vous êtes dans un '''manuel technique pour le logiciel MediaWiki'''.
Celui-ci contient des informations pour les '''développeurs''' et les '''administrateurs système''' concernant l’'''installation''', la '''gestion''' et le '''développement''' du logiciel MediaWiki.
<div lang="en" dir="ltr" class="mw-content-ltr">
This manual is '''not for end users''' of MediaWiki.
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
If you are looking for '''documentation to help you use MediaWiki, read the [[Special:MyLanguage/Help:Contents|MediaWiki Handbook]].'''
</div>
<span id="Main_sections"></span>
==Rubriques principales==
{{TNT|merge|Sysadmin hub|Developer hub}}
{| style="background:transparent;"
| style="width:50%; vertical-align:top; border:1px solid #aaa; padding: .5em 1.5em;" |
<div lang="en" dir="ltr" class="mw-content-ltr">
=== For system administrators ===
</div>
; [[Special:MyLanguage/Manual:Installation guide|<div lang="en" dir="ltr" class="mw-content-ltr">
Installation
</div>]]
: <div lang="en" dir="ltr" class="mw-content-ltr">
Guide to setting up a new MediaWiki installation.
</div>
: {{ll|Download}} | [[Special:MyLanguage/Manual:Installing MediaWiki|<div lang="en" dir="ltr" class="mw-content-ltr">
Installing
</div>]] | [[Special:MyLanguage/Manual:Configuring MediaWiki|<div lang="en" dir="ltr" class="mw-content-ltr">
Initial configuration
</div>]]
: [[Special:MyLanguage/Manual:Configuration settings (alphabetical)|<div lang="en" dir="ltr" class="mw-content-ltr">
Alphabetical list of settings
</div>]] | [[Special:MyLanguage/Manual:Configuration settings|<div lang="en" dir="ltr" class="mw-content-ltr">
Settings listed by function
</div>]]
; {{ll|Manual:System administration|nsp=0}}
: <div lang="en" dir="ltr" class="mw-content-ltr">
Guide to do administrative tasks on your wiki.
</div>
: [[Special:MyLanguage/Manual:Backing up a wiki|<div lang="en" dir="ltr" class="mw-content-ltr">
Backing up
</div>]] | {{ll|Manual:Maintenance scripts|nsp=0}}
; {{ll|Manual:Upgrading|nsp=0}}
: <div lang="en" dir="ltr" class="mw-content-ltr">
Guide to upgrade your MediaWiki installation.
</div>
''<div lang="en" dir="ltr" class="mw-content-ltr">
More on {{ll|Sysadmin hub}}.
</div>''
| style="width:50%; vertical-align:top; border:1px solid #aaa; padding: .5em 1.5em;" |
<div lang="en" dir="ltr" class="mw-content-ltr">
=== For developers ===
</div>
; <div lang="en" dir="ltr" class="mw-content-ltr">
Architecture
</div>
: <div lang="en" dir="ltr" class="mw-content-ltr">
An overview of the key parts of MediaWiki's source code.
</div>
: {{ll|Manual:Code|nsp=0}} | {{ll|Manual:Global object variables|nsp=0}} | [https://doc.wikimedia.org/ <div lang="en" dir="ltr" class="mw-content-ltr">
Doxygen-generated documentation
</div>]
; {{ll|Manual:Database layout|nsp=0}}
: <div lang="en" dir="ltr" class="mw-content-ltr">
Details about the database architecture used by MediaWiki.
</div>
: <div lang="en" dir="ltr" class="mw-content-ltr">
{{ll|Manual:MySQL|nsp=0}} | {{ll|Manual:PostgreSQL|nsp=0}} | {{ll|Manual:SQLite|nsp=0}} | {{ll|Manual:IBM DB2|nsp=0}} engines
</div>
; {{ll|Manual:Developing extensions|nsp=0}}
: <div lang="en" dir="ltr" class="mw-content-ltr">
An overview of the ways to create a new MediaWiki extension.
</div>
: {{ll|Manual:Hooks|nsp=0}} | {{ll|Manual:Parser functions|nsp=0}} | [[Special:MyLanguage/Manual:Tag extensions|<div lang="en" dir="ltr" class="mw-content-ltr">
Tag
</div>]] | [[Special:MyLanguage/Manual:Special pages|<div lang="en" dir="ltr" class="mw-content-ltr">
Special page
</div>]] | {{ll|Manual:Skins|nsp=0}}
; <div lang="en" dir="ltr" class="mw-content-ltr">
Web access
</div>
: <div lang="en" dir="ltr" class="mw-content-ltr">
Details about [[w:Query string|query string]] parameters that can be passed to MediaWiki access scripts.
</div>
: {{ll|Manual:Parameters to index.php|index.php}} | {{ll|API:Main page|api.php}}
''<div lang="en" dir="ltr" class="mw-content-ltr">
More on {{ll|Developer hub}}.
</div>''
|}
<div lang="en" dir="ltr" class="mw-content-ltr">
=== Others ===
</div>
; [[Special:MyLanguage/Manual:FAQ|<div lang="en" dir="ltr" class="mw-content-ltr">
MediaWiki FAQ
</div>]]
: <div lang="en" dir="ltr" class="mw-content-ltr">
Frequently asked questions about MediaWiki.
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
== Browsing the manual ==
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
There are multiple ways to browse through the documentation.
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
Readers having trouble finding a particular topic in the section above may find the following ways of browsing to be helpful.
</div>
* [[Special:Allpages/Manual:]] - <div lang="en" dir="ltr" class="mw-content-ltr">
An automatically generated list of all pages in the Manual: namespace.
</div>
* [[:Category:Manual]] - <div lang="en" dir="ltr" class="mw-content-ltr">
the top-level Manual category
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
== Improving the manual ==
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
* There are still a lot of holes in this manual!
</div> <div lang="en" dir="ltr" class="mw-content-ltr">
See the [[Special:MyLanguage/Manual:Contents/To do|'to do' page]] for details.
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
* There is still content on http://meta.wikimedia.org which may need to be migrated.
</div> <div lang="en" dir="ltr" class="mw-content-ltr">
If you can't find information on a particular issue in this documentation, please visit [[meta:MediaWiki_FAQ]] and [[meta:Help:Contents]].
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
* '''[[Project:Manual]]''' is a place to discuss and co-ordinate the development of the Manual: namespace.
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
* See also [[Project:Current issues]].
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
== MediaWiki Virtual Library ==
</div>
<div lang="en" dir="ltr" class="mw-content-ltr">
* '''[[:Category:MediaWiki Virtual Library (MVL)|MediaWiki Virtual Library]] (MVL)''' has manuals, guides, and collections of selected articles in PDF form you create on-the-fly
</div>
[[File:Incubator-notext.png|alt=foobaz|thumb|foobar]]
{| style="background:transparent;"
| style="width:50%; vertical-align:top; border:1px solid #aaa; padding: .5em 1.5em;" |
=== For system administrators ===
; [[Special:MyLanguage/Manual:Installation guide|Installation]]
: Guide to setting up a new MediaWiki installation.
: {{ll|Download}} | [[Special:MyLanguage/Manual:Installing MediaWiki|
Installing]] | [[Special:MyLanguage/Manual:Configuring MediaWiki|
Initial configuration]]
: [[Special:MyLanguage/Manual:Configuration settings (alphabetical)|
Alphabetical list of settings]] | [[Special:MyLanguage/Manual:Configuration settings|
Settings listed by function]]
; {{ll|Manual:System administration|nsp=0}}
: Guide to do administrative tasks on your wiki.
: [[Special:MyLanguage/Manual:Backing up a wiki|Backing up]] | {{ll|Manual:Maintenance scripts|nsp=0}}
; {{ll|Manual:Upgrading|nsp=0}}
:
Guide to upgrade your MediaWiki installation.
''More on {{ll|Sysadmin hub}}.''
| style="width:50%; vertical-align:top; border:1px solid #aaa; padding: .5em 1.5em;" |
=== For developers ===
; Architecture
: An overview of the key parts of MediaWiki's source code.
: {{ll|Manual:Code|nsp=0}} | {{ll|Manual:Global object variables|nsp=0}} | [https://doc.wikimedia.org/
Doxygen-generated documentation]
; {{ll|Manual:Database layout|nsp=0}}
: Details about the database architecture used by MediaWiki.
: {{ll|Manual:MySQL|nsp=0}} | {{ll|Manual:PostgreSQL|nsp=0}} | {{ll|Manual:SQLite|nsp=0}} | {{ll|Manual:IBM DB2|nsp=0}} engines
; {{ll|Manual:Developing extensions|nsp=0}}
: An overview of the ways to create a new MediaWiki extension.
: {{ll|Manual:Hooks|nsp=0}} | {{ll|Manual:Parser functions|nsp=0}} | [[Special:MyLanguage/Manual:Tag extensions|Tag]] | [[Special:MyLanguage/Manual:Special pages|Special page]] | {{ll|Manual:Skins|nsp=0}}
; Web access
: Details about [[w:Query string|query string]] parameters that can be passed to MediaWiki access scripts.
: {{ll|Manual:Parameters to index.php|index.php}} | {{ll|API:Main page|api.php}}
''More on {{ll|Developer hub}}.''
|}
[[File:Soldering a 0805.jpg|alt=foobaz|thumb|foobar]]
[[Category:MediaWiki technical documentation{{translation}}| ]]
[[Category:Manual{{translation}}| ]]
oacqtekyok8uwvfuvxrcqog4kz5jw8s
User:MrJaroslavik/global.js
2
118543
737763
736822
2026-04-12T17:37:13Z
MrJaroslavik
44012
test
737763
javascript
text/javascript
// GLOBAL - FOR ALL WIKIS START //
//-------------------------------------------------------------//
//-------------------------------------------------------------//
// CheckUserLogCount.js - https://test.wikipedia.org/w/index.php?title=User:MrJaroslavik/CheckUserLogCount.js
mw.loader.load('https://test.wikipedia.org/w/index.php?title=User:MrJaroslavik/CheckUserLogCount.js&action=raw&ctype=text/javascript');
// SuppressionLogCount.js - https://test.wikipedia.org/w/index.php?title=User:MrJaroslavik/SuppressionLogCount.js
mw.loader.load('https://test.wikipedia.org/w/index.php?title=User:MrJaroslavik/SuppressionLogCount.js&action=raw&ctype=text/javascript');
// GlobalCheckUserStats.js - https://test.wikipedia.org/w/index.php?title=User:MrJaroslavik/GlobalCheckUserStats.js
mw.loader.load('https://test.wikipedia.org/w/index.php?title=User:MrJaroslavik/GlobalCheckUserStats.js&action=raw&ctype=text/javascript');
// GlobalSuppressionStats.js - https://test.wikipedia.org/w/index.php?title=User:MrJaroslavik/GlobalSuppressionStats.js
mw.loader.load('https://test.wikipedia.org/w/index.php?title=User:MrJaroslavik/GlobalSuppressionStats.js&action=raw&ctype=text/javascript');
// MarkAdmins.js - https://test.wikipedia.org/w/index.php?title=User:MrJaroslavik/MarkAdmins.js
mw.loader.load('https://test.wikipedia.org/w/index.php?title=User:MrJaroslavik/MarkAdmins.js&action=raw&ctype=text/javascript');
// userinfo.js - https://test.wikipedia.org/wiki/User:MrJaroslavik/userinfo.js
if ([2, 3].indexOf(mw.config.get('wgNamespaceNumber')) !== -1 && mw.config.get('wgAction') === 'view') {
mw.loader.load('https://test.wikipedia.org/w/index.php?title=User:MrJaroslavik/userinfo.js&action=raw&ctype=text/javascript');
}
// ToggleSidebar.js - https://en.wikipedia.org/w/index.php?title=User:BrandonXLF/ToggleSidebar.js
mw.loader.load('https://en.wikipedia.org/w/index.php?title=User:BrandonXLF/ToggleSidebar.js&action=raw&ctype=text/javascript');
// NeverUseMobileVersion.js - https://en.wikipedia.org/w/index.php?title=User:%C3%9Ejarkur/NeverUseMobileVersion.js
mw.loader.load('https://en.wikipedia.org/w/index.php?title=User:%C3%9Ejarkur/NeverUseMobileVersion.js&action=raw&ctype=text/javascript');
// REDIRECT TO META IF ACCESSING LOCAL CENTRALAUTH PAGE
var relUser = mw.config.get('wgRelevantUserName');
if (mw.config.get('wgCanonicalSpecialPageName') == 'CentralAuth' && mw.config.get('wgDBname') != 'metawiki' && relUser) {
location.href = 'https://meta.wikimedia.org/wiki/Special:CentralAuth/' + encodeURIComponent(relUser);
}
mw.config.get('wgCanonicalSpecialPageName') === 'CentralAuth' &&
$(function sortCentralAuthByEditCount() {
let th = document.querySelector('.mw-centralauth-wikislist th:nth-child(6)');
if (!th) return;
let sort = () => {
if (!th.classList.contains('headerSort')) return;
for (let i = 0; i < 3; i++) {
if (th.classList.contains('headerSortDown')) return true;
th.click();
}
};
if (sort()) return;
let observer = new MutationObserver(() => {
sort() && observer.disconnect();
});
observer.observe(th, { attributes: true });
let $table = $('.mw-centralauth-wikislist');
if ($table.hasClass('jquery-tablesorter')) return;
mw.loader.using('jquery.tablesorter', () => {
$table.tablesorter();
});
});
// Change wiki logo link to Recent Changes
$(function() {
$('.mw-wiki-logo, .mw-logo').attr('href', mw.util.getUrl('Special:RecentChanges'));
});
// CustomSidebar.js - https://test.wikipedia.org/wiki/User:MrJaroslavik/CustomSidebar.js
mw.loader.load('https://test.wikipedia.org/w/index.php?title=User:MrJaroslavik/CustomSidebar.js&action=raw&ctype=text/javascript');
//-------------------------------------------------------------//
//-------------------------------------------------------------//
// GLOBAL - FOR ALL WIKIS END //
aqx75ynurru3pvpqjt3tjtogjx0by4m
Triton (moon)
0
124149
737795
734072
2026-04-13T00:03:32Z
InternetArchiveBot
34092
Rescuing 1 sources and tagging 0 as dead.) #IABot (v2.0.9.5
737795
wikitext
text/x-wiki
{{Short description|Largest moon of Neptune}}
{{Distinguish|Titan (moon)}}
{{Featured article}}
{{Use mdy dates|date=November 2014}}
{{Infobox planet
| name = Triton
| note = yes
| mpc_name = Neptune I
| pronounced = {{IPAc-en|ˈ|t|r|aɪ|t|ən}}
| adjectives = Tritonian<!--also used for Lake Triton, which per Lewis & Short is the same root--> ({{IPAc-en|t|r|aɪ|ˈ|t|oʊ|n|i|ə|n}})<ref>Robert Graves (1945) ''Hercules, My Shipmate''</ref>
| named_after = [[Triton (mythology)|Τρίτων]] ''Trītōn''
| image = Triton moon mosaic Voyager 2 (large).jpg
| image_size = 300px
| caption = {{longitem|''[[Voyager 2]]'' photomosaic of Triton's sub-Neptunian hemisphere{{refn | Photomosaic of Triton's sub-Neptunian hemisphere. The bright, slightly pinkish, south polar cap at bottom is composed of nitrogen and methane ice and is streaked by dust deposits left by nitrogen gas geysers. The mostly darker region above it includes Triton's "cantaloupe terrain" and cryovolcanic and tectonic features. Near the lower right limb are several dark maculae ("strange spots").| group = caption}}|style=padding: 4px 0;}}
| discoverer = [[William Lassell]]
| discovered = October 10, 1846
| semimajor = {{val|354759|u=km|fmt=commas}}
| eccentricity = {{val|0.000016|fmt=none}}<ref name="neptuniansatfact"/>
| period = {{val|5.876854|u=d|fmt=none}}<br/>([[Retrograde and direct motion|retrograde]])<ref name="neptuniansatfact"/><ref name="NYT-20141105-DO"/>
| avg_speed = 4.39 km/s{{efn|name=calculated|Calculated on the basis of other parameters.}}
| inclination = 129.812° (to the [[ecliptic]])<br/>156.885° (to Neptune's equator)<ref name="JPL-SSD-Neptune"/><ref name="Jacobson2009-AJ"/><br/>129.608° (to Neptune's orbit)
| satellite_of = [[Neptune]]
| mean_radius = {{val|1353.4|0.9|u=km|fmt=commas}}<ref name="JPL-SSD-sat_phys"/> ({{Earth radius|0.2122}})
| surface_area = {{val|23018000|u=km2|fmt=commas}}<ref name="nSurfaceArea" group="lower-alpha"/>
| volume = {{val|10384000000|u=km3|fmt=commas}}<ref name="nVolume" group="lower-alpha"/>
| mass = {{val|2.1390|0.0028|e=22|u=kg}}<br/>({{val|0.00359|u=Earths|fmt=none}})<ref name="nMass" group="lower-alpha"/>
| density = {{val|2.061|u=g/cm3}}<ref name="JPL-SSD-sat_phys"/>
| surface_grav = {{val|0.779|ul=m/s2}} ({{val|0.0794|u=[[G-force|''g'']]}}) (0.48 Moons)<ref name="nSurfaceGravity" group="lower-alpha"/>
| escape_velocity = {{val|1.455|u=km/s}}<ref name="nEscapeVelocity" group="lower-alpha"/>
| sidereal_day = 5 d, 21 h, 2 min, 53 s<ref name="EncycSolSys-Triton"/>
| rotation = [[Synchronous rotation|synchronous]]
| axial_tilt = 0 <ref name="AxialTilt" group="lower-alpha"/>
| albedo = 0.76<ref name="JPL-SSD-sat_phys"/>
| magnitude = 13.47<ref name="magnitude"/>
| abs_magnitude = −1.2<ref name="Fischer2006"/>
| single_temperature = {{cvt|38|K|C}}<ref name="EncycSolSys-Triton"/>
| atmosphere = yes
| surface_pressure = {{convert|1.4 to 1.9|Pa|atm|sigfig=3|abbr=on}}<ref name="EncycSolSys-Triton"/><ref name="solarsystemexploration"/>
| atmosphere_composition = [[nitrogen]]; [[methane]] traces<ref name="nature2"/>
}}
'''Triton''' is the largest [[natural satellite]] of the [[planet]] [[Neptune]], and was the first [[Moons of Neptune|Neptunian moon]] to be discovered, on October 10, 1846, by [[English people|English]] [[astronomer]] [[William Lassell]]. It is the only large moon in the [[Solar System]] with a [[Retrograde and prograde motion|retrograde orbit]], an orbit in the direction opposite to its planet's rotation.<ref name="NYT-20141105-DO"/><ref name="NYT-20141018-KC">{{cite news |last=Chang |first=Kenneth |title=Dark Spots in Our Knowledge of Neptune |url=https://www.nytimes.com/2014/08/19/science/dark-spots-in-our-knowledge-of-neptune.html |date=18 October 2014 |work= New York Times |access-date=21 October 2014 }}</ref> Because of its retrograde orbit and composition similar to Pluto, Triton is thought to have been a [[dwarf planet]], captured from the [[Kuiper belt]].<ref name="Agnor06"/>
At {{convert|2710|km}}<ref name="JPL-SSD-sat_phys"/> in diameter, it is the [[List of moons by diameter|seventh-largest moon]] in the Solar System, the only satellite of Neptune massive enough to be in [[hydrostatic equilibrium]], the second-largest planetary moon in relation to its primary (after Earth's [[Moon]]), and larger than [[Pluto]]. Triton is one of the few moons in the Solar System known to be geologically active (the others being [[Jupiter]]'s [[Io (moon)|Io]] and [[Europa (moon)|Europa]], and [[Saturn]]'s [[Enceladus (moon)|Enceladus]] and [[Titan (moon)|Titan]]). As a consequence, its surface is relatively young, with few obvious [[impact crater]]s. Intricate [[Cryovolcano|cryovolcanic]] and [[Tectonics|tectonic]] terrains suggest a complex geological history.
== Discovery and naming ==
[[File:William Lassell.jpg|thumb|left|upright|140px|William Lassell, the discoverer of Triton]]
Triton was discovered by British [[astronomer]] [[William Lassell]] on October 10, 1846,<ref name="LassellDiscovery"/> just 17 days after the [[discovery of Neptune]]. When [[John Herschel]] received news of Neptune's discovery, he wrote to Lassell suggesting he search for possible moons. Lassell did so and discovered Triton eight days later.<ref name="LassellDiscovery"/><ref name="Lassell refs"/><!-- Vol. 7, No. 16 is June 11, Vol. 8, No. 1 is November 12; Vol. 7, No. 17 was a special issue published "during the vacation" --> Lassell also claimed for a period{{efn|Lassell rejected his previous claim of discovery when he found that the orientation of the supposed rings changed when he rotated his telescope tube; see p. 9 of Smith & Baum, 1984.<ref name="Smithetal1984"/>}} to have discovered rings.<ref name="Smithetal1984"/> Although Neptune was later confirmed to [[Rings of Neptune|have rings]], they are so faint and dark that it is not plausible he actually saw them. A brewer by trade, Lassell spotted Triton with his self-built ~{{cvt|61|cm}} [[aperture]] metal mirror reflecting telescope (also known as the "two-foot" reflector).<ref name=":0">{{Cite web|url=http://www.royalobservatorygreenwich.org/articles.php?article=1046|title=The Royal Observatory Greenwich – where east meets west: Telescope: The Lassell 2-foot Reflector (1847)|website=www.royalobservatorygreenwich.org|access-date=2019-11-28}}</ref> This telescope was later donated to the [[Royal Observatory, Greenwich]] in the 1880s, but was eventually dismantled.<ref name=":0"/>
Triton is named after the Greek sea god [[Triton (mythology)|Triton]] (Τρίτων), the son of [[Poseidon]] (the Greek god corresponding to the Roman [[Neptune (mythology)|Neptune]]). The name was first proposed by [[Camille Flammarion]] in his 1880 book ''Astronomie Populaire'',<ref name="Flammarion1880"/> and was officially adopted many decades later.<ref name="PMoore"/> Until the discovery of the second moon [[Nereid (moon)|Nereid]] in 1949, Triton was commonly referred to as "the satellite of Neptune". Lassell did not name his own discovery; he later successfully suggested the name [[Hyperion (moon)|Hyperion]], previously chosen by [[John Herschel]], for the eighth moon of [[Saturn]] when he discovered it.<ref name="IAU-solarsysNames"/>
== Orbit and rotation ==
[[File:Triton orbit & Neptune.png|300px|thumb|The orbit of Triton (red) is [[Retrograde and direct motion|opposite in direction]] and [[orbital inclination|tilted −23°]] compared to a typical [[Moons of Neptune|moon]]'s orbit (green) in the plane of Neptune's equator.]]
Triton is unique among all large moons in the Solar System for its [[Retrograde and direct motion|retrograde orbit]] around its planet (i.e. it orbits in a direction opposite to the planet's rotation). Most of the outer [[irregular moon]]s of [[Jupiter]] and [[Saturn]] also have retrograde orbits, as do some of [[Uranus]]'s outer moons. However, these moons are all much more distant from their primaries, and are small in comparison; the largest of them ([[Phoebe (moon)|Phoebe]])<ref name="nLargest" group="lower-alpha"/> has only 8% of the diameter (and 0.03% of the mass) of Triton.
Triton's orbit is associated with two tilts, the [[obliquity]] of Neptune's rotation to Neptune's orbit, 30°, and the inclination of Triton's orbit to Neptune's rotation, 157° (an inclination over 90° indicates retrograde motion). Triton's orbit precesses forward relative to Neptune's rotation with a period of about 678 Earth years (4.1 Neptunian years),<ref name="JPL-SSD-Neptune"/><ref name="Jacobson2009-AJ"/> making its Neptune-orbit-relative inclination vary between 127° and 173°. That inclination is currently 130°; Triton's orbit is now near its maximum departure from coplanarity with Neptune's.
Triton's rotation is [[tidal locking|tidally locked]] to be synchronous with its orbit around Neptune: it keeps one face oriented toward the planet at all times. Its equator is almost exactly aligned with its orbital plane.<ref name="Davies1991-ControlNetwork"/> At the present time, Triton's rotational axis is about 40° from Neptune's [[Orbital plane (astronomy)|orbital plane]], and hence at some point during Neptune's year each pole points fairly close to the Sun, almost like the poles of Uranus. As Neptune orbits the Sun, Triton's polar regions take turns facing the Sun, resulting in seasonal changes as one pole, then the other, moves into the sunlight. Such changes were observed in 2010.<ref name="SpaceCom-TritonSeasons"/>
Triton's revolution around Neptune has become a nearly perfect circle with an [[Orbital eccentricity|eccentricity]] of almost zero. [[Viscoelasticity|Viscoelastic]] damping from tides alone is not thought to be capable of [[tidal circularization|circularizing]] Triton's orbit in the time since the origin of the system, and [[Drag (physics)|gas drag]] from a [[Retrograde and prograde motion|prograde]] debris disc is likely to have played a substantial role.<ref name="JPL-SSD-Neptune"/><ref name="Jacobson2009-AJ"/> [[Tidal deceleration|Tidal interactions]] also cause Triton's orbit, which is already closer to Neptune than the [[Moon]] is to Earth, to gradually decay further; predictions are that 3.6 billion years from now, Triton will pass within Neptune's [[Roche limit]].<ref name="Chyba"/> This will result in either a collision with Neptune's atmosphere or the breakup of Triton, forming a new [[planetary ring|ring]] system similar to that found around [[Saturn]].<ref name="Chyba"/>
== Capture ==
[[File:Triton Rotation Movie.gif|thumb|Animation of Triton]]
[[File:Outersolarsystem objectpositions labels comp.png|thumb|left|The [[Kuiper belt]] (green), in the Solar System's outskirts, is where Triton is thought to have originated.]]
Moons in retrograde orbits cannot form in the same region of the [[solar nebula]] as the planets they orbit, so Triton must have been captured from elsewhere. It might therefore have originated in the [[Kuiper belt]],<ref name="Agnor06"/> a ring of small icy objects extending from just inside the orbit of Neptune to about 50 [[astronomical unit|AU]] from the Sun. Thought to be the point of origin for the majority of short-period [[comet]]s observed from Earth, the belt is also home to several large, planet-like bodies including [[Pluto]], which is now recognized as the largest in a population of Kuiper belt objects (the [[plutino]]s) [[orbital resonance#Pluto resonances|locked in resonant orbits]] with Neptune. Triton is only slightly larger than Pluto and nearly identical in composition, which has led to the hypothesis that the two share a common origin.<ref name="Cruikshank2004"/>
The proposed capture of Triton may explain several features of the Neptunian system, including the extremely [[Orbital eccentricity|eccentric orbit]] of Neptune's moon [[Nereid (moon)|Nereid]] and the scarcity of moons as compared to the other [[giant planet]]s. Triton's initially eccentric orbit would have intersected orbits of irregular moons and [[Perturbation (astronomy)|disrupted]] those of smaller regular moons, dispersing them through [[gravitation]]al interactions.<ref name="JPL-SSD-Neptune"/><ref name="Jacobson2009-AJ"/>
Triton's eccentric post-capture orbit would have also resulted in [[tidal heating]] of its interior, which could have kept Triton fluid for a billion years; this inference is supported by evidence of differentiation in Triton's interior. This source of internal heat disappeared following tidal locking and circularization of the orbit.<ref name="Ross1990"/>
Two types of mechanisms have been proposed for Triton's capture. To be gravitationally captured by a planet, a passing body must lose sufficient energy to be slowed down to a speed less than that required to escape.<ref name="EncycSolSys-Triton"/> An early theory of how Triton may have been slowed was by collision with another object, either one that happened to be passing by Neptune (which is unlikely), or a moon or proto-moon in orbit around Neptune (which is more likely).<ref name="EncycSolSys-Triton"/> A more recent hypothesis suggests that, before its capture, Triton was part of a binary system. When this binary encountered Neptune, it interacted in such a way that the binary dissociated, with one portion of the binary expelled, and the other, Triton, becoming bound to Neptune. This event is more likely for more massive companions.<ref name="Agnor06"/> Similar mechanisms have been proposed for the capture of [[moons of Mars|Mars's moons]].<ref>[https://ntrs.nasa.gov/archive/nasa/casi.ntrs.nasa.gov/20020038729.pdf "Origin of Martian Moons from Binary Asteroid Dissociation"], AAAS – 57725, American Association for Advancement of Science Annual Meeting 2002</ref> This hypothesis is supported by several lines of evidence, including binaries being very common among the large Kuiper belt objects.<ref name="IOPorg-KuiperObjectBinaries"/><ref name="Jewitt2005"/> The event was brief but gentle, saving Triton from collisional disruption. Events like this may have been common during the formation of Neptune, or later when it [[Planetary migration|migrated outward]].<ref name="Agnor06"/>
However, simulations in 2017 showed that after Triton's capture, and before its orbital eccentricity decreased, it probably did collide with at least one other moon, and caused collisions between other moons.<ref>{{cite journal|last1=Raluca Rufu and [[Robin Canup]]|title=Triton's evolution with a primordial Neptunian satellite system|journal=The Astronomical Journal|volume=154|issue=5|pages=208|arxiv=1711.01581|date=Nov 5, 2017|doi=10.3847/1538-3881/aa9184|pmid=31019331|pmc=6476549|bibcode=2017AJ....154..208R}}</ref><ref>{{cite journal|title=Triton crashed into Neptune's moons|journal=New Scientist|date=Nov 18, 2017|volume=236|issue=3152|page=16|doi=10.1016/S0262-4079(17)32247-9|bibcode=2017NewSc.236...16.|url=https://www.newscientist.com/article/mg23631521-900-neptunes-other-moons-were-normal-until-triton-crashed-the-party}}</ref>
== Physical characteristics ==
{{multiple image
|direction = vertical
|align = right
|width = 238
|image1=Masa_de_triton.svg
|image2=Triton, Earth & Moon size comparison.jpg
|caption1=Triton dominates the Neptunian moon system, with over 99.5% of its total mass. This imbalance may reflect the elimination of many of Neptune's original satellites following Triton's capture.<ref name="JPL-SSD-Neptune"/><ref name="Jacobson2009-AJ"/>
|caption2=Triton (''lower left'') compared to the Moon (''upper left'') and Earth (''right''), to scale
}}
Triton is the seventh-largest moon and [[List of Solar System objects by size|sixteenth-largest object]] in the Solar System, and is modestly larger than the [[dwarf planet]]s [[Pluto]] and [[Eris (dwarf planet)|Eris]]. It comprises more than 99.5% of all the mass known to orbit Neptune, including the planet's rings and thirteen other known moons,<ref name="nMassTriton" group="lower-alpha"/> and is also more massive than all known moons in the Solar System smaller than itself combined.<ref name="nMassOthers" group="lower-alpha"/> Also, with a diameter 5.5% that of Neptune, it is the largest moon of a gas giant relative to its planet in terms of diameter, although Titan is bigger relative to Saturn in terms of mass. It has a radius, density (2.061 g/cm<sup>3</sup>), temperature and chemical composition similar to that of [[Pluto]].<ref name="voyager"/>
Triton's surface is covered with a transparent layer of [[annealing (metallurgy)|annealed]] [[solid nitrogen|frozen nitrogen]]. Only 40% of Triton's surface has been observed and studied, but it is possible that it is entirely covered in such a thin sheet of nitrogen ice. Like Pluto's, Triton's crust consists of 55% nitrogen ice with other ices mixed in. [[Water]] ice comprises 15–35% and frozen [[carbon dioxide]] ([[dry ice]]) the remaining 10–20%. Trace ices include 0.1% [[methane]] and 0.05% [[carbon monoxide]].<ref name="EncycSolSys-Triton"/> There could also be [[ammonia]] ice on the surface, as there are indications of ammonia [[hydrate|dihydrate]] in the [[lithosphere]].<ref name="ammonia"/> Triton's mean density implies that it probably consists of about 30–45% [[ice|water ice]] (including relatively small amounts of volatile ices), with the remainder being rocky material.<ref name="EncycSolSys-Triton"/> Triton's surface area is 23 million km<sup>2</sup>, which is 4.5% of [[Earth]], or 15.5% of Earth's land area. Triton has a considerably and unusually high [[albedo]], reflecting 60–95% of the sunlight that reaches it, and it has changed only slightly since the first observations. By comparison, the Moon reflects only 11%.<ref name="Medkeff2002-LunarAlbedo"/> Triton's reddish colour is thought to be the result of methane ice, which is converted to [[tholin]]s under exposure to [[ultraviolet]] radiation.<ref name="EncycSolSys-Triton"/><ref name="Grundy"/>
Because Triton's surface indicates a long history of melting, models of its interior posit that Triton is differentiated, like [[Earth]], into a solid [[core (geology)|core]], a [[mantle (geology)|mantle]] and a [[crust (geology)|crust]]. [[Water]], the most abundant [[Volatiles|volatile]] in the Solar System, comprises Triton's mantle, enveloping a core of rock and metal. There is enough rock in Triton's interior for [[radioactive decay]] to maintain a liquid [[subsurface ocean]] to this day, similar to what is thought to exist beneath the surface of [[Europa (moon)|Europa]] and a number of other icy outer Solar System worlds.<ref name="EncycSolSys-Triton"/><ref name="Hussman2006"/><ref name='Sci Am 2017'>{{cite web |url=https://www.scientificamerican.com/article/overlooked-ocean-worlds-fill-the-outer-solar-system/ |title=Overlooked Ocean Worlds Fill the Outer Solar System |first=John |last=Wenz |work=Scientific American |date=4 October 2017}}</ref><ref name="Nimmo2015">{{cite journal |last1=Nimmo |first1=Francis |title=Powering Triton's recent geological activity by obliquity tides: Implications for Pluto geology |journal=Icarus |date=15 January 2015 |volume=246 |pages=2–10 |doi= 10.1016/j.icarus.2014.01.044 |bibcode=2015Icar..246....2N |url=https://escholarship.org/content/qt99s8t6zm/qt99s8t6zm.pdf?t=nnm476 }}</ref> This is not thought to be adequate to power convection in Triton's icy crust. However, the strong [[obliquity]] [[Tidal Heating|tides]] are believed to generate enough additional heat to accomplish this and produce the observed signs of recent surface geological activity.<ref name="Nimmo2015"/> The black material ejected is suspected to contain [[organic compound]]s,<ref name="Sci Am 2017"/> and if liquid water is present on Triton, it has been speculated that this could make it [[Planetary habitability|habitable]] for some form of life.<ref name='Sci Am 2017'/><ref name="Irwin2001-Plausibility"/><ref name="space.com">{{cite web | url=http://www.space.com/17470-neptune-moon-triton-subsurface-ocean.html | title=Does Neptune's moon Triton have a subsurface ocean? | work=Space.com | date=September 6, 2012 | access-date=September 18, 2015 | author=Doyle, Amanda}}</ref>
== Atmosphere ==
{{Main|Atmosphere of Triton}}
[[File:Triton (artist's impression).jpg|thumb|left|Artist's impression of Triton, showing its tenuous atmosphere just over the limb.]]
Triton has a tenuous [[nitrogen]] atmosphere, with trace amounts of carbon monoxide and small amounts of methane near its surface.<ref name="nature2"/><ref name="grand"/><ref name="Lellouch2010"/> Like [[Pluto]]'s atmosphere, the atmosphere of Triton is thought to have resulted from evaporation of nitrogen from its surface.<ref name="Cruikshank2004"/> Its surface temperature is at least {{cvt|35.6|K|C}} because Triton's nitrogen ice is in the warmer, hexagonal crystalline state, and the phase transition between hexagonal and cubic nitrogen ice occurs at that temperature.<ref name="Duxburyetal1993"/> An upper limit in the low 40s (K) can be set from vapor pressure equilibrium with nitrogen gas in Triton's atmosphere.<ref name="Tryka1993-Determination"/> This is colder than Pluto's average equilibrium temperature of {{cvt|44|K|C}}. Triton's surface atmospheric pressure is only about {{cvt|1.4–1.9|Pa|mbar|lk=on}}.<ref name="EncycSolSys-Triton"/>
[[File:Tritoncloud.jpg|thumb|Clouds observed above Triton's limb by ''Voyager 2''.]]
Turbulence at Triton's surface creates a [[troposphere]] (a "weather region") rising to an altitude of 8 km. Streaks on Triton's surface left by geyser plumes suggest that the troposphere is driven by seasonal winds capable of moving material of over a micrometre in size.<ref name="SmithSoderblom1989"/> Unlike other atmospheres, Triton's lacks a [[stratosphere]], and instead has a [[thermosphere]] from altitudes of 8 to 950 km, and an exosphere above that.<ref name="EncycSolSys-Triton"/> The temperature of Triton's upper atmosphere, at {{val|95|5|u=K}}, is higher than that at its surface, due to heat absorbed from solar radiation and Neptune's [[magnetosphere]].<ref name="nature2"/><ref name="Stevens1992-thermosphere"/> A haze permeates most of Triton's troposphere, thought to be composed largely of [[hydrocarbon]]s and [[nitrile]]s created by the action of sunlight on methane. Triton's atmosphere also has clouds of condensed nitrogen that lie between 1 and 3 km from its surface.<ref name="EncycSolSys-Triton"/>
In 1997, observations from [[Earth]] were made of Triton's limb as it [[occultation|passed in front of stars]]. These observations indicated the presence of a denser atmosphere than was deduced from ''[[Voyager 2]]'' data.<ref name="Hubblesite"/> Other observations have shown an increase in temperature by 5% from 1989 to 1998.<ref name="MIT Triton"/> These observations indicated Triton was approaching an unusually warm southern-hemisphere summer season that happens only once every few hundred years. Theories for this warming include a change of frost patterns on Triton's surface and a change in ice [[albedo]], which would allow more heat to be absorbed.<ref name="Scienceagogo.com"/> Another theory argues that the changes in temperature are a result of deposition of dark, red material from geological processes. Because Triton's [[Bond albedo]] is among the highest within the [[Solar System]], it is sensitive to small variations in spectral albedo.<ref name="Nature"/>
== Surface features ==
[[File:Geology of Triton.jpg|thumb|400px|Interpretative [[geomorphology|geomorphological]] map of Triton]]
All detailed knowledge of the surface of Triton was acquired from a distance of 40,000 km by the ''Voyager 2'' spacecraft during a single encounter in 1989.<ref name="Gray1989"/> The 40% of Triton's surface imaged by ''Voyager 2'' revealed blocky outcrops, ridges, troughs, furrows, hollows, plateaus, icy plains and few craters. Triton is relatively flat; its observed topography never varies beyond a kilometre.<ref name="EncycSolSys-Triton"/> The [[impact crater]]s observed are concentrated almost entirely in Triton's [[leading hemisphere]].<ref name = "Mah2019">{{cite journal |last1= Mah|first1= J.|last2= Brasser|first2= R.|title= The origin of the cratering asymmetry on Triton|journal= Monthly Notices of the Royal Astronomical Society|volume= 486|pages= 836–842|date= 2019|doi= 10.1093/mnras/stz851|arxiv= 1904.08073|s2cid= 118682572}}</ref> Analysis of crater density and distribution has suggested that in geological terms, Triton's surface is extremely young, with regions varying from an estimated 50 million years old to just an estimated 6 million years old.<ref name="surface age"/> Fifty-five percent of Triton's surface is covered with frozen nitrogen, with water ice comprising 15–35% and [[dry ice|frozen CO<sub>2</sub>]] forming the remaining 10–20%.<ref>{{cite news |last=Williams |first=Matt |url=https://www.universetoday.com/56042/triton/ |title=Neptune's Moon Triton |work=Universe Today |date=28 July 2015 |access-date=2017-09-26 }}</ref> The surface shows deposits of [[tholin]]s, organic chemical compounds that may be precursors to the [[Abiogenesis|origin of life]].<ref name="LPI Oleson">{{cite conference |last1=Oleson |first1=Steven R. |last2=Landis |first2=Geoffrey |title=Triton Hopper: Exploring Neptune's Captured Kuiper Belt Object |url=https://www.hou.usra.edu/meetings/V2050/pdf/8145.pdf |conference=Planetary Science Vision 2050 Workshop 2017 }}</ref>
=== Cryovolcanism ===
{{Further|Cryovolcano}}
One of the largest cryovolcanic features found on Triton is [[Leviathan Patera]],<ref>{{Cite journal | doi=10.1016/j.pss.2018.03.010| title=Heat flow in Triton: Implications for heat sources powering recent geologic activity| year=2018| last1=Martin-Herrero| first1=Alvaro| last2=Romeo| first2=Ignacio| last3=Ruiz| first3=Javier| journal=Planetary and Space Science| volume=160| pages=19–25| bibcode=2018P&SS..160...19M}}</ref> a caldera-like feature roughly 100 km in diameter seen near the equator. Surrounding this caldera is a volcanic dome that stretches for roughly 2,000 km along its longest axis, indicating that Leviathan is the second largest volcano in the solar system by area, after [[Alba Mons]]. This feature is also connected to two enormous cryolava lakes seen north-west of the caldera. Because the cryolava on Triton is believed to be primarily water ice with some ammonia, these lakes would qualify as stable bodies of surface liquid water while they were molten. This is the first place such bodies have been found apart from Earth, and Triton is the only icy body known to feature cryolava lakes, although similar cryomagmatic extrusions can be seen on [[Ariel (moon)|Ariel]], [[Ganymede (moon)|Ganymede]], [[Charon (moon)|Charon]], and [[Titan (moon)|Titan]].<ref>{{cite web |last1=Schenk |first1=Paul |last2=Prockter |first2=Louise |title=Candidate Cryovolcanic Features in the Outer Solar System |url=https://www.hou.usra.edu/meetings/cryovolcanism2018/pdf/2035.pdf |publisher=Lunar and Planetary Institute}}</ref>
The ''[[Voyager 2]]'' probe observed in 1989 a handful of [[geyser]]-like eruptions of nitrogen gas and [[Entrainment (physical geography)|entrained]] dust from beneath the surface of Triton in plumes up to 8 km high.<ref name="voyager"/><ref name="Soderblom2"/> Triton is thus, along with [[Earth]], [[Io (moon)|Io]], [[Europa (moon)|Europa]] and [[Enceladus]], one of the few bodies in the Solar System on which active eruptions of some sort have been observed.<ref name="Kargel1994-Cryovolcanism"/> The best-observed examples are named [[Hili (plume)|Hili]] and [[Mahilani (plume)|Mahilani]] (after a [[Zulu mythology|Zulu]] [[Tikoloshe|water sprite]] and a [[Tonga]]n sea spirit, respectively).<ref name="USGS-planetarynames-Hili-Mahilani"/>
All the geysers observed were located between 50° and 57°S, the part of Triton's surface close to the [[subsolar point]]. This indicates that solar heating, although very weak at Triton's great distance from the Sun, plays a crucial role. It is thought that the surface of Triton probably consists of a [[Transparency and translucency|translucent]] layer of frozen nitrogen overlying a darker substrate, which creates a kind of "solid [[greenhouse effect]]". Solar radiation passes through the thin surface ice sheet, slowly heating and vaporizing subsurface nitrogen until enough gas pressure accumulates for it to erupt through the crust.<ref name="EncycSolSys-Triton"/><ref name="SmithSoderblom1989"/> A temperature increase of just 4 [[kelvin|K]] above the ambient surface temperature of 37 K could drive eruptions to the heights observed.<ref name="Soderblom2"/> Although commonly termed "cryovolcanic", this nitrogen plume activity is distinct from Triton's larger scale cryovolcanic eruptions, as well as volcanic processes on other worlds, which are powered by internal heat. [[carbon dioxide|CO<sub>2</sub>]] [[geysers on Mars]] are thought to erupt from its [[Climate of Mars#Polar caps|south polar cap]] each spring in the same way as Triton's geysers.<ref name="THEMIS"/>
Each eruption of a Triton geyser may last up to a year, driven by the [[Sublimation (phase transition)|sublimation]] of about {{convert|100|e6m3|e9ft3|abbr=unit}} of nitrogen ice over this interval; dust entrained may be deposited up to 150 km downwind in visible streaks, and perhaps much farther in more diffuse deposits.<ref name="Soderblom2"/> ''Voyager 2''{{'s}} images of Triton's southern hemisphere show many such streaks of dark material.<ref name="harv"/> Between 1977 and the ''Voyager 2'' flyby in 1989, Triton shifted from a reddish colour, similar to Pluto, to a far paler hue, suggesting that lighter nitrogen frosts had covered older reddish material.<ref name="EncycSolSys-Triton"/> The eruption of volatiles from Triton's equator and their deposition at the poles may redistribute enough mass over the course of 10,000 years to cause [[polar wander]].<ref name="Rubincam2002-wander"/>
<gallery widths="200px" heights="200px">
File:Leviathan Patera Volcanic Dome.gif|thumb|Close up of the volcanic province of [[Leviathan Patera]], the caldera in the center of the image. Several [[Crater chain|pit chains]] extend radially from the caldera to the right of the image, while the smaller of the two [[cryovolcano|cryolava lakes]] is seen to the upper left. Just off-screen to the lower left is a fault zone aligned radially with the caldera, indicating a close connection between the tectonics and volcanology of this geologic unit.
File:Voyager 2 Triton 14bg r90ccw colorized.jpg|thumb|Dark streaks across Triton's south polar cap surface, thought to be dust deposits left by eruptions of [[nitrogen]] geysers
Triton is geologically active; its surface is young and has relatively few impact craters. Although Triton's crust is made of various ices, its subsurface processes are similar to those that produce [[volcano]]es and [[rift valley]]s on Earth, but with water and [[ammonia]] as opposed to liquid rock.<ref name="EncycSolSys-Triton"/> Triton's entire surface is cut by complex valleys and ridges, probably the result of tectonics and icy [[volcanism]]. The vast majority of surface features on Triton are [[endogenic]]—the result of internal geological processes rather than external processes such as impacts. Most are volcanic and extrusive in nature, rather than [[Tectonics|tectonic]].<ref name="EncycSolSys-Triton"/>
File:Cryolava-lake-triton.jpg|thumb|Two large [[cryovolcano|cryolava]] lakes on Triton, seen west of [[Leviathan Patera]]. Combined, they are nearly the size of [[Kraken Mare]] on [[Titan (moon)|Titan]]. These features are unusually crater free, indicating they are young and were recently molten.
</gallery>
=== Polar cap, plains and ridges ===
[[File:Triton (moon).jpg|thumb|Triton's bright south polar cap above a region of cantaloupe terrain]]
Triton's south polar region is covered by a highly reflective cap of frozen nitrogen and methane sprinkled by impact craters and openings of geysers. Little is known about the north pole because it was on the night side during the ''Voyager 2'' encounter, but it is thought that Triton must also have a north polar ice cap.<ref name="Duxburyetal1993"/>
The high plains found on Triton's eastern hemisphere, such as Cipango Planum, cover over and blot out older features, and are therefore almost certainly the result of icy lava washing over the previous landscape. The plains are dotted with pits, such as [[Leviathan Patera]], which are probably the vents from which this lava emerged. The composition of the lava is unknown, although a mixture of ammonia and water is suspected.<ref name="EncycSolSys-Triton"/>
Four roughly circular "walled plains" have been identified on Triton. They are the flattest regions so far discovered, with a variance in altitude of less than 200 m. They are thought to have formed from eruption of icy lava.<ref name="EncycSolSys-Triton"/> The plains near Triton's eastern limb are dotted with black spots, the ''[[Macula (planetary geology)|maculae]]''. Some maculae are simple dark spots with diffuse boundaries, and others comprise a dark central patch surrounded by a white halo with sharp boundaries. <!-- Typical diameter of maculae is about 100 km and width of halo is between 20 and 30 km. Some speculate the maculae are outliers of the south polar cap, which retreats in summer. --> The maculae typically have diameters of about 100 km and widths of the halos of between 20 and 30 km.<ref name="EncycSolSys-Triton"/>
There are extensive ridges and valleys in complex patterns across Triton's surface, probably the result of freeze–thaw cycles.<ref name="Elliot1998-warming"/> Many also appear to be tectonic in nature and may result from extension or [[strike-slip fault]]ing.<ref name="linea"/> There are long double ridges of ice with central troughs bearing a strong resemblance to [[Europa (moon)#Lineae|Europan lineae]] (although they have a larger scale<ref name="Prockter"/>), and which may have a similar origin,<ref name="EncycSolSys-Triton"/> possibly shear heating from strike-slip motion along faults caused by diurnal tidal stresses experienced before Triton's orbit was fully circularized.<ref name="Prockter"/> These faults with parallel ridges expelled from the interior cross complex terrain with valleys in the equatorial region. The ridges and furrows, or ''[[Sulcus (geology)|sulci]],'' such as [[Yasu Sulci]], [[Ho Sulci]], and [[Lo Sulci]],<ref name="Aksnes1990-Nomenclature"/> are thought to be of intermediate age in Triton's geological history, and in many cases to have formed concurrently. They tend to be clustered in groups or "packets".<ref name="linea"/>
=== Cantaloupe terrain ===
[[File:PIA01537 Triton Faults.jpg|thumb|200px|Cantaloupe terrain viewed from 130,000 km by ''[[Voyager 2]]'', with crosscutting [[Europa (moon)|Europa]]-like double ridges. Slidr Sulci (vertical) and Tano Sulci form the prominent "X".]]
Triton's western hemisphere consists of a strange series of fissures and depressions known as "cantaloupe terrain" because of its resemblance to the skin of a [[cantaloupe]] melon. Although it has few craters, it is thought that this is the oldest terrain on Triton.<ref name="cantaloupe"/> It probably covers much of Triton's western half.<ref name="EncycSolSys-Triton"/>
Cantaloupe terrain, which is mostly dirty water ice, is only known to exist on Triton. It contains depressions {{nowrap|30–40 km}} in diameter.<ref name="cantaloupe"/> The depressions (''cavi'') are probably not impact craters because they are all of similar size and have smooth curves. The leading hypothesis for their formation is [[diapir]]ism, the rising of "lumps" of less dense material through a stratum of denser material.<ref name="EncycSolSys-Triton"/><ref name="Diapirism"/> Alternative hypotheses include formation by collapses, or by flooding caused by [[cryovolcanism]].<ref name="cantaloupe"/>
=== Impact craters ===
[[File:PIA01538 Complex Geologic History of Triton.jpg|thumb|200x200px|Tuonela Planitia (left) and Ruach Planitia (center) are two of Triton's [[cryovolcanic]] "walled plains". The paucity of craters is evidence of extensive, relatively recent, geologic activity.]]
Due to constant erasure and modification by ongoing geological activity, [[impact crater]]s on Triton's surface are relatively rare. A census of Triton's craters imaged by ''Voyager 2'' found only 179 that were incontestably of impact origin, compared with 835 observed for [[Uranus]]'s moon [[Miranda (moon)|Miranda]], which has only three percent of Triton's [[surface area]].<ref name="impact"/> The largest crater observed on Triton thought to have been created by an impact is a {{convert|27|km|mi|adj=mid|-diameter}} feature called [[Mazomba (crater)|Mazomba]].<ref name="impact"/><ref name="Ingersoll1990-plumes"/> Although larger craters have been observed, they are generally thought to be volcanic in nature.<ref name="impact"/>
The few impact craters on Triton are almost all concentrated in the leading hemisphere—that facing the direction of the orbital motion—with the majority concentrated around the equator between 30° and 70° longitude,<ref name="impact"/> resulting from material swept up from orbit around Neptune.<ref name="surface age"/> Because it orbits with one side permanently facing the planet, astronomers expect that Triton should have fewer impacts on its trailing hemisphere, due to impacts on the leading hemisphere being more frequent and more violent.<ref name="impact"/> ''Voyager 2'' imaged only 40% of Triton's surface, so this remains uncertain. However, the observed cratering asymmetry exceeds what can be explained on the basis of the impactor populations, and implies a younger surface age for the crater-free regions (≤ 6 million years old) than for the cratered regions (≤ 50 million years old).<ref name = "Mah2019"/>
== Observation and exploration ==
[[File:PIA23874-NeptuneMoonTriton-TridentMission-20200616.jpg|thumb|left|400px|NASA illustration detailing the studies of the proposed Trident mission]]
[[File:Voyager 2 Neptune and Triton.jpg|thumb|200px|Neptune (top) and Triton (bottom) three days after flyby of ''Voyager 2'']]
The orbital properties of Triton were already determined with high accuracy in the 19th century. It was found to have a retrograde orbit, at a very high angle of inclination to the plane of Neptune's orbit. The first detailed observations of Triton were not made until 1930. Little was known about the satellite until ''[[Voyager 2]]'' flew by in 1989.<ref name="EncycSolSys-Triton"/>
Before the [[Planetary flyby|flyby]] of ''Voyager 2'', astronomers suspected that Triton might have [[liquid nitrogen]] seas and a nitrogen/methane atmosphere with a density as much as 30% that of Earth. Like the famous overestimates of the [[Atmosphere of Mars|atmospheric density of Mars]], this proved incorrect. As with [[Mars]], a denser atmosphere is postulated for its early history.<ref name="Lunine1992-massive"/>
The first attempt to measure the diameter of Triton was made by [[Gerard Kuiper]] in 1954. He obtained a value of 3,800 km. Subsequent measurement attempts arrived at values ranging from 2,500 to 6,000 km, or from slightly smaller than the Moon (3,474.2 km) to nearly half the diameter of Earth.<ref name="Cruikshank1979-diameterreflectance"/> Data from the approach of ''Voyager 2'' to Neptune on August 25, 1989, led to a more accurate estimate of Triton's diameter (2,706 km).<ref name="Stone1989-Voyager 2-Neptune"/>
In the 1990s, various observations from Earth were made of the limb of Triton using the [[occultation]] of nearby stars, which indicated the presence of an atmosphere and an exotic surface. Observations in late 1997 suggest that Triton is heating up and the atmosphere has become significantly denser since ''Voyager 2'' flew past in 1989.<ref name="Hubblesite"/>
[[Neptune Orbiter|New concepts for missions to the Neptune system]] to be conducted in the 2010s were proposed by [[NASA]] scientists on numerous occasions over the last decades. All of them identified Triton as being a prime target and a separate Triton lander comparable to the [[Huygens (spacecraft)|''Huygens'' probe]] for [[Titan (moon)|Titan]] was frequently included in those plans. No efforts aimed at Neptune and Triton went beyond the proposal phase and NASA's funding on missions to the outer Solar System is currently focused on the Jupiter and Saturn systems.<ref name="NASAgov-428154"/> A proposed lander mission to Triton, called ''[[Triton Hopper]]'', would mine nitrogen ice from the surface of Triton and process it to be used as propellant for a small rocket, enabling it to fly or 'hop' across the surface.<ref>{{cite magazine |last=Ferreira |first=Becky |date=August 28, 2015 |title=Why We Should Use This Jumping Robot to Explore Neptune |url=http://motherboard.vice.com/read/neptune-or-bust |magazine=[[Vice (magazine)|Vice Motherboard]] |access-date=March 20, 2019 |archive-date=January 26, 2017 |archive-url=https://web.archive.org/web/20170126153114/http://motherboard.vice.com/read/neptune-or-bust |url-status=dead }}</ref><ref name='Oleson 2015'>{{cite web |url=https://www.nasa.gov/feature/triton-hopper-exploring-neptunes-captured-kuiper-belt-object/ |title=Triton Hopper: Exploring Neptune's Captured Kuiper Belt Object |date=7 May 2015 |first=Steven |last=Oleson |publisher=NASA Glenn Research Center |access-date=11 February 2017 }}</ref> Another concept, involving a flyby, was formally proposed in 2019 as part of NASA's [[Discovery Program]] under the name ''[[Trident (spacecraft)|Trident]]''.<ref name="NYT-Trident">{{cite news |last=Brown |first=David W. |date=19 March 2019 |title=Neptune's Moon Triton Is Destination of Proposed NASA Mission |url=https://www.nytimes.com/2019/03/19/science/triton-neptune-nasa-trident.html |work=[[The New York Times]] |access-date=20 March 2019}}</ref> [[Neptune Odyssey]] is a mission concept for a Neptune orbiter with a focus in Triton being studied as a possible [[large strategic science mission]] by NASA that would launch in 2033 and arrive at the Neptune system in 2049.<ref name="Rymer">{{cite web |author1=Abigail Rymer |author2=Brenda Clyde |author3=Kirby Runyon |title=Neptune Odyssey: Mission to the Neptune-Triton System |url=https://science.nasa.gov/science-pink/s3fs-public/atoms/files/Neptune%20Odyssey.pdf |access-date=18 April 2021 |date=August 2020 |archive-date=December 15, 2020 |archive-url=https://web.archive.org/web/20201215003151/https://science.nasa.gov/science-pink/s3fs-public/atoms/files/Neptune%20Odyssey.pdf }}</ref>
{{clear}}
==Maps==
{|align="center"
|-
|{{Annotated image | image = PIA18668 Map of Triton.jpg | image-width = 400
| height = 204 <!-- to crop the lower part of the image -->
| float = none | annotations= <!-- this parameter must be there, empty or not! -->
| caption = Enhanced-color map; leading hemisphere is on right}}
|{{Annotated image | image = Triton polar maps.jpg | image-width = 400
| height = 196 <!-- to crop the lower part of the image -->
| float = none | annotations= <!-- this parameter must be there, empty or not! -->
| caption = Enhanced-color polar maps; south is right}}
|}
== See also ==
* [[List of natural satellites]]
* [[List of geological features on Triton]]
* [[Neptune in fiction]]
* ''[[Triton Hopper]]'', a proposed lander to Triton
* [[Extraterrestrial sky#The sky of Triton|Triton's sky]]
== Notes ==
{{Reflist| group = caption}}
{{Reflist
| group = lower-alpha
| refs =
<ref name="nSurfaceArea">
Surface area derived from the radius ''r'': <math>4 \pi r^2</math>.
</ref>
<ref name="nVolume">
Volume ''v'' derived from the radius ''r'': <math>\frac{4}{3}\pi r^3</math>''.
</ref>
<ref name="nMass">
Mass ''m'' derived from the density ''d'' and the volume ''v'': <math>m=d\times v</math>.
</ref>
<ref name="nSurfaceGravity">
Surface gravity derived from the mass ''m'', the [[gravitational constant]] ''G'' and the radius ''r'': <math>\frac{Gm}{r^2}</math>.
</ref>
<ref name="nEscapeVelocity">
Escape velocity derived from the mass ''m'', the [[gravitational constant]] ''G'' and the radius ''r'': <math>\sqrt{2Gm/r}</math>.
</ref>
<ref name="nMassTriton">
Mass of Triton: 2.14{{e|22}} kg. Combined mass of 12 other known moons of Neptune: 7.53{{e|19}} kg, or 0.35%. The mass of the rings is negligible.
</ref>
<ref name="AxialTilt">
With respect to Triton's orbit about Neptune.
</ref>
<ref name="nMassOthers">
The masses of other spherical moons are: [[Titania (moon)|Titania]]—3.5{{e|21}}, [[Oberon (moon)|Oberon]]—3.0{{e|21}}, [[Rhea (moon)|Rhea]]—2.3{{e|21}}, [[Iapetus (moon)|Iapetus]]—1.8{{e|21}}, [[Charon (moon)|Charon]]—1.5{{e|21}}, [[Ariel (moon)|Ariel]]—1.3{{e|21}}, [[Umbriel (moon)|Umbriel]]—1.2{{e|21}}, [[Dione (moon)|Dione]]—1.0{{e|21}}, [[Tethys (moon)|Tethys]]—0.6{{e|21}}, [[Enceladus (moon)|Enceladus]]—0.12{{e|21}}, [[Miranda (moon)|Miranda]]—0.06{{e|21}}, [[Proteus (moon)|Proteus]]—0.05{{e|21}}, [[Mimas (moon)|Mimas]]—0.04{{e|21}}. The total mass of remaining moons is about 0.09{{e|21}}. So, the total mass of all moons smaller than Triton is about 1.65{{e|22}}. (See [[List of moons by diameter]])
</ref>
<ref name="nLargest">
Largest [[irregular moon]]s: Saturn's [[Phoebe (moon)|Phoebe]] (210 km), Uranus's [[Sycorax (moon)|Sycorax]] (160 km), and Jupiter's [[Himalia (moon)|Himalia]] (140 km)
</ref>
}}
== References ==
{{Reflist
| colwidth = 30em
| refs =
<ref name="neptuniansatfact">{{cite web
|title = Neptunian Satellite Fact Sheet
|publisher = NASA
|author = Williams, David R.
|date = November 23, 2006
|url = http://nssdc.gsfc.nasa.gov/planetary/factsheet/neptuniansatfact.html
|access-date = January 18, 2008
|archive-url = https://web.archive.org/web/20111020174353/http://nssdc.gsfc.nasa.gov/planetary/factsheet/neptuniansatfact.html
|archive-date = October 20, 2011
|url-status = dead
|df = mdy-all
}}</ref>
<ref name="Prockter">
{{cite journal
| last1 = Prockter | first1 = L. M.
| last2 = Nimmo | first2 = F.
| last3 = Pappalardo | first3 = R. T.
| title = A shear heating origin for ridges on Triton
| journal = [[Geophysical Research Letters]]
| volume = 32
| issue = 14| pages = L14202
| date = July 30, 2005
| url = http://www.es.ucsc.edu/~fnimmo/website/Prockter_et_al.pdf
| doi = 10.1029/2005GL022832
| access-date = October 9, 2011
| bibcode = 2005GeoRL..3214202P
}}
</ref>
<ref name="LassellDiscovery">
{{cite journal
| title = Lassell's Satellite of Neptune
| author= Lassell, William
| date = November 12, 1847
| journal = [[Monthly Notices of the Royal Astronomical Society]]
| volume = 10
| issue = 1
| bibcode = 1847MNRAS...8....9B
| page = 8
| doi=10.1093/mnras/10.1.8
| url = https://zenodo.org/record/1431823
| doi-access = free
}}
</ref>
<ref name="Lassell refs">
{{cite journal
| title = Discovery of Supposed Ring and Satellite of Neptune
| author = Lassell, William
| author-link = William Lassell
| date = November 13, 1846
| journal = Monthly Notices of the Royal Astronomical Society
| volume = 7
| issue = 9
| page = 157
| bibcode = 1846MNRAS...7..157L
| doi=10.1093/mnras/7.9.154| doi-access = free
}}<br/>
{{cite journal
| title = Physical observations on Neptune
| author = Lassell, William
| date = December 11, 1846
| journal = Monthly Notices of the Royal Astronomical Society
| volume = 7
| issue = 10
| pages = 167–168
| doi = 10.1093/mnras/7.10.165a
| bibcode = 1847MNRAS...7..297L | doi-access = free
}}<br/>
{{cite journal
| title = Observations of Neptune and his satellite
| author = Lassell, W.
| journal = Monthly Notices of the Royal Astronomical Society
| date = 1847
| volume = 7
| issue = 17
| pages = 307–308
| bibcode = 1847MNRAS...7..307L
| doi = 10.1002/asna.18530360703
| url = https://zenodo.org/record/1424639
}}
</ref>
<ref name="Smithetal1984">
{{cite journal
| last1 = Smith
| first1 = R. W.
| last2 = Baum
| first2 = R.
| title = William Lassell and the Ring of Neptune: A Case Study in Instrumental Failure
| journal = Journal for the History of Astronomy
| volume = 15
| issue = 42
| pages = 1–17
| date = 1984
| bibcode = 1984JHA....15....1S
| doi = 10.1177/002182868401500101
| s2cid = 116314854
}}
</ref>
<ref name="Flammarion1880">
{{cite book
|author = Flammarion, Camille
|author-link = Camille Flammarion
|title = Astronomie populaire
|page = 591
|date = 1880
|url = http://gallica.bnf.fr/ark:/12148/bpt6k94887w/f610.table
|access-date = April 10, 2007
|archive-url = https://web.archive.org/web/20120301004123/http://gallica.bnf.fr/ark:/12148/bpt6k94887w/f610.table
|archive-date = March 1, 2012
|url-status = live
}}
</ref>
<ref name="PMoore">
{{cite book
| last = Moore
| first = Patrick
| author-link = Patrick Moore
| title = The planet Neptune: an historical survey before Voyager
| publisher = [[John Wiley & Sons]]
| series = Wiley-Praxis Series in Astronomy and Astrophysics
| edition = 2nd
| date = April 1996
| pages = 150 (see p. 68)
| url = https://www.google.com/books?id=RZruAAAAMAAJ&q=H.+N.+Russell#search_anchor
| isbn = 978-0-471-96015-7
| oclc = 33103787
}}
</ref>
<ref name="IAU-solarsysNames">
{{cite web
| title = Planet and Satellite Names and their Discoverers
| work = International Astronomical Union
| url = http://www.indwes.edu/Faculty/bcupp/solarsys/Names.htm
| archive-url = https://web.archive.org/web/20080212065751/http://www.indwes.edu/Faculty/bcupp/solarsys/Names.htm
| archive-date = February 12, 2008
| access-date = January 13, 2008
}}
</ref>
<ref name="Davies1991-ControlNetwork">
{{cite journal
| last1 = Davies
| first1 = M.
| first2 = P.
| last2 = Rogers
| first3 = T.
| last3 = Colvin
| date = 1991
| title = A Control Network of Triton
| journal = J. Geophys. Res.
| volume = 96(E1)
| issue = E1
| pages = 15675–15681
| url = https://www.rand.org/content/dam/rand/pubs/notes/2009/N3425.pdf
| bibcode = 1991JGR....9615675D
| doi = 10.1029/91JE00976
}}
</ref>
<ref name="SpaceCom-TritonSeasons">
[http://www.space.com/8162-seasons-discovered-neptunes-moon-triton.html Seasons Discovered on Neptune's Moon Triton — Space.com] (2010)
{{webarchive |url=https://web.archive.org/web/20110917084637/http://www.space.com/8162-seasons-discovered-neptunes-moon-triton.html |date=September 17, 2011 }}
</ref>
<ref name="Chyba">
{{cite journal
| last1 = Chyba
| first1 = C. F.
| author-link = Christopher Chyba
| last2 = Jankowski
| first2 = D. G.
| last3 = Nicholson
| first3 = P. D.
| title = Tidal evolution in the Neptune-Triton system
| journal = [[Astronomy and Astrophysics]]
| date = July 1989
| volume = 219
| issue = 1–2
| pages = L23–L26
| bibcode = 1989A&A...219L..23C
}}
</ref>
<ref name="Cruikshank2004">
{{cite journal
| first = Dale P.
| last = Cruikshank
| title = Triton, Pluto, Centaurs, and Trans-Neptunian Bodies
| url = https://books.google.com/books?id=MbmiTd3x1UcC&q=Triton,+Pluto,+Centaurs,+and+Trans-Neptunian+Bodies&pg=PA421
| date = 2004
| pages = 421–439
| volume = 116
| issue = 1–2
| journal = Space Science Reviews
| isbn = 978-1-4020-3362-9
| doi = 10.1007/s11214-005-1964-0
| bibcode = 2005SSRv..116..421C
| s2cid = 189794324
}}
</ref>
<ref name="IOPorg-KuiperObjectBinaries">{{cite journal|last1=Sheppard|first1=Scott S.|last2=Jewitt|first2=David|title=Extreme Kuiper Belt Object 2001 Q<sub>G298</sub> and the Fraction of Contact Binaries|journal=The Astronomical Journal|volume=127|issue=5|year=2004|pages=3023–3033|issn=0004-6256|doi=10.1086/383558|arxiv=astro-ph/0402277|bibcode=2004AJ....127.3023S|s2cid=119486610}}</ref>
<ref name="Jewitt2005">
{{cite web
|last=Jewitt
|first=Dave
|date=2005
|title=Binary Kuiper Belt Objects
|work=University of Hawaii
|url=http://www2.ess.ucla.edu/~jewitt/kb/binaries.html
|access-date=June 24, 2007
|archive-url=https://web.archive.org/web/20110716032025/http://www2.ess.ucla.edu/~jewitt/kb/binaries.html
|archive-date=July 16, 2011
|url-status=live
}}
</ref>
<ref name="voyager">
{{cite web
|date=June 1, 2005
|title=Triton (Voyager)
|publisher=[[NASA]]
|url=http://voyager.jpl.nasa.gov/science/neptune_triton.html
|access-date=December 9, 2007
|archive-url=https://web.archive.org/web/20110927062022/http://voyager.jpl.nasa.gov/science/neptune_triton.html
|archive-date=September 27, 2011
|url-status=live
}}
</ref>
<ref name="ammonia">{{cite journal
| author = Ruiz, Javier
| title = Heat flow and depth to a possible internal ocean on Triton
| journal = Icarus
| volume = 166
| issue = 2
| pages = 436–439
| date = December 2003
| doi = 10.1016/j.icarus.2003.09.009
| bibcode = 2003Icar..166..436R
| url = http://eprints.ucm.es/10454/1/11-Trit%C3%B3n_1.pdf
| access-date = January 9, 2022
| archive-date = December 12, 2019
| archive-url = https://web.archive.org/web/20191212145428/http://eprints.ucm.es/10454/1/11-Trit%C3%B3n_1.pdf
| url-status = dead
}}</ref>
<ref name="Medkeff2002-LunarAlbedo">
{{cite web
| title = Lunar Albedo
| author = Medkeff, Jeff
| work = Sky and Telescope Magazine
| date = 2002
| url = http://jeff.medkeff.com/astro/lunar/obs_tech/albedo.htm
| archive-url = https://web.archive.org/web/20080523151225/http://jeff.medkeff.com/astro/lunar/obs_tech/albedo.htm
| archive-date = May 23, 2008
| access-date = February 4, 2008
}}
</ref>
<ref name="Grundy">
{{cite journal
| last1 = Grundy | first1 = W. M.
| last2 = Buie | first2 = M. W.
| last3 = Spencer | first3 = J. R.
| title = Spectroscopy of Pluto and Triton at 3–4 Microns: Possible Evidence for Wide Distribution of Nonvolatile Solids
| journal = [[The Astronomical Journal]]
| volume = 124
| issue = 4
| pages = 2273–2278
| date = October 2002
| doi = 10.1086/342933
| bibcode = 2002AJ....124.2273G
| s2cid = 59040182
| url = http://pdfs.semanticscholar.org/0c69/02d4e8fe0c7e4e708097cd5b125e479e87d7.pdf | archive-url = https://web.archive.org/web/20190218101926/http://pdfs.semanticscholar.org/0c69/02d4e8fe0c7e4e708097cd5b125e479e87d7.pdf | url-status = dead | archive-date = 2019-02-18 }}
</ref>
<ref name="Hussman2006">
{{cite journal| doi = 10.1016/j.icarus.2006.06.005| last1 = Hussmann| first1 = Hauke| last2 = Sohl| first2 = Frank| last3 = Spohn| first3 = Tilman| date = November 2006| title = Subsurface oceans and deep interiors of medium-sized outer planet satellites and large trans-neptunian objects| journal = [[Icarus (journal)|Icarus]]| volume = 185| issue = 1| pages = 258–273| url = https://www.researchgate.net/publication/225019299| bibcode = 2006Icar..185..258H| ref = {{sfnRef|Hussmann Sohl et al.|2006}}}}
</ref>
<ref name="Irwin2001-Plausibility">
{{Cite journal | doi = 10.1089/153110701753198918| pmid = 12467118| title = Assessing the Plausibility of Life on Other Worlds| journal = Astrobiology| volume = 1| issue = 2| pages = 143–60| year = 2001| last1 = Irwin | first1 = L. N. | last2 = Schulze-Makuch | first2 = D. | bibcode = 2001AsBio...1..143I}}
</ref>
<ref name="grand">
{{cite book
| title = The Grand Tour: A Traveler's Guide to the Solar System
| author = Miller, Ron
| author-link = Ron Miller (artist and author)
|author2=Hartmann, William K.
|date=May 2005
| pages = 172–73
| publisher = Workman Publishing
| location = Thailand
| edition = 3rd
| isbn = 978-0-7611-3547-0
}}
</ref>
<ref name="Lellouch2010">
{{cite journal
| last1 = Lellouch | first1 = E.
| last2 = de Bergh | first2 = C.
| last3 = Sicardy | first3 = B.
| last4 = Ferron | first4 = S.
| last5 = Käufl | first5 = H.-U.
| date = 2010
| title = Detection of CO in Triton's atmosphere and the nature of surface-atmosphere interactions
| journal = Astronomy & Astrophysics
| arxiv = 1003.2866
| doi = 10.1051/0004-6361/201014339
| volume = 512
| pages = L8
| bibcode = 2010A&A...512L...8L | s2cid = 58889896
}}
</ref>
<ref name="Duxburyetal1993">
{{cite journal
| title = The Phase Composition of Triton's Polar Caps
| bibcode = 1993Sci...261..748D
| author = Duxbury, N S |author2 = Brown, R H
| journal = Science
| volume = 261
| issue = 5122
| pages = 748–751
|date=August 1993
| doi = 10.1126/science.261.5122.748
| pmid = 17757213
| s2cid = 19761107
}}
</ref>
ctdf98lwz300pofwo6a63xz8j5ped32
User:SongVĩ.Bot II
2
124239
737760
737493
2026-04-12T17:00:15Z
SongVĩ.Bot II
52414
[[User:SongVĩ.Bot II|Task 0]]: Đã 1565 ngày...
737760
wikitext
text/x-wiki
Cập nhật lần cuối: 13-04-2026
Đã 1565 ngày...
l8d8uf5x4bc8q5fdk6brmz520usl9lk
User:Novem Linguae/XfD log
2
147994
737788
737743
2026-04-12T21:49:19Z
Novem Linguae
49714
Logging [[Wikipedia:Articles for deletion/NovemTest110|AfD nomination]] of [[:NovemTest110]].
737788
wikitext
text/x-wiki
This is a log of all [[WP:XFD|deletion discussion]] nominations made by this user using [[WP:TW|Twinkle]]'s XfD module.
If you no longer wish to keep this log, you can turn it off using the [[Wikipedia:Twinkle/Preferences|preferences panel]], and nominate this page for speedy deletion under [[WP:CSD#U1|CSD U1]].
This log does not track XfD-related deletions made using Twinkle.
=== December 2022 ===
# [[:Template:Test]]: [[Wikipedia:Templates for discussion/Log/2022 December 22#Template:Test|nominated]] at [[WP:TFD|TfD]] 20:05, 22 December 2022 (UTC)
#* '''Reason''': Test
# [[:Template:Test]]: [[Wikipedia:Templates for discussion/Log/2022 December 22#Template:Test|nominated]] at [[WP:TFD|TfD]] 20:23, 22 December 2022 (UTC)
#* '''Reason''': test
# [[:Template:Test]]: [[Wikipedia:Templates for discussion/Log/2022 December 22#Template:Test|nominated]] at [[WP:TFD|TfD]] 20:26, 22 December 2022 (UTC)
#* '''Reason''': Test
=== April 2023 ===
# [[:Category:Ursa Major Moving Group]]: nominated at [[WP:CFDS|CfDS]] (C2D); New name: [[:Category:Ursa Major moving group]] 09:03, 11 April 2023 (UTC)
#* '''Reason''': Capitalization change
# [[:Category:Ursa Major Moving Group]]: [[* [[:Category:Ursa Major Moving Group]] to [[:Category:Ursa Major moving group]] – C2D: Capitalization change [[User:Novem Linguae|Novem Linguae]] ([[User talk:Novem Linguae|talk]]) 09:08, 11 April 2023 (UTC)|nominated]] at [[WP:CFDS|CfDS]] (C2D); New name: [[:Category:Ursa Major moving group]] 09:08, 11 April 2023 (UTC)
#* '''Reason''': Capitalization change
# [[:Category:Ursa Major Moving Group]]: [[undefined#Category:Ursa Major Moving Group|nominated]] at [[WP:CFDS|CfDS]] (C2D); New name: [[:Category:Ursa Major moving group]] 09:10, 11 April 2023 (UTC)
#* '''Reason''': Capitalization change
# [[:Category:Ursa Major Moving Group]]: [[Wikipedia:Categories for discussion/Speedy#Current requests|nominated]] at [[WP:CFDS|CfDS]] (C2D); New name: [[:Category:Ursa Major moving group]] 09:16, 11 April 2023 (UTC)
#* '''Reason''': Capitalization change
# [[:Category:Ursa Major Moving Group]]: nominated at [[WP:CFDS|CfDS]] (C2D); New name: [[:Category:Ursa Major moving group]] 09:17, 11 April 2023 (UTC)
#* '''Reason''': Capitalization change
# [[:Category:Ursa Major Moving Group]]: nominated at [[WP:CFDS|CfDS]] (C2D); New name: [[:Category:Ursa Major moving group]] 09:22, 11 April 2023 (UTC)
#* '''Reason''': Capitalization change
# [[:Mainspace]]: [[Wikipedia:Articles for deletion/Mainspace (9th nomination)|nominated]] at [[WP:AFD|AfD]] 09:26, 11 April 2023 (UTC)
#* '''Reason''': Test
# [[:Test90237320]]: [[Wikipedia:Articles for deletion/Test90237320|nominated]] at [[WP:AFD|AfD]] 10:59, 20 April 2023 (UTC)
#* '''Reason''': test
# [[:Mainspace]]: nominated at [[WP:RM|RM]]; New name: [[:Mainspace28097]] 01:28, 28 April 2023 (UTC)
#* '''Reason''': Test
# [[:Mainspace]]: nominated at [[WP:RM|RM]]; New name: [[:Mainspace097097]] 01:31, 28 April 2023 (UTC)
#* '''Reason''': test
=== May 2023 ===
# [[:Mainspace]]: nominated at [[WP:RM|RM]] (technical); New name: [[:Mainspace2]] 09:15, 20 May 2023 (UTC)
#* '''Reason''': Test
# [[:Mainspace]]: nominated at [[WP:RM|RM]] (technical); New name: [[:Mainspace2]] 09:16, 20 May 2023 (UTC)
#* '''Reason''': test
# [[:Mainspace]]: nominated at [[WP:RM|RM]] (technical); New name: [[:Mainspace2]] 09:17, 20 May 2023 (UTC)
#* '''Reason''': testtt
=== June 2023 ===
# [[:Mainspace]]: [[Wikipedia:Articles for deletion/Mainspace (10th nomination)|nominated]] at [[WP:AFD|AfD]] 00:26, 21 June 2023 (UTC)
#* '''Reason''': Deletion sort test
=== September 2025 ===
# [[:Wikipedia:NovemTest1]]: [[Wikipedia:Miscellany for deletion/Wikipedia:NovemTest1|nominated]] at [[WP:MFD|MfD]] 20:30, 10 September 2025 (UTC)
#* '''Reason''': Testing XFDcloser.
=== November 2025 ===
# [[:File:Test file.png]]: ([{{fullurl:Special:Log|page=File:Test_file.png}} log]) [[Wikipedia:Files for discussion/2025 November 20#File:Test file.png|nominated]] at [[WP:FFD|FfD]] 19:55, 20 November 2025 (UTC)
#* '''Reason''': Test
# [[:File:Test file.png]]: ([{{fullurl:Special:Log|page=File:Test_file.png}} log]) [[Wikipedia:Files for discussion/2025 November 20#File:Test file.png|nominated]] at [[WP:FFD|FfD]] 19:57, 20 November 2025 (UTC)
#* '''Reason''': Test 2
=== December 2025 ===
# [[:Mainspace]]: [[Wikipedia:Articles for deletion/Mainspace (17th nomination)|nominated]] at [[WP:AFD|AfD]] 06:01, 13 December 2025 (UTC)
#* '''Reason''': Test
=== April 2026 ===
# [[:Mainspace]]: [[Wikipedia:Articles for deletion/Mainspace (18th nomination)|nominated]] at [[WP:AFD|AfD]] 19:56, 11 April 2026 (UTC)
#* '''Reason''': Test
# [[:Mainspace]]: [[Wikipedia:Articles for deletion/Mainspace (19th nomination)|nominated]] at [[WP:AFD|AfD]] 19:56, 11 April 2026 (UTC)
#* '''Reason''': Test 2
# [[:NovemTest100]]: [[Wikipedia:Articles for deletion/NovemTest100|nominated]] at [[WP:AFD|AfD]] 19:57, 11 April 2026 (UTC)
#* '''Reason''': Test
# [[:NovemTest101]]: [[Wikipedia:Articles for deletion/NovemTest101|nominated]] at [[WP:AFD|AfD]] 19:58, 11 April 2026 (UTC)
#* '''Reason''': Test 2
# [[:NovemTest102]]: [[Wikipedia:Articles for deletion/NovemTest102|nominated]] at [[WP:AFD|AfD]] 19:59, 11 April 2026 (UTC)
#* '''Reason''': redirect
# [[:NovemTest103]]: [[Wikipedia:Articles for deletion/NovemTest103|nominated]] at [[WP:AFD|AfD]] 19:59, 11 April 2026 (UTC)
#* '''Reason''': draftify
# [[:Mainspace]]: [[Wikipedia:Articles for deletion/Mainspace (20th nomination)|nominated]] at [[WP:AFD|AfD]] 09:15, 12 April 2026 (UTC)
# [[:Mainspace]]: [[Wikipedia:Articles for deletion/Mainspace (21th nomination)|nominated]] at [[WP:AFD|AfD]] 09:17, 12 April 2026 (UTC)
# [[:Mainspace]]: [[Wikipedia:Articles for deletion/Mainspace (22th nomination)|nominated]] at [[WP:AFD|AfD]] 10:05, 12 April 2026 (UTC)
# [[:NovemTest110]]: [[Wikipedia:Articles for deletion/NovemTest110|nominated]] at [[WP:AFD|AfD]]; notified {{user|1=NovemBot}} 21:49, 12 April 2026 (UTC)
4uaju85m7e841ywpo8457dzg87j4kq8
737793
737788
2026-04-12T21:51:51Z
Novem Linguae
49714
Logging [[Wikipedia:Articles for deletion/NovemTest110 (2nd nomination)|AfD nomination]] of [[:NovemTest110]].
737793
wikitext
text/x-wiki
This is a log of all [[WP:XFD|deletion discussion]] nominations made by this user using [[WP:TW|Twinkle]]'s XfD module.
If you no longer wish to keep this log, you can turn it off using the [[Wikipedia:Twinkle/Preferences|preferences panel]], and nominate this page for speedy deletion under [[WP:CSD#U1|CSD U1]].
This log does not track XfD-related deletions made using Twinkle.
=== December 2022 ===
# [[:Template:Test]]: [[Wikipedia:Templates for discussion/Log/2022 December 22#Template:Test|nominated]] at [[WP:TFD|TfD]] 20:05, 22 December 2022 (UTC)
#* '''Reason''': Test
# [[:Template:Test]]: [[Wikipedia:Templates for discussion/Log/2022 December 22#Template:Test|nominated]] at [[WP:TFD|TfD]] 20:23, 22 December 2022 (UTC)
#* '''Reason''': test
# [[:Template:Test]]: [[Wikipedia:Templates for discussion/Log/2022 December 22#Template:Test|nominated]] at [[WP:TFD|TfD]] 20:26, 22 December 2022 (UTC)
#* '''Reason''': Test
=== April 2023 ===
# [[:Category:Ursa Major Moving Group]]: nominated at [[WP:CFDS|CfDS]] (C2D); New name: [[:Category:Ursa Major moving group]] 09:03, 11 April 2023 (UTC)
#* '''Reason''': Capitalization change
# [[:Category:Ursa Major Moving Group]]: [[* [[:Category:Ursa Major Moving Group]] to [[:Category:Ursa Major moving group]] – C2D: Capitalization change [[User:Novem Linguae|Novem Linguae]] ([[User talk:Novem Linguae|talk]]) 09:08, 11 April 2023 (UTC)|nominated]] at [[WP:CFDS|CfDS]] (C2D); New name: [[:Category:Ursa Major moving group]] 09:08, 11 April 2023 (UTC)
#* '''Reason''': Capitalization change
# [[:Category:Ursa Major Moving Group]]: [[undefined#Category:Ursa Major Moving Group|nominated]] at [[WP:CFDS|CfDS]] (C2D); New name: [[:Category:Ursa Major moving group]] 09:10, 11 April 2023 (UTC)
#* '''Reason''': Capitalization change
# [[:Category:Ursa Major Moving Group]]: [[Wikipedia:Categories for discussion/Speedy#Current requests|nominated]] at [[WP:CFDS|CfDS]] (C2D); New name: [[:Category:Ursa Major moving group]] 09:16, 11 April 2023 (UTC)
#* '''Reason''': Capitalization change
# [[:Category:Ursa Major Moving Group]]: nominated at [[WP:CFDS|CfDS]] (C2D); New name: [[:Category:Ursa Major moving group]] 09:17, 11 April 2023 (UTC)
#* '''Reason''': Capitalization change
# [[:Category:Ursa Major Moving Group]]: nominated at [[WP:CFDS|CfDS]] (C2D); New name: [[:Category:Ursa Major moving group]] 09:22, 11 April 2023 (UTC)
#* '''Reason''': Capitalization change
# [[:Mainspace]]: [[Wikipedia:Articles for deletion/Mainspace (9th nomination)|nominated]] at [[WP:AFD|AfD]] 09:26, 11 April 2023 (UTC)
#* '''Reason''': Test
# [[:Test90237320]]: [[Wikipedia:Articles for deletion/Test90237320|nominated]] at [[WP:AFD|AfD]] 10:59, 20 April 2023 (UTC)
#* '''Reason''': test
# [[:Mainspace]]: nominated at [[WP:RM|RM]]; New name: [[:Mainspace28097]] 01:28, 28 April 2023 (UTC)
#* '''Reason''': Test
# [[:Mainspace]]: nominated at [[WP:RM|RM]]; New name: [[:Mainspace097097]] 01:31, 28 April 2023 (UTC)
#* '''Reason''': test
=== May 2023 ===
# [[:Mainspace]]: nominated at [[WP:RM|RM]] (technical); New name: [[:Mainspace2]] 09:15, 20 May 2023 (UTC)
#* '''Reason''': Test
# [[:Mainspace]]: nominated at [[WP:RM|RM]] (technical); New name: [[:Mainspace2]] 09:16, 20 May 2023 (UTC)
#* '''Reason''': test
# [[:Mainspace]]: nominated at [[WP:RM|RM]] (technical); New name: [[:Mainspace2]] 09:17, 20 May 2023 (UTC)
#* '''Reason''': testtt
=== June 2023 ===
# [[:Mainspace]]: [[Wikipedia:Articles for deletion/Mainspace (10th nomination)|nominated]] at [[WP:AFD|AfD]] 00:26, 21 June 2023 (UTC)
#* '''Reason''': Deletion sort test
=== September 2025 ===
# [[:Wikipedia:NovemTest1]]: [[Wikipedia:Miscellany for deletion/Wikipedia:NovemTest1|nominated]] at [[WP:MFD|MfD]] 20:30, 10 September 2025 (UTC)
#* '''Reason''': Testing XFDcloser.
=== November 2025 ===
# [[:File:Test file.png]]: ([{{fullurl:Special:Log|page=File:Test_file.png}} log]) [[Wikipedia:Files for discussion/2025 November 20#File:Test file.png|nominated]] at [[WP:FFD|FfD]] 19:55, 20 November 2025 (UTC)
#* '''Reason''': Test
# [[:File:Test file.png]]: ([{{fullurl:Special:Log|page=File:Test_file.png}} log]) [[Wikipedia:Files for discussion/2025 November 20#File:Test file.png|nominated]] at [[WP:FFD|FfD]] 19:57, 20 November 2025 (UTC)
#* '''Reason''': Test 2
=== December 2025 ===
# [[:Mainspace]]: [[Wikipedia:Articles for deletion/Mainspace (17th nomination)|nominated]] at [[WP:AFD|AfD]] 06:01, 13 December 2025 (UTC)
#* '''Reason''': Test
=== April 2026 ===
# [[:Mainspace]]: [[Wikipedia:Articles for deletion/Mainspace (18th nomination)|nominated]] at [[WP:AFD|AfD]] 19:56, 11 April 2026 (UTC)
#* '''Reason''': Test
# [[:Mainspace]]: [[Wikipedia:Articles for deletion/Mainspace (19th nomination)|nominated]] at [[WP:AFD|AfD]] 19:56, 11 April 2026 (UTC)
#* '''Reason''': Test 2
# [[:NovemTest100]]: [[Wikipedia:Articles for deletion/NovemTest100|nominated]] at [[WP:AFD|AfD]] 19:57, 11 April 2026 (UTC)
#* '''Reason''': Test
# [[:NovemTest101]]: [[Wikipedia:Articles for deletion/NovemTest101|nominated]] at [[WP:AFD|AfD]] 19:58, 11 April 2026 (UTC)
#* '''Reason''': Test 2
# [[:NovemTest102]]: [[Wikipedia:Articles for deletion/NovemTest102|nominated]] at [[WP:AFD|AfD]] 19:59, 11 April 2026 (UTC)
#* '''Reason''': redirect
# [[:NovemTest103]]: [[Wikipedia:Articles for deletion/NovemTest103|nominated]] at [[WP:AFD|AfD]] 19:59, 11 April 2026 (UTC)
#* '''Reason''': draftify
# [[:Mainspace]]: [[Wikipedia:Articles for deletion/Mainspace (20th nomination)|nominated]] at [[WP:AFD|AfD]] 09:15, 12 April 2026 (UTC)
# [[:Mainspace]]: [[Wikipedia:Articles for deletion/Mainspace (21th nomination)|nominated]] at [[WP:AFD|AfD]] 09:17, 12 April 2026 (UTC)
# [[:Mainspace]]: [[Wikipedia:Articles for deletion/Mainspace (22th nomination)|nominated]] at [[WP:AFD|AfD]] 10:05, 12 April 2026 (UTC)
# [[:NovemTest110]]: [[Wikipedia:Articles for deletion/NovemTest110|nominated]] at [[WP:AFD|AfD]]; notified {{user|1=NovemBot}} 21:49, 12 April 2026 (UTC)
# [[:NovemTest110]]: [[Wikipedia:Articles for deletion/NovemTest110 (2nd nomination)|nominated]] at [[WP:AFD|AfD]]; notified {{user|1=NovemBot}} 21:51, 12 April 2026 (UTC)
gwdqq5d8eujdp7kp52omxfiklrnibo1
Translate test/ja
0
150494
737780
737618
2026-04-12T20:35:36Z
FuzzyBot
18251
Updating to match new version of source page
737780
wikitext
text/x-wiki
<languages/>
{{int:Project-localized-name-metawiki}}
{{TNT|Hubs|banner|dev=y|admin=y}}
[[File:MediaWiki-Manual bookstyle-transparent.png|{{dir|{{pagelang}}|left|right}}|175px|メディアウィキドキュメント]]
これは'''MediaWikiに関する技術マニュアルです'''.
開発者とシステム管理者のためのインストール、管理、開発に関する情報を掲載しています。
このマニュアルはエンドユーザー向けのものではありません。
ソフトウェアの使い方については[[Special:MyLanguage/Help:Contents|MediaWiki Handbook]]をご覧ください。
<span id="Main_sections"></span>
==主要なセクション==
{{TNT|merge|Sysadmin hub|Developer hub}}
{| style="background:transparent;"
| style="width:50%; vertical-align:top; border:1px solid #aaa; padding: .5em 1.5em;" |
<span id="For_system_administrators"></span>
=== システム管理者へ ===
; [[Special:MyLanguage/Manual:Installation guide|インストール]]
: 新しいMediaWikiインストールのガイド
: {{ll|Download}} | [[Special:MyLanguage/Manual:Installing MediaWiki|インストール]] | [[Special:MyLanguage/Manual:Configuring MediaWiki|初期設定]]
: [[Special:MyLanguage/Manual:Configuration settings (alphabetical)|設定のアルファベット順リスト]] | [[Special:MyLanguage/Manual:Configuration settings|機能によってリストされた設定]]
; {{ll|Manual:System administration|nsp=0}}
: ウィキの管理作業のガイド
: [[Special:MyLanguage/Manual:Backing up a wiki|バックアップ]] | {{ll|Manual:Maintenance scripts|nsp=0}}
; {{ll|Manual:Upgrading|nsp=0}}
: MediaWikiをアップグレードするガイド
''詳細は{{ll|Sysadmin hub}}''
| style="width:50%; vertical-align:top; border:1px solid #aaa; padding: .5em 1.5em;" |
<span id="For_developers"></span>
=== 開発者へ ===
; アーキテクチャ
: MediaWikiのソースコードの重要な部分の概要。
: {{ll|Manual:Code|nsp=0}} | {{ll|Manual:Global object variables|nsp=0}} | [https://doc.wikimedia.org/ Doxygenで生成された文書]
; {{ll|Manual:Database layout|nsp=0}}
: MediaWikiが使用するデータベースアーキテクチャについての詳細
: {{ll|Manual:MySQL|nsp=0}} | {{ll|Manual:PostgreSQL|nsp=0}} | {{ll|Manual:SQLite|nsp=0}} | {{ll|Manual:IBM DB2|nsp=0}} エンジン
; {{ll|Manual:Developing extensions|nsp=0}}
: 新しいMediaWiki拡張機能を作成する方法の概要
: {{ll|Manual:Hooks|nsp=0}} | {{ll|Manual:Parser functions|nsp=0}} | [[Special:MyLanguage/Manual:Tag extensions|タグ]] | [[Special:MyLanguage/Manual:Special pages|特別ページ]] | {{ll|Manual:Skins|nsp=0}}
; ウェブアクセス
: 使用可能な[[w:Query string|クエリ文字列]]の詳細
: {{ll|Manual:Parameters to index.php|index.php}} | {{ll|API:Main page|api.php}}
''詳細は{{ll|Developer hub}}''
|}
<span id="Others"></span>
=== その他 ===
; [[Special:MyLanguage/Manual:FAQ|MediaWiki FAQ]]
: メディアウィキに関するよくある質問
<span id="Browsing_the_manual"></span>
== マニュアルを検索する ==
文書を閲覧する方法は複数あります
上記の節である特定の話題を見つけて困惑する読者たちは、以下のブラウジング方法を参考 に する こと が できます。
* [[Special:Allpages/Manual:]] - 自動生成されたマニュアルのすべてのページのリスト:名前空間
* [[:Category:Manual]] - 最上級のマニュアルカテゴリー
<span id="Improving_the_manual"></span>
== マニュアルを改善する ==
* このマニュアルにはまだ穴がたくさんあります! 詳細は[[Special:MyLanguage/Manual:Contents/To do|'to do' page]]を参照。
*http://meta.wikimedia.orgにはまだ移行する必要があるコンテンツがあります。 このドキュメントでは特定の問題に関する情報が見つからない場合は [[meta:MediaWiki_FAQ]]と [[meta:Help:Contents]] をご覧ください。
* '''[[Project:Manual]]'''はマニュアルの発展について議論する場所です。
* 以下も参照 [[Project:Current issues]].
<span id="MediaWiki_Virtual_Library"></span>
== MediaWiki仮想図書館 ==
* '''[[:Category:MediaWiki Virtual Library (MVL)|MediaWiki Virtual Library]] (MVL)'''にMediaWikiについてのガイドがあります。
[[File:Incubator-notext.png|alt=foobaz|thumb|foobar]]
{| style="background:transparent;"
| style="width:50%; vertical-align:top; border:1px solid #aaa; padding: .5em 1.5em;" |
=== For system administrators ===
; [[Special:MyLanguage/Manual:Installation guide|Installation]]
: Guide to setting up a new MediaWiki installation.
: {{ll|Download}} | [[Special:MyLanguage/Manual:Installing MediaWiki|
Installing]] | [[Special:MyLanguage/Manual:Configuring MediaWiki|
Initial configuration]]
: [[Special:MyLanguage/Manual:Configuration settings (alphabetical)|
Alphabetical list of settings]] | [[Special:MyLanguage/Manual:Configuration settings|
Settings listed by function]]
; {{ll|Manual:System administration|nsp=0}}
: Guide to do administrative tasks on your wiki.
: [[Special:MyLanguage/Manual:Backing up a wiki|Backing up]] | {{ll|Manual:Maintenance scripts|nsp=0}}
; {{ll|Manual:Upgrading|nsp=0}}
:
Guide to upgrade your MediaWiki installation.
''More on {{ll|Sysadmin hub}}.''
| style="width:50%; vertical-align:top; border:1px solid #aaa; padding: .5em 1.5em;" |
=== For developers ===
; Architecture
: An overview of the key parts of MediaWiki's source code.
: {{ll|Manual:Code|nsp=0}} | {{ll|Manual:Global object variables|nsp=0}} | [https://doc.wikimedia.org/
Doxygen-generated documentation]
; {{ll|Manual:Database layout|nsp=0}}
: Details about the database architecture used by MediaWiki.
: {{ll|Manual:MySQL|nsp=0}} | {{ll|Manual:PostgreSQL|nsp=0}} | {{ll|Manual:SQLite|nsp=0}} | {{ll|Manual:IBM DB2|nsp=0}} engines
; {{ll|Manual:Developing extensions|nsp=0}}
: An overview of the ways to create a new MediaWiki extension.
: {{ll|Manual:Hooks|nsp=0}} | {{ll|Manual:Parser functions|nsp=0}} | [[Special:MyLanguage/Manual:Tag extensions|Tag]] | [[Special:MyLanguage/Manual:Special pages|Special page]] | {{ll|Manual:Skins|nsp=0}}
; Web access
: Details about [[w:Query string|query string]] parameters that can be passed to MediaWiki access scripts.
: {{ll|Manual:Parameters to index.php|index.php}} | {{ll|API:Main page|api.php}}
''More on {{ll|Developer hub}}.''
|}
[[File:Soldering a 0805.jpg|alt=foobaz|thumb|foobar]]
[[Category:MediaWiki technical documentation{{translation}}| ]]
[[Category:Manual{{translation}}| ]]
edbzgowvgm722tkf2bk9wsrvalp8sjq
User talk:JWBTH/CD test page
3
154341
737818
737732
2026-04-13T11:57:34Z
JWBTH
52211
/* Comment split by replies */
737818
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)
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 [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 18:53, 7 April 2026 (UTC)
*:iaos dhioas dhoia sdhiosa d [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 07:38, 8 April 2026 (UTC)
*:reply [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 11:15, 8 April 2026 (UTC)
*:reply [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 11:15, 8 April 2026 (UTC)
*:tests [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 11:57, 8 April 2026 (UTC)
*: test [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 14:07, 8 April 2026 (UTC)
*:s [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 14:39, 8 April 2026 (UTC)
*:ss [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 18:23, 8 April 2026 (UTC)
=== Transcluded comments ===
{{User talk:JWBTH/CD test page/comment}}
=== 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)
=== Comment split by replies ===
Somebody describes something, and here goes a list:
* List item 1
** Here a user breaks the list with their reply. [[User:Example2|Example2]] ([[User talk:Example2|talk]]) 06:46, 9 April 2026 (UTC)
* List item 2
** Here a user breaks the list with their reply. [[User:Example2|Example2]] ([[User talk:Example2|talk]]) 06:47, 9 April 2026 (UTC)
* List item 3
** Here a user breaks the list with their reply. [[User:Example2|Example2]] ([[User talk:Example2|talk]]) 06:48, 9 April 2026 (UTC)
Here continues the original post. [[User:Example|Example]] ([[User talk:Example|talk]]) 05:48, 9 April 2026 (UTC)
=== Comment split by replies 2 ===
Somebody describes something, and here goes a list:
* List item 1
** Here a user breaks the list with their reply. [[User:Example2|Example2]] ([[User talk:Example2|talk]]) 06:46, 10 April 2026 (UTC)
*** Reply. [[User:Example|Example]] ([[User talk:Example|talk]]) 06:46, 10 April 2026 (UTC)
* List item 2
** Here a user breaks the list with their reply. [[User:Example2|Example2]] ([[User talk:Example2|talk]]) 06:47, 10 April 2026 (UTC)
* List item 3
** Here a user breaks the list with their reply. [[User:Example2|Example2]] ([[User talk:Example2|talk]]) 06:48, 10 April 2026 (UTC)
Here continues the original post. [[User:Example|Example]] ([[User talk:Example|talk]]) 05:48, 10 April 2026 (UTC)
=== Comment split by replies 3 ===
Somebody describes something, and here goes a list:
* List item 1
** Here a user breaks the list with their reply. [[User:Example2|Example2]] ([[User talk:Example2|talk]]) 06:46, 11 April 2026 (UTC)
** Another reply. [[User:Example2|Example2]] ([[User talk:Example2|talk]]) 06:46, 11 April 2026 (UTC)
* List item 2
** Here a user breaks the list with their reply. [[User:Example2|Example2]] ([[User talk:Example2|talk]]) 06:47, 11 April 2026 (UTC)
* List item 3
** Here a user breaks the list with their reply. [[User:Example2|Example2]] ([[User talk:Example2|talk]]) 06:48, 11 April 2026 (UTC)
Here continues the original post. [[User:Example|Example]] ([[User talk:Example|talk]]) 05:48, 11 April 2026 (UTC)
=== Comment split by replies 3 ===
Somebody describes something, and here goes a list:
* Comment from Example2.
** A genuine list item from Example2.
** A genuine list item from Example2. [[User:Example2|Example2]] ([[User talk:Example2|talk]]) 06:46, 12 April 2026 (UTC)
Here continues the original post. [[User:Example|Example]] ([[User talk:Example|talk]]) 05:48, 12 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
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.08406505844874512 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 16:32, 16 March 2026 (UTC)
: Test. [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 18:16, 16 March 2026 (UTC)
: Test. test3 [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 18:17, 16 March 2026 (UTC)
: Test comment with random number 0.8357927622675184 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 18:53, 16 March 2026 (UTC)
: Test comment with random number 0.47460188540542925 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 18:53, 16 March 2026 (UTC)
: Test comment with random number 0.687062002939545 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 12:03, 23 March 2026 (UTC)
: Test comment with random number 0.21500952410025898 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 12:22, 23 March 2026 (UTC)
: Test comment with random number 0.6571328205265842 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 17:21, 23 March 2026 (UTC)
: Test comment with random number 0.8725721668943434 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 11:21, 24 March 2026 (UTC)
: Test comment with random number 0.9535110784110594 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 11:22, 24 March 2026 (UTC)
: Test comment with random number 0.4330065153484025 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 03:55, 27 March 2026 (UTC)
: Test comment with random number 0.7353033907097808 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 10:23, 27 March 2026 (UTC)
: Test comment with random number 0.44304195516553146 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 10:23, 27 March 2026 (UTC)
: Test comment with random number 0.02243804450899023 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 10:24, 27 March 2026 (UTC)
: Test comment with random number 0.520846091950367 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 10:24, 27 March 2026 (UTC)
: Test comment with random number 0.9946058761624214 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 10:25, 27 March 2026 (UTC)
: Test comment with random number 0.1691580237328757 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 10:28, 27 March 2026 (UTC)
: Test comment with random number 0.06490355868980668 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 10:30, 27 March 2026 (UTC)
: Test comment with random number 0.9392023221346153 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 10:32, 27 March 2026 (UTC)
: Test comment with random number 0.5537697536904882 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 16:38, 1 April 2026 (UTC)
: Test comment with random number 0.470848341599752 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 17:14, 1 April 2026 (UTC)
: Test comment with random number 0.41673313374066356 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 17:15, 1 April 2026 (UTC)
: Test comment with random number 0.671732439038764 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 17:35, 1 April 2026 (UTC)
:testtt [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 07:10, 7 April 2026 (UTC)
: test [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 17:59, 7 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>
== Section for moving ==
test [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 19:52, 15 March 2026 (UTC)
rg1iy8ivy6exko3ejm41q54vy0aq1s4
737819
737818
2026-04-13T11:57:43Z
JWBTH
52211
/* Comment split by replies 3 */
737819
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)
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 [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 18:53, 7 April 2026 (UTC)
*:iaos dhioas dhoia sdhiosa d [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 07:38, 8 April 2026 (UTC)
*:reply [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 11:15, 8 April 2026 (UTC)
*:reply [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 11:15, 8 April 2026 (UTC)
*:tests [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 11:57, 8 April 2026 (UTC)
*: test [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 14:07, 8 April 2026 (UTC)
*:s [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 14:39, 8 April 2026 (UTC)
*:ss [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 18:23, 8 April 2026 (UTC)
=== Transcluded comments ===
{{User talk:JWBTH/CD test page/comment}}
=== 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)
=== Comment split by replies ===
Somebody describes something, and here goes a list:
* List item 1
** Here a user breaks the list with their reply. [[User:Example2|Example2]] ([[User talk:Example2|talk]]) 06:46, 9 April 2026 (UTC)
* List item 2
** Here a user breaks the list with their reply. [[User:Example2|Example2]] ([[User talk:Example2|talk]]) 06:47, 9 April 2026 (UTC)
* List item 3
** Here a user breaks the list with their reply. [[User:Example2|Example2]] ([[User talk:Example2|talk]]) 06:48, 9 April 2026 (UTC)
Here continues the original post. [[User:Example|Example]] ([[User talk:Example|talk]]) 05:48, 9 April 2026 (UTC)
=== Comment split by replies 2 ===
Somebody describes something, and here goes a list:
* List item 1
** Here a user breaks the list with their reply. [[User:Example2|Example2]] ([[User talk:Example2|talk]]) 06:46, 10 April 2026 (UTC)
*** Reply. [[User:Example|Example]] ([[User talk:Example|talk]]) 06:46, 10 April 2026 (UTC)
* List item 2
** Here a user breaks the list with their reply. [[User:Example2|Example2]] ([[User talk:Example2|talk]]) 06:47, 10 April 2026 (UTC)
* List item 3
** Here a user breaks the list with their reply. [[User:Example2|Example2]] ([[User talk:Example2|talk]]) 06:48, 10 April 2026 (UTC)
Here continues the original post. [[User:Example|Example]] ([[User talk:Example|talk]]) 05:48, 10 April 2026 (UTC)
=== Comment split by replies 3 ===
Somebody describes something, and here goes a list:
* List item 1
** Here a user breaks the list with their reply. [[User:Example2|Example2]] ([[User talk:Example2|talk]]) 06:46, 11 April 2026 (UTC)
** Another reply. [[User:Example2|Example2]] ([[User talk:Example2|talk]]) 06:46, 11 April 2026 (UTC)
* List item 2
** Here a user breaks the list with their reply. [[User:Example2|Example2]] ([[User talk:Example2|talk]]) 06:47, 11 April 2026 (UTC)
* List item 3
** Here a user breaks the list with their reply. [[User:Example2|Example2]] ([[User talk:Example2|talk]]) 06:48, 11 April 2026 (UTC)
Here continues the original post. [[User:Example|Example]] ([[User talk:Example|talk]]) 05:48, 11 April 2026 (UTC)
=== Comment split by replies 4 ===
Somebody describes something, and here goes a list:
* Comment from Example2.
** A genuine list item from Example2.
** A genuine list item from Example2. [[User:Example2|Example2]] ([[User talk:Example2|talk]]) 06:46, 12 April 2026 (UTC)
Here continues the original post. [[User:Example|Example]] ([[User talk:Example|talk]]) 05:48, 12 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
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.08406505844874512 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 16:32, 16 March 2026 (UTC)
: Test. [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 18:16, 16 March 2026 (UTC)
: Test. test3 [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 18:17, 16 March 2026 (UTC)
: Test comment with random number 0.8357927622675184 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 18:53, 16 March 2026 (UTC)
: Test comment with random number 0.47460188540542925 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 18:53, 16 March 2026 (UTC)
: Test comment with random number 0.687062002939545 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 12:03, 23 March 2026 (UTC)
: Test comment with random number 0.21500952410025898 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 12:22, 23 March 2026 (UTC)
: Test comment with random number 0.6571328205265842 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 17:21, 23 March 2026 (UTC)
: Test comment with random number 0.8725721668943434 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 11:21, 24 March 2026 (UTC)
: Test comment with random number 0.9535110784110594 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 11:22, 24 March 2026 (UTC)
: Test comment with random number 0.4330065153484025 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 03:55, 27 March 2026 (UTC)
: Test comment with random number 0.7353033907097808 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 10:23, 27 March 2026 (UTC)
: Test comment with random number 0.44304195516553146 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 10:23, 27 March 2026 (UTC)
: Test comment with random number 0.02243804450899023 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 10:24, 27 March 2026 (UTC)
: Test comment with random number 0.520846091950367 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 10:24, 27 March 2026 (UTC)
: Test comment with random number 0.9946058761624214 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 10:25, 27 March 2026 (UTC)
: Test comment with random number 0.1691580237328757 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 10:28, 27 March 2026 (UTC)
: Test comment with random number 0.06490355868980668 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 10:30, 27 March 2026 (UTC)
: Test comment with random number 0.9392023221346153 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 10:32, 27 March 2026 (UTC)
: Test comment with random number 0.5537697536904882 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 16:38, 1 April 2026 (UTC)
: Test comment with random number 0.470848341599752 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 17:14, 1 April 2026 (UTC)
: Test comment with random number 0.41673313374066356 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 17:15, 1 April 2026 (UTC)
: Test comment with random number 0.671732439038764 [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 17:35, 1 April 2026 (UTC)
:testtt [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 07:10, 7 April 2026 (UTC)
: test [[User:JWBTH|JWBTH]] ([[User talk:JWBTH|talk]]) 17:59, 7 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>
== Section for moving ==
test [[User:Test account 8|Test account 8]] ([[User talk:Test account 8|talk]]) 19:52, 15 March 2026 (UTC)
th17vqnob7l2nwt6foqh8hle4ybmymj
User talk:NovemBot
3
160473
737786
735305
2026-04-12T21:49:18Z
Novem Linguae
49714
Notification: [[Wikipedia:Articles for deletion/NovemTest110|listing]] of [[:NovemTest110]] at [[WP:Articles for deletion]].
737786
wikitext
text/x-wiki
== Test 2 ==
Test –[[User:Novem Linguae|<span style="color:blue">'''Novem Linguae'''</span>]] <small>([[User talk:Novem Linguae|talk]])</small> 08:46, 26 March 2026 (UTC)
{{subst:afd notice|1=NovemTest110}} –[[User:Novem Linguae|<span style="color:blue">'''Novem Linguae'''</span>]] <small>([[User talk:Novem Linguae|talk]])</small> 21:49, 12 April 2026 (UTC)
80m3wb3qp569kpgj8yszqkbq7o8obai
737791
737786
2026-04-12T21:51:50Z
Novem Linguae
49714
Notification: [[Wikipedia:Articles for deletion/NovemTest110 (2nd nomination)|listing]] of [[:NovemTest110]] at [[WP:Articles for deletion]].
737791
wikitext
text/x-wiki
== Test 2 ==
Test –[[User:Novem Linguae|<span style="color:blue">'''Novem Linguae'''</span>]] <small>([[User talk:Novem Linguae|talk]])</small> 08:46, 26 March 2026 (UTC)
{{subst:afd notice|1=NovemTest110}} –[[User:Novem Linguae|<span style="color:blue">'''Novem Linguae'''</span>]] <small>([[User talk:Novem Linguae|talk]])</small> 21:49, 12 April 2026 (UTC)
{{subst:afd notice|outcome=draftification|order=  (2nd nomination)|1=NovemTest110}} –[[User:Novem Linguae|<span style="color:blue">'''Novem Linguae'''</span>]] <small>([[User talk:Novem Linguae|talk]])</small> 21:51, 12 April 2026 (UTC)
fddgmjegzx8c067tg3riuzplxkq4i1p
User:Namoroka/sandbox
2
163120
737798
736524
2026-04-13T06:57:02Z
Namoroka
19627
737798
wikitext
text/x-wiki
__NOINDEX__
*insource:/\| *Author *=/
*insource:/\| *subscription *= *yes/
*insource:/\| *subscription *= *[Yy][Ee][Ss]/
*insource:/\| *subjectlink([1-9]\d*)? *=/
*insource:/참고 자료 시작 *\| *colwidth *=/ hastemplate:참고 자료 시작
insource:"확인연도" insource:/\|\s*확인연도\s*=\s*[\|}]/
*(\{\{\s*저널 인용[^}]*\s*\|\s*)언어링크(\s*=[^}]*)
*(\n|)\|\s*(꺾|꺽)쇠표\s*=\s*(1|예)\s*(\n|)
----
*https://en.wikipedia.org/wiki/Wikipedia:AutoWikiBrowser/Regular_expression#Examples
*https://en.wikipedia.org/wiki/Wikipedia:AutoWikiBrowser/Find_and_replace Templates to substitute
*https://en.wikipedia.org/wiki/Wikipedia:AutoWikiBrowser/Rename_template_parameters
*https://en.wikipedia.org/wiki/Wikipedia:AutoWikiBrowser/Template_redirects<!--
인용 틀 및 각주 관련하여서 업데이트를 하기 전 의견을 여쭙고자 합니다.
=== 1. {{틀|각주}} 틀 ===
==== 1-1. {{변수|refs}} 변수 사용 제한 및 봇 치환 ====
[[백:목록 지정형 각주|목록 지정형 각주]]를 사용하면 여러 {{tag|ref}} 태그를 {{틀|각주}}의 {{변수|refs}}에 모아서 사용할 수 있습니다. 그러나 {{변수|refs}} 변수는 현재 [[백:시각편집기|시각편집기]]나 [[mw:Content_translation/ko|내용 번역]]과 같은 번역 도구 등과 호환이 되지 않습니다.
예를 들어 시각편집기에서 {{변수|refs}}를 사용한 {{틀|각주}}의 편집을 시도하면 "{{int:cite-ve-referenceslist-missingref-in-list}}"와 같은 메시지가 떠서 편집이 불가능합니다. ([//ko.wikipedia.org/w/index.php?title=%EA%B5%AD%EC%A0%9C_%EC%8B%9D%EB%AC%BC%EB%AA%85_%EC%83%89%EC%9D%B8&oldid=38873118&veaction=edit 예시]) / 대신 {{tlx|각주|refs{{=}}...}}를 일반 {{tag|references}} 태그로 대체하면 각주 목록에서 바로 각주 편집이 가능합니다. ([//ko.wikipedia.org/w/index.php?title=%EA%B5%AD%EC%A0%9C_%EC%8B%9D%EB%AC%BC%EB%AA%85_%EC%83%89%EC%9D%B8&oldid=41575972&veaction=edit 예시], [[특수:차이/41575972|차이]])
이러한 문제점은 10년 전부터 확인되었지만, 오랜 기간 수정이 이루어지지 않았습니다([[:phab:T52896|T52896]]). 이는 시각편집기를 사용하는 많은 사용자들에 불편을 끼치기 때문에 {{변수|refs}} 사용을 공식적으로 더 이상 허용하지 않도록(deprecated) 할 것을 제안합니다.
가능한 경우 봇을 통해 기존의 {{tlx|각주|refs{{=}}...}}를 일반 {{tag|references}} 태그로 치환하는 것도 동시에 제안합니다.
{{변수|refs}}를 사용하지 않는 {{틀|각주}}의 사용에는 영향을 끼치지 않습니다. 유사한 {{틀|notelist}}류 틀의 {{변수|refs}}도 동일하게 적용합니다. ([[w:Wikipedia:Village_pump_(proposals)/Archive_223#Bot_to_make_list-defined_references_editable_with_the_VisualEditor|관련 영어판 토론]])
==== 1-2. 틀 코드 및 틀스타일 개선 ====
기존의 [[틀:각주/styles.css|틀스타일]]과 [[:mw:mw:Extension:Cite|Cite 확장기능]]의 스타일에는 서로 중복되거나 상충하는 코드가 존재합니다. 영어판에서는 1-1 문제를 해결하면서 일부 사용자들이 기존의 {{틀|각주}}와 {{tag|references|single}}가 표출되는 모양이 가끔씩 서로 상이하다는 점을 지적하였습니다.
따라서 영어판에 맞추어 틀과 [[틀:각주/styles.css|틀스타일]]을 업데이트할 것을 제안합니다. [//ko.wikipedia.org/wiki/특수:문서비교?page1=틀%3A각주&rev1=41574853&page2=틀%3A각주%2F연습장&rev2=41575902&action=&unhide= 틀 차이], [//ko.wikipedia.org/wiki/%ED%8A%B9%EC%88%98:%EB%AC%B8%EC%84%9C%EB%B9%84%EA%B5%90?page1=%ED%8B%80%3A%EA%B0%81%EC%A3%BC%2Fstyles.css&rev1=35556958&page2=%ED%8B%80%3A%EA%B0%81%EC%A3%BC%2F%EC%97%B0%EC%8A%B5%EC%9E%A5%2Fstyles.css&rev2=41575901&action=&unhide= 틀스타일 차이], [[특수:고유링크/41575925|수정된 틀 설명문서]]
주요 차이점으로는 다음이 있습니다.
* 수동으로 지정한 열 너비 값에는 90% 곱해져서 계산됩니다. (예: <code><nowiki>{{각주|30em}}</nowiki></code> → 27em으로 계산) 너비를 계산할 때 문서 폰트의 90% 크기가 아닌 기본값을 기준으로 하기 때문입니다. 이렇게 하면 기존에 수동으로 지정한 값에 영향을 끼치지 않고, {{틀|각주}}와 {{tag|references|single}}의 모양을 동일하게 가져갈 수 있습니다.
* [[특수:내사용자문서/commons.css|개인 CSS]]로 각주 목록 모양을 조절한 사용자의 경우, 이전과 동일하게 작동하지 않아 CSS를 다시 지정해야할 수 있습니다.
=== 2. 인용 틀 ===
==== 2-1. 모듈 전반적인 업데이트 ====
* {{틀|sfn}} 등의 틀에서 사용하기 위해 {{변수|ref{{=}}harv}}를 수동으로 지정하지 않아도 자동으로 CITEREF 앵커 값이 생성됩니다. 기존에 {{변수|ref{{=}}harv}}와 같이 입력된 것은 봇을 통해 일괄 삭제되어야 합니다.
* 편집 화면에서 오류/관리 메시지에 대한 안내 문구가 나옵니다. 숨길 수 없습니다.
* {{변수|날짜}}에서 날짜를 YYYY-MM 형식으로 입력할 수 없습니다. {{변수|날짜|1980-12}}는 {{변수|1980년 12월}} 또는 영어로 {{변수|December 1980}}으로 풀어 써야 합니다. 이는 1980–81과 같이 연도 범위를 나타내는 것과 뜻이 겹치기에 모호하기 때문입니다. 2000-01을 생각하시면 쉽습니다. 2000년 1월일까요? 2000년부터 2001년 사이 범위를 의미하는 것일까요? [[분류:CS1 관리 - 축약된 연도 범위]]로 분류합니다.
* 지원하지 않는 변수
==== 2-2. 제안 ====
; 식별자 변수 제거
범용 변수 {{변수|id}} 사용.
{{변수|eram}} {{변수|eudml}} {{변수|numdam}} {{변수|naid}} 제거.
; 인용 표출 순서 정리
기존의 영어판 모듈과 동일한 순서로 출력되도록 할 것을 제안합니다.
==== 2-3. archive.today 비활성화 ====
[[archive.today]]는 2012년부터 운영을 시작한 [[웹 아카이빙]] 사이트로 데니스 페트로프(Denis Petrov)라고 알려진 신원 미상의 인물이 운영합니다. 비영리 단체에서 운영하는 [[인터넷 아카이브]](archive.org)와 달리 [[robots.txt]]를 준수하지 않거나 빠른 속도 등 사용자 측면에서 일부 나은 점도 있었기에 위키백과를 비롯한 여러 사이트에서 널리 사용되었습니다.
2026년 1월 14일 밝혀진 바에 따르면, archive.today는 사이트 내 [[CAPTCHA]](캡차)에 악성 [[자바스크립트]] 코드를 삽입하여 운영자에 대한 글을 게시한 야니 파토칼리오(Jani Patokallio)의 블로그에 [[서비스 거부 공격|분산 서비스 거부 공격]](DDoS)을 실시했습니다. archive.today에 방문한 사용자들은 자신도 모르는 사이 누군가의 웹사이트에 DDos 공격을 시행하는 것을 도운 셈입니다. 또한 파토칼리오에 따르면 보존된 웹페이지에 자신의 이름이 악의적으로 삽입되는 등, 보존된 자료의 조작도 가능하다는 점을 밝혔습니다.
현재 한국어 위키백과에는 {{search link|insource:"archive.today" -intitle:"archive.today"|약 13,000개의 링크}}가 존재합니다.
따라서 다음을 제안합니다.
* archive.today 링크의 신규 추가를 불허합니다. 2월 27일에 추가된 [[특수:편집필터/172]]를 통해 신규 추가를 감시하고 있지만, 금지하고 있지는 않습니다.
* 인용 틀에서 archive.today 링크를 사용한 경우, 해당 링크를 표시하지 않습니다. ({{변수|보존url}} 뿐만이 아니라 일반 {{변수|url}} 등 모든 변수 포함) 문서는 [[:분류:CS1 관리 - 사용하지 않는 보존 서비스]]([[w:Category:CS1 maint: deprecated archival service|영어판]])로 자동 분류됩니다.
* [[도움말:웹사이트 보존하기]] 등 관련 문서 내용을 전면 수정합니다. 다른 대체 서비스를 적극 사용하고, archive.today 링크를 삭제합니다.
* 추후 정리가 완료되면 도메인을 [[미디어위키:Spam-blacklist|블랙리스트]]에 등재합니다.
추가 읽기:
* {{웹 인용|저자=박재홍|url=https://wikidocs.net/blog/@jaehong/8104/|제목=위키백과가 Archive.today를 퇴출시킨 이유: DDoS 공격과 아카이브 조작의 충격|날짜=2026-02-21|웹사이트=위키독스 블로그}}
* {{뉴스 인용|저자=남유리|url=https://kitpa.org/news/1119|제목=위키피디아, Archive.today 전면 차단...DDoS 공격·아카이브 조작 이중 논란...보안 블로거 표적 삼아 이용자 PC 악용, 저장 페이지 내용까지 위·변조|날짜=2026-02-22|뉴스=한국정보기술신문}}
-->
2kdzj5w6xuri0agbh5ryyvo8b310pm5
User:Əkrəm/styles.css
2
171860
737764
726043
2026-04-12T19:49:28Z
Əkrəm
53528
Əkrəm moved page [[Template:User:Əkrəm/styles.css]] to [[User:Əkrəm/styles.css]] without leaving a redirect
726043
sanitized-css
text/css
.userpage-background img {
max-width: 100%;
height: auto
}
.userpage-container {
display: flex;
justify-content: space-around;
position: absolute;
top: 0;
left: 0;
width: 100%
}
.userpage-item {
flex: 1
}
l9zt9qow05vbx11glr1p0zcnz7w2gf1
737766
737764
2026-04-12T20:00:12Z
Əkrəm
53528
737766
sanitized-css
text/css
.background img {
position: absolute;
max-width: 100%;
height: auto
}
.container {
display: flex;
justify-content: space-around;
}
a3db76r815ccxxnbegubq9tfk3n12ek
737767
737766
2026-04-12T20:03:36Z
Əkrəm
53528
737767
sanitized-css
text/css
.background {
position: absolute;
z-index: -1
}
.background img {
max-width: 100%;
height: auto
}
.container {
display: flex;
justify-content: space-around;
}
sl1vzlyxg3xr18abmgyh7rphf7gcc3n
737769
737767
2026-04-12T20:05:18Z
Əkrəm
53528
737769
sanitized-css
text/css
.background {
position: absolute;
z-index: -1
}
.background img {
max-width: 100%;
height: auto;
filter: brightness(0.5)
}
.container {
display: flex;
justify-content: space-around
}
m6ie2ll85mh80ne9ul12fzzo6idpgub
737771
737769
2026-04-12T20:14:04Z
Əkrəm
53528
737771
sanitized-css
text/css
.container {
display: flex;
justify-content: space-around;
background: url('https://upload.wikimedia.org/wikipedia/commons/1/1a/Northwest_Persian_Harshang_Carpet.JPG');
background-size: cover;
}
qtqfzk9el111svur8d4jgr8cifkmlhy
737773
737771
2026-04-12T20:20:17Z
Əkrəm
53528
737773
sanitized-css
text/css
.container {
display: flex;
justify-content: space-around;
position: relative
}
.container::before {
content: '';
position: absolute;
top: 0;
left: 0;
background: url('https://upload.wikimedia.org/wikipedia/commons/1/1a/Northwest_Persian_Harshang_Carpet.JPG');
background-size: cover;
width: 100%;
height: 100%;
filter: brightness(0.25)
}
byeo06d9035apg8c4syu9lrwqrlkdn1
737774
737773
2026-04-12T20:20:34Z
Əkrəm
53528
737774
sanitized-css
text/css
.container {
display: flex;
justify-content: space-around;
position: relative
}
.container::before {
content: '';
position: absolute;
top: 0;
left: 0;
background: url('https://upload.wikimedia.org/wikipedia/commons/1/1a/Northwest_Persian_Harshang_Carpet.JPG');
background-size: cover;
width: 100%;
height: 100%;
filter: brightness(0.25);
z-index: -1
}
dkbfw93ae2fpla71aur696cbu9s6ds5
User:Əkrəm
2
171861
737765
726044
2026-04-12T19:49:55Z
Əkrəm
53528
737765
wikitext
text/x-wiki
<templatestyles src="User:Əkrəm/styles.css" />
<div class="userpage-background" style="anchor-name: --userpage-background">[[File:NASA’s Advanced Composite Solar Sail System (NHQ202409020001).jpg|link=]]</div><!--
--><div class="userpage-container" style="position-anchor: --userpage-background"><!--
--><div class="userpage-item">[[File:WP25 Museum.svg|128px]]</div><!--
--><div class="userpage-item">[[File:WP25 Museum.svg|128px]]</div><!--
--><div class="userpage-item">[[File:WP25 Museum.svg|128px]]</div><!--
--><div class="userpage-item">[[File:WP25 Museum.svg|128px]]</div><!--
--><div class="userpage-item">[[File:WP25 Museum.svg|128px]]</div><!--
--></div>
bmtwm6fau2ts4fy13pfbkwalkwc0hof
737770
737765
2026-04-12T20:05:25Z
Əkrəm
53528
737770
wikitext
text/x-wiki
<templatestyles src="User:Əkrəm/styles.css" />
<div class="background">[[File:Northwest Persian Harshang Carpet.JPG|link=]]</div>
<div class="container">test</div>
9frg7xa3p9orkjea4zp2jbaz6ga8sgh
737772
737770
2026-04-12T20:14:17Z
Əkrəm
53528
737772
wikitext
text/x-wiki
<templatestyles src="User:Əkrəm/styles.css" />
<div class="container">test</div>
l6l8wzyvthm0fyi6leem01qfryhftsg
User:MrJaroslavik/GlobalCheckUserStats.js
2
174673
737751
737750
2026-04-12T12:02:45Z
MrJaroslavik
44012
e
737751
javascript
text/javascript
// GlobalCheckUserStats.js
// Features:
// - Global Audit: Scans CheckUser logs across 900+ Wikimedia projects at once.
// - Smart Categorization: Identifies roles (Local CU, Steward, Staff, etc.).
// - Steward Logic: Detects temporary access, exact durations, and longest active periods.
// - Deep Scan: Automated pagination to bypass the 500-entry API limit.
// - Robust Connection: Automated retries for 429 rate-limits and custom domain mapping.
// - Full Reporting: Outputs sortable Wikitables and detailed rights change logs.
// - Custom UI: Integrated control panel on [[Special:BlankPage/GlobalCheckUserStats]].
// - With help of Gemini 3
(function () {
'use strict';
if (mw.config.get('wgCanonicalSpecialPageName') !== 'Blankpage' || mw.config.get('wgTitle').indexOf('GlobalCheckUserStats') === -1) return;
const rawWikis = ['abstractwiki', 'abwiki', 'acewiki', 'adywiki', 'afwiki', 'afwikibooks', 'afwikiquote', 'afwiktionary', 'alswiki', 'altwiki', 'amiwiki', 'amwiki', 'amwiktionary', 'angwiki', 'angwiktionary', 'annwiki', 'anpwiki', 'anwiki', 'anwiktionary', 'arcwiki', 'arwiki', 'arwikibooks', 'arwikimedia', 'arwikinews', 'arwikiquote', 'arwikisource', 'arwikiversity', 'arwiktionary', 'arywiki', 'arzwiki', 'astwiki', 'astwiktionary', 'aswiki', 'aswikiquote', 'aswikisource', 'atjwiki', 'avkwiki', 'avwiki', 'awawiki', 'aywiki', 'aywiktionary', 'azbwiki', 'azwiki', 'azwikibooks', 'azwikiquote', 'azwikisource', 'azwiktionary', 'banwiki', 'banwikisource', 'barwiki', 'bat_smgwiki', 'bawiki', 'bawikibooks', 'bbcwiki', 'bclwiki', 'bclwikiquote', 'bclwikisource', 'bclwiktionary', 'bdrwiki', 'bdwikimedia', 'be_x_oldwiki', 'betawikiversity', 'bewiki', 'bewikibooks', 'bewikimedia', 'bewikiquote', 'bewikisource', 'bewiktionary', 'bewwiki', 'bewwiktionary', 'bgwiki', 'bgwikibooks', 'bgwikiquote', 'bgwikisource', 'bgwiktionary', 'bhwiki', 'biwiki', 'bjnwiki', 'bjnwikiquote', 'bjnwiktionary', 'blkwiki', 'blkwiktionary', 'bmwiki', 'bnwiki', 'bnwikibooks', 'bnwikiquote', 'bnwikisource', 'bnwikivoyage', 'bnwiktionary', 'bowiki', 'bpywiki', 'brwiki', 'brwikimedia', 'brwikiquote', 'brwikisource', 'brwiktionary', 'bswiki', 'bswikibooks', 'bswikinews', 'bswikiquote', 'bswikisource', 'bswiktionary', 'btmwiki', 'btmwiktionary', 'bugwiki', 'bxrwiki', 'cawiki', 'cawikibooks', 'cawikimedia', 'cawikinews', 'cawikiquote', 'cawikisource', 'cawiktionary', 'cbk_zamwiki', 'cdowiki', 'cebwiki', 'cewiki', 'chrwiki', 'chrwiktionary', 'chwiki', 'chywiki', 'ckbwiki', 'ckbwiktionary', 'commonswiki', 'cowiki', 'cowikimedia', 'cowiktionary', 'crhwiki', 'csbwiki', 'csbwiktionary', 'cswiki', 'cswikibooks', 'cswikinews', 'cswikiquote', 'cswikisource', 'cswikiversity', 'cswikivoyage', 'cswiktionary', 'cuwiki', 'cvwiki', 'cvwikibooks', 'cywiki', 'cywikibooks', 'cywikiquote', 'cywikisource', 'cywiktionary', 'dagwiki', 'dawiki', 'dawikibooks', 'dawikiquote', 'dawikisource', 'dawiktionary', 'dewiki', 'dewikibooks', 'dewikinews', 'dewikiquote', 'dewikisource', 'dewikiversity', 'dewikivoyage', 'dewiktionary', 'dgawiki', 'dinwiki', 'diqwiki', 'diqwiktionary', 'dkwikimedia', 'dsbwiki', 'dtpwiki', 'dtywiki', 'dvwiki', 'dvwiktionary', 'dzwiki', 'eewiki', 'elwiki', 'elwikibooks', 'elwikinews', 'elwikiquote', 'elwikisource', 'elwikiversity', 'elwikivoyage', 'elwiktionary', 'emlwiki', 'enwiki', 'enwikibooks', 'enwikinews', 'enwikiquote', 'enwikisource', 'enwikiversity', 'enwikivoyage', 'enwiktionary', 'eowiki', 'eowikibooks', 'eowikinews', 'eowikiquote', 'eowikisource', 'eowikivoyage', 'eowiktionary', 'eswiki', 'eswikibooks', 'eswikinews', 'eswikiquote', 'eswikisource', 'eswikiversity', 'eswikivoyage', 'eswiktionary', 'etwiki', 'etwikibooks', 'eewikimedia', 'etwikiquote', 'etwikisource', 'etwiktionary', 'euwiki', 'euwikibooks', 'euwikiquote', 'euwikisource', 'euwiktionary', 'extwiki', 'fatwiki', 'fawiki', 'fawikibooks', 'fawikinews', 'fawikiquote', 'fawikisource', 'fawikivoyage', 'fawiktionary', 'ffwiki', 'fiu_vrowiki', 'fiwiki', 'fiwikibooks', 'fiwikimedia', 'fiwikinews', 'fiwikiquote', 'fiwikisource', 'fiwikiversity', 'fiwikivoyage', 'fiwiktionary', 'fjwiki', 'fjwiktionary', 'fonwiki', 'foundationwiki', 'fowiki', 'fowikisource', 'fowiktionary', 'frpwiki', 'frrwiki', 'frwiki', 'frwikibooks', 'frwikinews', 'frwikiquote', 'frwikisource', 'frwikiversity', 'frwikivoyage', 'frwiktionary', 'furwiki', 'fywiki', 'fywikibooks', 'fywiktionary', 'gagwiki', 'ganwiki', 'gawiki', 'gawiktionary', 'gcrwiki', 'gdwiki', 'gdwiktionary', 'glkwiki', 'glwiki', 'glwikibooks', 'glwikiquote', 'glwikisource', 'glwiktionary', 'gnwiki', 'gnwiktionary', 'gomwiki', 'gomwiktionary', 'gorwiki', 'gorwikiquote', 'gorwiktionary', 'gotwiki', 'gpewiki', 'gucwiki', 'gurwiki', 'guwiki', 'guwikiquote', 'guwikisource', 'guwiktionary', 'guwwiki', 'guwwikinews', 'guwwikiquote', 'guwwiktionary', 'gvwiki', 'gvwiktionary', 'hakwiki', 'hawiki', 'hawiktionary', 'hawwiki', 'hewiki', 'hewikibooks', 'hewikinews', 'hewikiquote', 'hewikisource', 'hewikivoyage', 'hewiktionary', 'hifwiki', 'hifwiktionary', 'hiwiki', 'hiwikibooks', 'hiwikiquote', 'hiwikisource', 'hiwikiversity', 'hiwikivoyage', 'hiwiktionary', 'hrwiki', 'hrwikibooks', 'hrwikiquote', 'hrwikisource', 'hrwiktionary', 'hsbwiki', 'hsbwiktionary', 'htwiki', 'huwiki', 'huwikibooks', 'huwikisource', 'huwiktionary', 'hywiki', 'hywikibooks', 'hywikiquote', 'hywikisource', 'hywiktionary', 'hywwiki', 'iawiki', 'iawikibooks', 'iawiktionary', 'ibawiki', 'idwiki', 'idwikibooks', 'idwikiquote', 'idwikisource', 'idwikivoyage', 'idwiktionary', 'iewiki', 'iewiktionary', 'iglwiki', 'igwiki', 'igwikiquote', 'igwiktionary', 'ikwiki', 'ilowiki', 'incubatorwiki', 'inhwiki', 'iowiki', 'iowiktionary', 'iswiki', 'iswikibooks', 'iswikiquote', 'iswikisource', 'iswiktionary', 'itwiki', 'itwikibooks', 'itwikinews', 'itwikiquote', 'itwikisource', 'itwikiversity', 'itwikivoyage', 'itwiktionary', 'iuwiki', 'iuwiktionary', 'jamwiki', 'jawiki', 'jawikibooks', 'jawikinews', 'jawikisource', 'jawikiversity', 'jawikivoyage', 'jawiktionary', 'jbowiki', 'jbowiktionary', 'jvwiki', 'jvwikisource', 'jvwiktionary', 'kaawiki', 'kaawiktionary', 'kabwiki', 'kaiwiki', 'kajwiki', 'kawiki', 'kawikibooks', 'kawikiquote', 'kawikisource', 'kawiktionary', 'kbdwiki', 'kbdwiktionary', 'kbpwiki', 'kcgwiki', 'kcgwiktionary', 'kgewiki', 'kgwiki', 'kiwiki', 'kkwiki', 'kkwikibooks', 'kkwiktionary', 'klwiktionary', 'kmwiki', 'kmwikibooks', 'kmwiktionary', 'kncwiki', 'knwiki', 'knwikiquote', 'knwikisource', 'knwiktionary', 'koiwiki', 'kowiki', 'kowikibooks', 'kowikinews', 'kowikiquote', 'kowikisource', 'kowikiversity', 'kowiktionary', 'krcwiki', 'kshwiki', 'kswiki', 'kswiktionary', 'kuswiki', 'kuwiki', 'kuwikibooks', 'kuwikiquote', 'kuwiktionary', 'kvwiki', 'kwwiki', 'kwwiktionary', 'kywiki', 'kywikiquote', 'kywiktionary', 'labswiki', 'ladwiki', 'lawiki', 'lawikibooks', 'lawikiquote', 'lawikisource', 'lawiktionary', 'lbewiki', 'lbwiki', 'lbwiktionary', 'lezwiki', 'lfnwiki', 'lgwiki', 'lijwiki', 'lijwikisource', 'liwiki', 'liwikibooks', 'liwikinews', 'liwikiquote', 'liwikisource', 'liwiktionary', 'lldwiki', 'lmowiki', 'lmowiktionary', 'lnwiki', 'lnwiktionary', 'lowiki', 'lowiktionary', 'ltgwiki', 'ltwiki', 'ltwikibooks', 'ltwikiquote', 'ltwikisource', 'ltwiktionary', 'lvwiki', 'lvwiktionary', 'madwiki', 'madwikisource', 'madwiktionary', 'maiwiki', 'map_bmswiki', 'mdfwiki', 'mediawikiwiki', 'metawiki', 'mgwiki', 'mgwikibooks', 'mgwiktionary', 'mhrwiki', 'minwiki', 'minwikibooks', 'minwikisource', 'minwiktionary', 'miwiki', 'miwiktionary', 'mkwiki', 'mkwikibooks', 'mkwikimedia', 'mkwikisource', 'mkwiktionary', 'mlwiki', 'mlwikibooks', 'mlwikiquote', 'mlwikisource', 'mlwiktionary', 'mniwiki', 'mniwiktionary', 'mnwiki', 'mnwiktionary', 'mnwwiki', 'mnwwiktionary', 'moswiki', 'mrjwiki', 'mrwiki', 'mrwikibooks', 'mrwikiquote', 'mrwikisource', 'mrwiktionary', 'mswiki', 'mswikibooks', 'mswikiquote', 'mswikisource', 'mswiktionary', 'mtwiki', 'mtwiktionary', 'mwlwiki', 'mxwikimedia', 'myvwiki', 'mywiki', 'mywikisource', 'mywiktionary', 'mznwiki', 'nahwiki', 'nahwiktionary', 'napwiki', 'napwikisource', 'nawiktionary', 'nds_nlwiki', 'ndswiki', 'ndswiktionary', 'newiki', 'newikibooks', 'newiktionary', 'newwiki', 'niawiki', 'niawiktionary', 'nlwiki', 'nlwikibooks', 'nlwikimedia', 'nlwikinews', 'nlwikiquote', 'nlwikisource', 'nlwikivoyage', 'nlwiktionary', 'nnwiki', 'nnwikiquote', 'nnwiktionary', 'novwiki', 'nowiki', 'nowikibooks', 'nowikimedia', 'nowikinews', 'nowikiquote', 'nowikisource', 'nowiktionary', 'nqowiki', 'nrmwiki', 'nrwiki', 'nsowiki', 'nupwiki', 'nvwiki', 'nycwikimedia', 'nywiki', 'ocwiki', 'ocwikibooks', 'ocwiktionary', 'olowiki', 'omwiki', 'omwiktionary', 'orwiki', 'orwikisource', 'orwiktionary', 'oswiki', 'outreachwiki', 'pagwiki', 'pamwiki', 'papwiki', 'pawiki', 'pawikibooks', 'pawikisource', 'pawiktionary', 'pcdwiki', 'pcmwiki', 'pcmwikiquote', 'pdcwiki', 'pflwiki', 'piwiki', 'plwiki', 'plwikibooks', 'plwikimedia', 'plwikinews', 'plwikiquote', 'plwikisource', 'plwikivoyage', 'plwiktionary', 'pmswiki', 'pmswikisource', 'pnbwiki', 'pnbwiktionary', 'pntwiki', 'pplwiki', 'pswiki', 'pswikivoyage', 'pswiktionary', 'ptwiki', 'ptwikibooks', 'ptwikimedia', 'ptwikinews', 'ptwikiquote', 'ptwikisource', 'ptwikiversity', 'ptwikivoyage', 'ptwiktionary', 'pwnwiki', 'quwiktionary', 'rkiwiki', 'rmwiki', 'rmywiki', 'rnwiki', 'roa_rupwiki', 'roa_rupwiktionary', 'roa_tarawiki', 'rowiki', 'rowikibooks', 'rowikinews', 'rowikiquote', 'rowiktionary', 'rskwiki', 'ruewiki', 'ruwiki', 'ruwikibooks', 'ruwikinews', 'ruwikiquote', 'ruwikisource', 'ruwikiversity', 'ruwikivoyage', 'ruwiktionary', 'rwwiki', 'rwwiktionary', 'sahwiki', 'sahwikiquote', 'sahwikisource', 'satwiki', 'satwiktionary', 'sawiki', 'sawikibooks', 'sawikiquote', 'sawikisource', 'sawiktionary', 'scnwiki', 'scnwiktionary', 'scowiki', 'scwiki', 'sdwiki', 'sdwiktionary', 'sewiki', 'sewikimedia', 'sgwiki', 'sgwiktionary', 'shiwiki', 'shnwiki', 'shnwikibooks', 'shnwikinews', 'shnwikivoyage', 'shnwiktionary', 'shwiki', 'shwiktionary', 'shywiktionary', 'simplewiki', 'simplewiktionary', 'siwiki', 'siwikibooks', 'siwiktionary', 'skwiki', 'skrwiki', 'skrwiktionary', 'slwiki', 'slwikibooks', 'slwikiquote', 'slwikisource', 'slwikiversity', 'slwiktionary', 'smnwiki', 'smwiki', 'smwiktionary', 'snwiki', 'sourceswiki', 'sowiki', 'sowiktionary', 'specieswiki', 'sqwiki', 'sqwikibooks', 'sqwikinews', 'sqwikiquote', 'sqwiktionary', 'srnwiki', 'srwiki', 'srwikibooks', 'srwikinews', 'srwikiquote', 'srwikisource', 'srwiktionary', 'sswiki', 'sswiktionary', 'stqwiki', 'stwiki', 'stwiktionary', 'suwiki', 'suwikiquote', 'suwikisource', 'suwiktionary', 'svwiki', 'svwikibooks', 'svwikinews', 'svwikiquote', 'svwikisource', 'svwikiversity', 'svwikivoyage', 'svwiktionary', 'swwiki', 'swwiktionary', 'sylwiki', 'szlwiki', 'szywiki', 'tawiki', 'tawikibooks', 'tawikinews', 'tawikiquote', 'tawikisource', 'tawiktionary', 'taywiki', 'tcywiki', 'tcywikisource', 'tcywiktionary', 'tddwiki', 'tewiki', 'tewikibooks', 'tewikiquote', 'tewikisource', 'tewiktionary', 'tgwiki', 'tgwikibooks', 'tgwiktionary', 'thwiki', 'thwikibooks', 'thwikimedia', 'thwikiquote', 'thwikisource', 'thwiktionary', 'tigwiki', 'tiwiki', 'tiwiktionary', 'tkwiki', 'tkwiktionary', 'tlwiki', 'tlwikibooks', 'tlwikiquote', 'tlwikisource', 'tlwiktionary', 'tlywiki', 'tnwiki', 'tnwiktionary', 'tokwiki', 'towiki', 'tpiwiki', 'tpiwiktionary', 'trvwiki', 'trwiki', 'trwikibooks', 'trwikimedia', 'trwikinews', 'trwikiquote', 'trwikisource', 'trwikivoyage', 'trwiktionary', 'tswiki', 'tswiktionary', 'ttwiki', 'ttwikibooks', 'ttwikiquote', 'ttwiktionary', 'tumwiki', 'twwiki', 'twwiktionary', 'tyvwiki', 'tywiki', 'uawikimedia', 'udmwiki', 'ugwiki', 'ugwiktionary', 'ukwiki', 'ukwikibooks', 'ukwikinews', 'ukwikiquote', 'ukwikisource', 'ukwikivoyage', 'ukwiktionary', 'urwiki', 'urwikibooks', 'urwikiquote', 'urwikisource', 'urwiktionary', 'uzwiki', 'uzwikiquote', 'uzwiktionary', 'vecwiki', 'vecwikisource', 'vecwiktionary', 'vepwiki', 'vewiki', 'viwiki', 'viwikibooks', 'viwikiquote', 'viwikisource', 'viwikivoyage', 'viwiktionary', 'vlswiki', 'vowiki', 'vowiktionary', 'warwiki', 'wawiki', 'wawikisource', 'wawiktionary', 'wikidatawiki', 'wikimaniawiki', 'wowiki', 'wowiktionary', 'wuuwiki', 'xalwiki', 'xhwiki', 'xmfwiki', 'yiwiki', 'yiwikisource', 'yiwiktionary', 'yowiki', 'zawiki', 'zeawiki', 'zghwiki', 'zghwiktionary', 'zh_classicalwiki', 'zh_min_nanwiki', 'zh_min_nanwikisource', 'zh_min_nanwiktionary', 'zh_yuewiki', 'zhwiki', 'zhwikibooks', 'zhwikinews', 'zhwikiquote', 'zhwikisource', 'zhwikiversity', 'zhwikivoyage', 'zhwiktionary', 'zuwiki', 'zuwiktionary', 'test2wiki', 'testwiki'];
const DELAY_MS = 800;
const sleep = ms => new Promise(r => setTimeout(r, ms));
let userCache = {};
let historyCache = {};
function getDomain(db) {
const mapping = {
'commonswiki': 'commons.wikimedia.org', 'metawiki': 'meta.wikimedia.org', 'wikidatawiki': 'www.wikidata.org',
'mediawikiwiki': 'www.mediawiki.org', 'sourceswiki': 'wikisource.org', 'foundationwiki': 'foundation.wikimedia.org',
'incubatorwiki': 'incubator.wikimedia.org', 'outreachwiki': 'outreach.wikimedia.org',
'be_x_oldwiki': 'be-tarask.wikipedia.org', 'labswiki': 'wikitech.wikimedia.org',
'wikimaniawiki': 'wikimania.wikimedia.org', 'specieswiki': 'species.wikimedia.org',
'abstractwiki': 'abstract.wikipedia.org', 'betawikiversity': 'beta.wikiversity.org',
'mo_wiki': 'ro.wikipedia.org',
'testwiki': 'test.wikipedia.org', 'test2wiki': 'test2.wikipedia.org',
'wikifunctionswiki': 'www.wikifunctions.org',
'testcommonswiki': 'test-commons.wikimedia.org',
'testwikidatawiki': 'test.wikidata.org',
};
if (mapping[db]) return mapping[db];
const name = db.replace(/_/g, '-');
if (name.endsWith('wikisource')) return name.slice(0, -10) + '.wikisource.org';
if (name.endsWith('wikiversity')) return name.slice(0, -11) + '.wikiversity.org';
if (name.endsWith('wiktionary')) return name.slice(0, -10) + '.wiktionary.org';
if (name.endsWith('wikivoyage')) return name.slice(0, -10) + '.wikivoyage.org';
if (name.endsWith('wikibooks')) return name.slice(0, -9) + '.wikibooks.org';
if (name.endsWith('wikiquote')) return name.slice(0, -9) + '.wikiquote.org';
if (name.endsWith('wikinews')) return name.slice(0, -8) + '.wikinews.org';
if (name.endsWith('wikimedia')) return name.slice(0, -9) + '.wikimedia.org';
if (name.endsWith('wiki')) return name.slice(0, -4) + '.wikipedia.org';
return name + '.wikipedia.org';
}
function getInterwikiPrefix(db) {
const specials = {
'commonswiki': 'commons', 'metawiki': 'm', 'wikidatawiki': 'd',
'mediawikiwiki': 'mw', 'specieswiki': 'wikispecies',
'foundationwiki': 'wikimedia', 'sourceswiki': 'wikisource'
};
if (specials[db]) return specials[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 'wikimedia:' + name.slice(0, -9);
if (name.endsWith('wiki')) return 'w:' + name.slice(0, -4);
return 'w:' + name;
}
mw.loader.using(['mediawiki.api', 'mediawiki.ForeignApi']).then(function () {
const metaApi = new mw.ForeignApi('https://meta.wikimedia.org/w/api.php');
const now = new Date();
let results = {}, emptyWikis = [], failedWikis = [], scannedWikis = [], isRunning = false;
let currentFilterMode = 'all';
let currentUserFilter = 'all';
function setupUI() {
const currentYear = now.getFullYear();
let yearOpts = '';
for (let y = currentYear; y >= 2005; y--) yearOpts += `<option value="${y}">${y}</option>`;
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>`;
}
$('#firstHeading').text('GlobalCheckUserStats.js');
$('#mw-content-text').empty().append(`
<div style="border:1px solid #a2a9b1; padding:15px; background:#f8f9fa;">
<h3>Stats Range</h3>
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 style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>Wiki Filter:</strong>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="wiki-mode" id="btn-all" checked> All wikis</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="wiki-mode" id="btn-except"> All wikis except</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><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; font-size:12px; line-height:18px;" title="Show all wiki DB names">?</span>
</div>
<div id="filter-input-container" style="display:none; margin-top:10px;">
<input id="wiki-filter" type="text" style="width:100%; max-width:600px;" placeholder="">
</div>
<div style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>User Filter:</strong>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-all" checked> All users</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-local"> Only Local CUs</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-steward"> Only Stewards</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; color:#202122;">
<strong>Available Wikis (alphabetical):</strong><br>${[...rawWikis].sort().join(', ')}
</div>
<div style="margin-top:20px;">
<button id="start" class="mw-ui-button mw-ui-progressive">Run GlobalCheckUserStats.js</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="${rawWikis.length}" style="width:100%"></progress></div>
<textarea id="out" style="width:100%; height:450px; margin-top:10px; display:none; font-family:monospace; font-size:12px;"></textarea>
</div>
`);
$('#btn-all').click(() => { currentFilterMode = 'all'; $('#filter-input-container').hide(); });
$('#btn-except').click(() => { currentFilterMode = 'exclude'; $('#filter-input-container').show(); $('#wiki-filter').attr('placeholder', 'Exclude (dbname1, dbname2)...').focus(); });
$('#btn-only').click(() => { currentFilterMode = 'include'; $('#filter-input-container').show(); $('#wiki-filter').attr('placeholder', 'Only (dbname1, dbname2)...').focus(); });
$('#u-all').click(() => { currentUserFilter = 'all'; });
$('#u-local').click(() => { currentUserFilter = 'local'; });
$('#u-steward').click(() => { currentUserFilter = 'steward'; });
$('#wiki-help-trigger').click(() => $('#wiki-list-help').toggle());
$('#start').click(() => runAudit($('#y-f').val(), $('#m-f').val(), $('#y-t').val(), $('#m-t').val()));
$('#stop').click(() => isRunning = false);
}
async function fetchWikiMetrics(db) {
try {
const api = new mw.ForeignApi(`https://${getDomain(db)}/w/api.php`);
const res = await api.get({
action: 'query', meta: 'siteinfo', siprop: 'statistics', formatversion: 2
});
return { active: res.query.statistics.activeusers || 0 };
} catch (e) { return { active: 0 }; }
}
async function checkGlobalHistory(user, start, end) {
try {
const res = await metaApi.get({
action: 'query', list: 'logevents', letype: 'gblrights', letitle: 'User:' + user, lelimit: 'max', formatversion: 2
});
const logs = res.query.logevents || [];
const auditStart = new Date(start), auditEnd = new Date(end);
let state = { steward: false, staff: false, ombuds: false };
const priorLogs = logs.filter(l => new Date(l.timestamp) <= auditStart).sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
if (priorLogs.length > 0) {
const p = priorLogs[0].params || {};
const groupsInLog = p.newGroups || p[1] || [];
state.steward = groupsInLog.includes('steward');
state.staff = groupsInLog.includes('staff');
state.ombuds = groupsInLog.includes('ombuds');
}
let held = { wasSteward: state.steward, wasStaff: state.staff, wasOmbuds: state.ombuds };
logs.filter(l => { const ts = new Date(l.timestamp); return ts > auditStart && ts < auditEnd; }).forEach(e => {
const p = e.params || {};
const added = p.newGroups || p[1] || [];
if (added.includes('steward')) held.wasSteward = true;
if (added.includes('staff')) held.wasStaff = true;
if (added.includes('ombuds')) held.wasOmbuds = true;
});
return held;
} catch (e) { return { wasSteward: false, wasStaff: false, wasOmbuds: false }; }
}
async function fetchUserData(user, db, start, end) {
const auditStart = new Date(start), auditEnd = new Date(end);
// 1. FETCH CURRENT GLOBAL GROUPS (Cached)
if (!userCache[user]) {
try {
const gres = await metaApi.get({ action: 'query', meta: 'globaluserinfo', guiprop: 'groups', guiuser: user, formatversion: 2 });
userCache[user] = gres.query.globaluserinfo.groups || [];
} catch (e) { userCache[user] = []; }
}
const gGroups = userCache[user];
const isGloballySteward = gGroups.includes('steward');
const isGloballyStaff = gGroups.includes('staff');
const isGloballyOmbuds = gGroups.includes('ombuds') || gGroups.includes('ombudsman');
// 2. FETCH GLOBAL HISTORY (To identify roles held during the period but lost since)
if (!historyCache[user]) historyCache[user] = await checkGlobalHistory(user, start, end);
const historyRes = historyCache[user];
// 3. CHECK CURRENT LOCAL CHECKUSER STATUS
let isCurrentLocal = false;
try {
const localApi = new mw.ForeignApi(`https://${getDomain(db)}/w/api.php`);
const ures = await localApi.get({ action: 'query', list: 'users', ususers: user, usprop: 'groups', formatversion: 2 });
const lGroups = (ures.query.users[0] && ures.query.users[0].groups) || [];
isCurrentLocal = lGroups.includes('checkuser');
} catch (e) {}
const target = 'User:' + user + '@' + db;
let logText = '', addTime = null, removeTime = null, isSelfAssign = false, maxDurationMins = -1, longestTimeStr = "", assignCount = 0;
// --- 4. DEEP RIGHTS LOG SCAN (Pagination to bypass 500-limit) ---
let events = [];
let continueToken = null;
let finished = false;
let hasLocalRightsInPeriod = false;
try {
while (!finished) {
let params = { action: 'query', list: 'logevents', letype: 'rights', letitle: target, ledir: 'older', lelimit: 'max', formatversion: 2 };
if (continueToken) Object.assign(params, continueToken);
const res = await metaApi.get(params);
events = events.concat(res.query.logevents || []);
if (res.continue) continueToken = res.continue;
else finished = true;
}
let logEntries = [];
let pendingRemoved = null;
let lastAddedTime = null; // Track last processed ADD to prevent duplicates
for (let i = 0; i < events.length; i++) {
const e = events[i], p = e.params || {};
const cuAdded = (p.add || []).includes('checkuser') || ((p.newgroups || []).includes('checkuser') && !(p.oldgroups || []).includes('checkuser'));
const cuRemoved = (p.remove || []).includes('checkuser') || (!(p.newgroups || []).includes('checkuser') && (p.oldgroups || []).includes('checkuser'));
const eventDate = new Date(e.timestamp);
const exactTime = e.timestamp.replace('T', ' ').replace('Z', '');
if (cuAdded) addTime = eventDate;
if (cuRemoved) removeTime = eventDate;
if (addTime && addTime <= auditEnd && (!removeTime || removeTime >= auditStart)) hasLocalRightsInPeriod = true;
if (eventDate >= auditStart && eventDate <= auditEnd && (cuAdded || cuRemoved)) {
let expiryDate = null;
if (p.newmetadata) {
const cuMeta = p.newmetadata.find(m => m.group === 'checkuser');
if (cuMeta && cuMeta.expiry && cuMeta.expiry !== 'infinity') expiryDate = new Date(cuMeta.expiry);
}
const isSelf = (e.user === user || !e.user) ? " (Self-assign)" : "";
if (e.user === user || !e.user) isSelfAssign = true;
if (cuAdded) {
if (pendingRemoved) {
const diffMins = Math.round(Math.abs(pendingRemoved.date - eventDate) / 60000);
const d = Math.floor(diffMins / 1440), h = Math.floor((diffMins % 1440) / 60), m = diffMins % 60;
const durStr = `${d > 0 ? d+'d ' : ''}${h > 0 ? h+'h ' : ''}${m}m`;
logEntries.unshift(`* ADDED: ${exactTime} | REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf} (Duration: ${durStr})`);
if (diffMins > maxDurationMins) { maxDurationMins = diffMins; longestTimeStr = durStr; }
assignCount++;
pendingRemoved = null;
lastAddedTime = exactTime;
} else if (expiryDate) {
const diffMins = Math.round(Math.abs(expiryDate - eventDate) / 60000);
const d = Math.floor(diffMins / 1440), h = Math.floor((diffMins % 1440) / 60), m = diffMins % 60;
const durStr = `${d > 0 ? d+'d ' : ''}${h > 0 ? h+'h ' : ''}${m}m`;
const exactExpiryTime = expiryDate.toISOString().replace('T', ' ').substring(0, 19);
logEntries.unshift(`* ADDED: ${exactTime} | EXPIRED: ${exactExpiryTime} by ${e.user}${isSelf} (Duration: ${durStr})`);
if (diffMins > maxDurationMins) { maxDurationMins = diffMins; longestTimeStr = durStr; }
assignCount++;
lastAddedTime = exactTime;
} else if (lastAddedTime !== exactTime) { // Xaosflux check
logEntries.unshift(`* ADDED: ${exactTime} | REMOVED: (Active/Not removed) by ${e.user}${isSelf}`);
lastAddedTime = exactTime;
}
} else if (cuRemoved) {
if (pendingRemoved) logEntries.unshift(`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf}`);
pendingRemoved = { time: exactTime, date: eventDate, user: e.user, isSelf: isSelf };
}
}
}
if (pendingRemoved) logEntries.unshift(`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf}`);
if (logEntries.length) logText = '\n' + logEntries.join('\n');
// --- ROLE CLASSIFICATION (Opravená priorita) ---
let roleLabel = "";
const hasActions = (results[db] && results[db][user] && results[db][user].total > 0);
let wasCU = hasLocalRightsInPeriod || hasActions;
// 1. Nejdřív Staff/Ombuds
if (isGloballyStaff || historyRes.wasStaff) {
roleLabel = isGloballyStaff ? "Current Staff" : "Former Staff (in period)";
}
else if (isGloballyOmbuds || historyRes.wasOmbuds) {
roleLabel = isGloballyOmbuds ? "Current Ombudsman" : "Former Ombudsman (in period)";
}
// 2. Potom CheckUser (Current vs Former)
else if (isCurrentLocal) {
roleLabel = "Current Local CheckUser";
}
else if (wasCU) {
roleLabel = "Former Local CheckUser (in period)";
}
// 3. A až nakonec Steward (pokud dělal akce bez CU práv)
else if (longestTimeStr && isSelfAssign && (isGloballySteward || historyRes.wasSteward)) {
roleLabel = `Steward action (Self-assign: ${assignCount > 1 ? assignCount + 'x, longest ' : ''}${longestTimeStr})`;
}
else if (isGloballySteward || historyRes.wasSteward) {
roleLabel = isGloballySteward ? "Current Steward" : "Former Steward (in period)";
}
else {
roleLabel = "Unknown role";
}
return { role: roleLabel, log: logText };
} catch (err) {
return { role: "Error fetching data", log: "" };
}
}
async function runAudit(yf, mf, yt, mt) {
isRunning = true; userCache = {}; historyCache = {}; results = {}; emptyWikis = []; failedWikis = []; scannedWikis = [];
const filterText = $('#wiki-filter').val().trim().toLowerCase();
const filterList = filterText ? filterText.split(',').map(s => s.trim()).filter(s => s !== "") : [];
let wikisToScan = rawWikis;
if (currentFilterMode === 'include') wikisToScan = rawWikis.filter(w => filterList.includes(w));
else if (currentFilterMode === 'exclude') wikisToScan = rawWikis.filter(w => !filterList.includes(w));
if (wikisToScan.length === 0) { alert("No wikis selected!"); return; }
$('#start').prop('disabled', true); $('#stop').prop('disabled', false); $('#out').hide();
$('#bar').attr('max', wikisToScan.length).val(0);
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`;
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++; }
}
for (let i = 0; i < wikisToScan.length; i++) {
if (!isRunning) break;
const db = wikisToScan[i]; scannedWikis.push(db);
$('#status-msg').text(`Scanning ${db} (${i + 1}/${wikisToScan.length})...`);
let successLocal = false, continueToken = null;
// Added max 3 retries logic per wiki
let retryCount = 0;
while (!successLocal && isRunning) {
try {
const api = new mw.ForeignApi(`https://${getDomain(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 api.get(params);
const ent = res.query?.checkuserlog?.entries || [];
if (ent.length) {
if (!results[db]) results[db] = {};
ent.forEach(e => {
const u = e.checkuser;
if (!results[db][u]) results[db][u] = { total: 0, months: {} };
const mKey = e.timestamp.slice(0, 7);
results[db][u].total++; results[db][u].months[mKey] = (results[db][u].months[mKey] || 0) + 1;
});
}
if (res.continue && isRunning) continueToken = res.continue;
else { if (!results[db]) emptyWikis.push(db); successLocal = true; }
retryCount = 0; // Reset on success
} catch (err) {
if (err?.status === 429) {
retryCount++;
if (retryCount <= 3) {
const retryAfter = parseInt(err.xhr?.getResponseHeader('Retry-After')) || 60;
$('#status-msg').html(`<span style="color:orange; font-weight:bold;">Rate limit on ${db}! Waiting ${retryAfter}s... (Try ${retryCount}/3)</span>`);
await sleep(retryAfter * 1000);
continue;
} else {
$('#status-msg').html(`<span style="color:red; font-weight:bold;">Skipping ${db} after 3 failed retries.</span>`);
failedWikis.push(db);
successLocal = true;
}
} else {
failedWikis.push(db);
successLocal = true;
}
}
}
$('#bar').val(i + 1); await sleep(DELAY_MS);
}
$('#status-msg').text(`Generating report...`);
const today = new Date().toISOString().split('T')[0];
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' (UTC)';
let wt = `== Global CheckUser Stats (${mf}/${yf} - ${mt}/${yt}) ==\n''Report generated on: ${timestamp}''\n\n`;
let rightsLog = `\n== Rights Log (In Period) ==\n`;
wt += `{| class="wikitable sortable" style="font-size:90%; text-align:right;"\n! Wiki / User !! Category !! '''Total''' !! per 1k active users ${monthCols.map(m => `!! ${m.label}`).join(' ')}\n`;
const sortedDBs = Object.keys(results).sort();
for (const db of sortedDBs) {
// Safeguard against missing wikis during report generation
let metrics = { active: 0 };
try {
metrics = await fetchWikiMetrics(db);
} catch (e) {
console.warn(`Metrics failed for ${db}`);
}
const fmtActive = metrics.active.toLocaleString('en-US');
const iwPrefix = getInterwikiPrefix(db);
const projectLink = `[[${iwPrefix}:|${db}]]`;
const logLink = `[[${iwPrefix}:Special:CheckUserLog|(log)]]`;
wt += `|-\n! colspan="${4 + monthCols.length}" style="background:#eaecf0; text-align:center;" | ${projectLink} <small style="font-weight:normal; color:#54595d;">(Active Users as of ${today}: ${fmtActive})</small> — ${logLink}\n`;
let wikiRows = [], wT = 0, wMS = {}; monthCols.forEach(col => wMS[col.key] = 0);
const projectUsers = Object.keys(results[db]).sort();
for (const user of projectUsers) {
// Safeguard against missing users during report generation
let m = { role: "Unknown/Error", log: "" };
try {
m = await fetchUserData(user, db, START, END);
} catch (e) {
m.role = "Data Error (429)";
}
const isSteward = m.role.includes("Steward") || m.role.includes("Staff") || m.role.includes("Ombudsman");
const isLocalCU = m.role.includes("Local CheckUser");
if (currentUserFilter === 'local' && !isLocalCU) continue;
if (currentUserFilter === 'steward' && !isSteward) continue;
const uD = results[db][user];
wT += uD.total; monthCols.forEach(col => wMS[col.key] += (uD.months[col.key] || 0));
let uP1kU = metrics.active > 0 ? ((uD.total / metrics.active) * 1000).toFixed(1) : "0.0";
let row = `|-\n| style="text-align:left;" | ${user}@${db} || <small>${m.role}</small> || '''${uD.total}''' || ${uP1kU}`;
monthCols.forEach(col => row += ` || ${uD.months[col.key] || 0}`);
wikiRows.push({ html: row + "\n", log: m.log ? `'''${user}@${db}''':${m.log}\n\n` : "" });
}
let wP1kU = metrics.active > 0 ? ((wT / metrics.active) * 1000).toFixed(1) : "0.0";
let totalRow = `|- style="background:#f8f9fa; font-weight:bold;"\n| style="text-align:left;" | TOTAL ${db} || — || ${wT} || ${wP1kU}`;
monthCols.forEach(col => totalRow += ` || ${wMS[col.key]}`);
wt += totalRow + "\n";
wikiRows.forEach(r => { wt += r.html; if (r.log) rightsLog += r.log; });
}
wt += `|}\n\n== Projects with 0 actions ==\n<div style="font-size:85%; color:#54595d;">${emptyWikis.sort().join(', ')}</div>\n`;
wt += rightsLog + `\n\n<references />\n`;
$('#out').val(wt).show();
$('#status-msg').text(`Done.`); $('#start').prop('disabled', false); $('#stop').prop('disabled', true);
}
setupUI();
});
})();
f281txq37ms2ujarllskdhqln0jkqnp
737752
737751
2026-04-12T12:07:41Z
MrJaroslavik
44012
e
737752
javascript
text/javascript
// GlobalCheckUserStats.js
// Features:
// - Global Audit: Scans CheckUser logs across 900+ Wikimedia projects at once.
// - Smart Categorization: Identifies roles (Local CU, Steward, Staff, etc.).
// - Steward Logic: Detects temporary access, exact durations, and longest active periods.
// - Deep Scan: Automated pagination to bypass the 500-entry API limit.
// - Robust Connection: Automated retries for 429 rate-limits and custom domain mapping.
// - Full Reporting: Outputs sortable Wikitables and detailed rights change logs.
// - Custom UI: Integrated control panel on [[Special:BlankPage/GlobalCheckUserStats]].
// - With help of Gemini 3
(function () {
'use strict';
if (mw.config.get('wgCanonicalSpecialPageName') !== 'Blankpage' || mw.config.get('wgTitle').indexOf('GlobalCheckUserStats') === -1) return;
const rawWikis = ['abstractwiki', 'abwiki', 'acewiki', 'adywiki', 'afwiki', 'afwikibooks', 'afwikiquote', 'afwiktionary', 'alswiki', 'altwiki', 'amiwiki', 'amwiki', 'amwiktionary', 'angwiki', 'angwiktionary', 'annwiki', 'anpwiki', 'anwiki', 'anwiktionary', 'arcwiki', 'arwiki', 'arwikibooks', 'arwikimedia', 'arwikinews', 'arwikiquote', 'arwikisource', 'arwikiversity', 'arwiktionary', 'arywiki', 'arzwiki', 'astwiki', 'astwiktionary', 'aswiki', 'aswikiquote', 'aswikisource', 'atjwiki', 'avkwiki', 'avwiki', 'awawiki', 'aywiki', 'aywiktionary', 'azbwiki', 'azwiki', 'azwikibooks', 'azwikiquote', 'azwikisource', 'azwiktionary', 'banwiki', 'banwikisource', 'barwiki', 'bat_smgwiki', 'bawiki', 'bawikibooks', 'bbcwiki', 'bclwiki', 'bclwikiquote', 'bclwikisource', 'bclwiktionary', 'bdrwiki', 'bdwikimedia', 'be_x_oldwiki', 'betawikiversity', 'bewiki', 'bewikibooks', 'bewikimedia', 'bewikiquote', 'bewikisource', 'bewiktionary', 'bewwiki', 'bewwiktionary', 'bgwiki', 'bgwikibooks', 'bgwikiquote', 'bgwikisource', 'bgwiktionary', 'bhwiki', 'biwiki', 'bjnwiki', 'bjnwikiquote', 'bjnwiktionary', 'blkwiki', 'blkwiktionary', 'bmwiki', 'bnwiki', 'bnwikibooks', 'bnwikiquote', 'bnwikisource', 'bnwikivoyage', 'bnwiktionary', 'bowiki', 'bpywiki', 'brwiki', 'brwikimedia', 'brwikiquote', 'brwikisource', 'brwiktionary', 'bswiki', 'bswikibooks', 'bswikinews', 'bswikiquote', 'bswikisource', 'bswiktionary', 'btmwiki', 'btmwiktionary', 'bugwiki', 'bxrwiki', 'cawiki', 'cawikibooks', 'cawikimedia', 'cawikinews', 'cawikiquote', 'cawikisource', 'cawiktionary', 'cbk_zamwiki', 'cdowiki', 'cebwiki', 'cewiki', 'chrwiki', 'chrwiktionary', 'chwiki', 'chywiki', 'ckbwiki', 'ckbwiktionary', 'commonswiki', 'cowiki', 'cowikimedia', 'cowiktionary', 'crhwiki', 'csbwiki', 'csbwiktionary', 'cswiki', 'cswikibooks', 'cswikinews', 'cswikiquote', 'cswikisource', 'cswikiversity', 'cswikivoyage', 'cswiktionary', 'cuwiki', 'cvwiki', 'cvwikibooks', 'cywiki', 'cywikibooks', 'cywikiquote', 'cywikisource', 'cywiktionary', 'dagwiki', 'dawiki', 'dawikibooks', 'dawikiquote', 'dawikisource', 'dawiktionary', 'dewiki', 'dewikibooks', 'dewikinews', 'dewikiquote', 'dewikisource', 'dewikiversity', 'dewikivoyage', 'dewiktionary', 'dgawiki', 'dinwiki', 'diqwiki', 'diqwiktionary', 'dkwikimedia', 'dsbwiki', 'dtpwiki', 'dtywiki', 'dvwiki', 'dvwiktionary', 'dzwiki', 'eewiki', 'elwiki', 'elwikibooks', 'elwikinews', 'elwikiquote', 'elwikisource', 'elwikiversity', 'elwikivoyage', 'elwiktionary', 'emlwiki', 'enwiki', 'enwikibooks', 'enwikinews', 'enwikiquote', 'enwikisource', 'enwikiversity', 'enwikivoyage', 'enwiktionary', 'eowiki', 'eowikibooks', 'eowikinews', 'eowikiquote', 'eowikisource', 'eowikivoyage', 'eowiktionary', 'eswiki', 'eswikibooks', 'eswikinews', 'eswikiquote', 'eswikisource', 'eswikiversity', 'eswikivoyage', 'eswiktionary', 'etwiki', 'etwikibooks', 'eewikimedia', 'etwikiquote', 'etwikisource', 'etwiktionary', 'euwiki', 'euwikibooks', 'euwikiquote', 'euwikisource', 'euwiktionary', 'extwiki', 'fatwiki', 'fawiki', 'fawikibooks', 'fawikinews', 'fawikiquote', 'fawikisource', 'fawikivoyage', 'fawiktionary', 'ffwiki', 'fiu_vrowiki', 'fiwiki', 'fiwikibooks', 'fiwikimedia', 'fiwikinews', 'fiwikiquote', 'fiwikisource', 'fiwikiversity', 'fiwikivoyage', 'fiwiktionary', 'fjwiki', 'fjwiktionary', 'fonwiki', 'foundationwiki', 'fowiki', 'fowikisource', 'fowiktionary', 'frpwiki', 'frrwiki', 'frwiki', 'frwikibooks', 'frwikinews', 'frwikiquote', 'frwikisource', 'frwikiversity', 'frwikivoyage', 'frwiktionary', 'furwiki', 'fywiki', 'fywikibooks', 'fywiktionary', 'gagwiki', 'ganwiki', 'gawiki', 'gawiktionary', 'gcrwiki', 'gdwiki', 'gdwiktionary', 'glkwiki', 'glwiki', 'glwikibooks', 'glwikiquote', 'glwikisource', 'glwiktionary', 'gnwiki', 'gnwiktionary', 'gomwiki', 'gomwiktionary', 'gorwiki', 'gorwikiquote', 'gorwiktionary', 'gotwiki', 'gpewiki', 'gucwiki', 'gurwiki', 'guwiki', 'guwikiquote', 'guwikisource', 'guwiktionary', 'guwwiki', 'guwwikinews', 'guwwikiquote', 'guwwiktionary', 'gvwiki', 'gvwiktionary', 'hakwiki', 'hawiki', 'hawiktionary', 'hawwiki', 'hewiki', 'hewikibooks', 'hewikinews', 'hewikiquote', 'hewikisource', 'hewikivoyage', 'hewiktionary', 'hifwiki', 'hifwiktionary', 'hiwiki', 'hiwikibooks', 'hiwikiquote', 'hiwikisource', 'hiwikiversity', 'hiwikivoyage', 'hiwiktionary', 'hrwiki', 'hrwikibooks', 'hrwikiquote', 'hrwikisource', 'hrwiktionary', 'hsbwiki', 'hsbwiktionary', 'htwiki', 'huwiki', 'huwikibooks', 'huwikisource', 'huwiktionary', 'hywiki', 'hywikibooks', 'hywikiquote', 'hywikisource', 'hywiktionary', 'hywwiki', 'iawiki', 'iawikibooks', 'iawiktionary', 'ibawiki', 'idwiki', 'idwikibooks', 'idwikiquote', 'idwikisource', 'idwikivoyage', 'idwiktionary', 'iewiki', 'iewiktionary', 'iglwiki', 'igwiki', 'igwikiquote', 'igwiktionary', 'ikwiki', 'ilowiki', 'incubatorwiki', 'inhwiki', 'iowiki', 'iowiktionary', 'iswiki', 'iswikibooks', 'iswikiquote', 'iswikisource', 'iswiktionary', 'itwiki', 'itwikibooks', 'itwikinews', 'itwikiquote', 'itwikisource', 'itwikiversity', 'itwikivoyage', 'itwiktionary', 'iuwiki', 'iuwiktionary', 'jamwiki', 'jawiki', 'jawikibooks', 'jawikinews', 'jawikisource', 'jawikiversity', 'jawikivoyage', 'jawiktionary', 'jbowiki', 'jbowiktionary', 'jvwiki', 'jvwikisource', 'jvwiktionary', 'kaawiki', 'kaawiktionary', 'kabwiki', 'kaiwiki', 'kajwiki', 'kawiki', 'kawikibooks', 'kawikiquote', 'kawikisource', 'kawiktionary', 'kbdwiki', 'kbdwiktionary', 'kbpwiki', 'kcgwiki', 'kcgwiktionary', 'kgewiki', 'kgwiki', 'kiwiki', 'kkwiki', 'kkwikibooks', 'kkwiktionary', 'klwiktionary', 'kmwiki', 'kmwikibooks', 'kmwiktionary', 'kncwiki', 'knwiki', 'knwikiquote', 'knwikisource', 'knwiktionary', 'koiwiki', 'kowiki', 'kowikibooks', 'kowikinews', 'kowikiquote', 'kowikisource', 'kowikiversity', 'kowiktionary', 'krcwiki', 'kshwiki', 'kswiki', 'kswiktionary', 'kuswiki', 'kuwiki', 'kuwikibooks', 'kuwikiquote', 'kuwiktionary', 'kvwiki', 'kwwiki', 'kwwiktionary', 'kywiki', 'kywikiquote', 'kywiktionary', 'labswiki', 'ladwiki', 'lawiki', 'lawikibooks', 'lawikiquote', 'lawikisource', 'lawiktionary', 'lbewiki', 'lbwiki', 'lbwiktionary', 'lezwiki', 'lfnwiki', 'lgwiki', 'lijwiki', 'lijwikisource', 'liwiki', 'liwikibooks', 'liwikinews', 'liwikiquote', 'liwikisource', 'liwiktionary', 'lldwiki', 'lmowiki', 'lmowiktionary', 'lnwiki', 'lnwiktionary', 'lowiki', 'lowiktionary', 'ltgwiki', 'ltwiki', 'ltwikibooks', 'ltwikiquote', 'ltwikisource', 'ltwiktionary', 'lvwiki', 'lvwiktionary', 'madwiki', 'madwikisource', 'madwiktionary', 'maiwiki', 'map_bmswiki', 'mdfwiki', 'mediawikiwiki', 'metawiki', 'mgwiki', 'mgwikibooks', 'mgwiktionary', 'mhrwiki', 'minwiki', 'minwikibooks', 'minwikisource', 'minwiktionary', 'miwiki', 'miwiktionary', 'mkwiki', 'mkwikibooks', 'mkwikimedia', 'mkwikisource', 'mkwiktionary', 'mlwiki', 'mlwikibooks', 'mlwikiquote', 'mlwikisource', 'mlwiktionary', 'mniwiki', 'mniwiktionary', 'mnwiki', 'mnwiktionary', 'mnwwiki', 'mnwwiktionary', 'moswiki', 'mrjwiki', 'mrwiki', 'mrwikibooks', 'mrwikiquote', 'mrwikisource', 'mrwiktionary', 'mswiki', 'mswikibooks', 'mswikiquote', 'mswikisource', 'mswiktionary', 'mtwiki', 'mtwiktionary', 'mwlwiki', 'mxwikimedia', 'myvwiki', 'mywiki', 'mywikisource', 'mywiktionary', 'mznwiki', 'nahwiki', 'nahwiktionary', 'napwiki', 'napwikisource', 'nawiktionary', 'nds_nlwiki', 'ndswiki', 'ndswiktionary', 'newiki', 'newikibooks', 'newiktionary', 'newwiki', 'niawiki', 'niawiktionary', 'nlwiki', 'nlwikibooks', 'nlwikimedia', 'nlwikinews', 'nlwikiquote', 'nlwikisource', 'nlwikivoyage', 'nlwiktionary', 'nnwiki', 'nnwikiquote', 'nnwiktionary', 'novwiki', 'nowiki', 'nowikibooks', 'nowikimedia', 'nowikinews', 'nowikiquote', 'nowikisource', 'nowiktionary', 'nqowiki', 'nrmwiki', 'nrwiki', 'nsowiki', 'nupwiki', 'nvwiki', 'nycwikimedia', 'nywiki', 'ocwiki', 'ocwikibooks', 'ocwiktionary', 'olowiki', 'omwiki', 'omwiktionary', 'orwiki', 'orwikisource', 'orwiktionary', 'oswiki', 'outreachwiki', 'pagwiki', 'pamwiki', 'papwiki', 'pawiki', 'pawikibooks', 'pawikisource', 'pawiktionary', 'pcdwiki', 'pcmwiki', 'pcmwikiquote', 'pdcwiki', 'pflwiki', 'piwiki', 'plwiki', 'plwikibooks', 'plwikimedia', 'plwikinews', 'plwikiquote', 'plwikisource', 'plwikivoyage', 'plwiktionary', 'pmswiki', 'pmswikisource', 'pnbwiki', 'pnbwiktionary', 'pntwiki', 'pplwiki', 'pswiki', 'pswikivoyage', 'pswiktionary', 'ptwiki', 'ptwikibooks', 'ptwikimedia', 'ptwikinews', 'ptwikiquote', 'ptwikisource', 'ptwikiversity', 'ptwikivoyage', 'ptwiktionary', 'pwnwiki', 'quwiktionary', 'rkiwiki', 'rmwiki', 'rmywiki', 'rnwiki', 'roa_rupwiki', 'roa_rupwiktionary', 'roa_tarawiki', 'rowiki', 'rowikibooks', 'rowikinews', 'rowikiquote', 'rowiktionary', 'rskwiki', 'ruewiki', 'ruwiki', 'ruwikibooks', 'ruwikinews', 'ruwikiquote', 'ruwikisource', 'ruwikiversity', 'ruwikivoyage', 'ruwiktionary', 'rwwiki', 'rwwiktionary', 'sahwiki', 'sahwikiquote', 'sahwikisource', 'satwiki', 'satwiktionary', 'sawiki', 'sawikibooks', 'sawikiquote', 'sawikisource', 'sawiktionary', 'scnwiki', 'scnwiktionary', 'scowiki', 'scwiki', 'sdwiki', 'sdwiktionary', 'sewiki', 'sewikimedia', 'sgwiki', 'sgwiktionary', 'shiwiki', 'shnwiki', 'shnwikibooks', 'shnwikinews', 'shnwikivoyage', 'shnwiktionary', 'shwiki', 'shwiktionary', 'shywiktionary', 'simplewiki', 'simplewiktionary', 'siwiki', 'siwikibooks', 'siwiktionary', 'skwiki', 'skrwiki', 'skrwiktionary', 'slwiki', 'slwikibooks', 'slwikiquote', 'slwikisource', 'slwikiversity', 'slwiktionary', 'smnwiki', 'smwiki', 'smwiktionary', 'snwiki', 'sourceswiki', 'sowiki', 'sowiktionary', 'specieswiki', 'sqwiki', 'sqwikibooks', 'sqwikinews', 'sqwikiquote', 'sqwiktionary', 'srnwiki', 'srwiki', 'srwikibooks', 'srwikinews', 'srwikiquote', 'srwikisource', 'srwiktionary', 'sswiki', 'sswiktionary', 'stqwiki', 'stwiki', 'stwiktionary', 'suwiki', 'suwikiquote', 'suwikisource', 'suwiktionary', 'svwiki', 'svwikibooks', 'svwikinews', 'svwikiquote', 'svwikisource', 'svwikiversity', 'svwikivoyage', 'svwiktionary', 'swwiki', 'swwiktionary', 'sylwiki', 'szlwiki', 'szywiki', 'tawiki', 'tawikibooks', 'tawikinews', 'tawikiquote', 'tawikisource', 'tawiktionary', 'taywiki', 'tcywiki', 'tcywikisource', 'tcywiktionary', 'tddwiki', 'tewiki', 'tewikibooks', 'tewikiquote', 'tewikisource', 'tewiktionary', 'tgwiki', 'tgwikibooks', 'tgwiktionary', 'thwiki', 'thwikibooks', 'thwikimedia', 'thwikiquote', 'thwikisource', 'thwiktionary', 'tigwiki', 'tiwiki', 'tiwiktionary', 'tkwiki', 'tkwiktionary', 'tlwiki', 'tlwikibooks', 'tlwikiquote', 'tlwikisource', 'tlwiktionary', 'tlywiki', 'tnwiki', 'tnwiktionary', 'tokwiki', 'towiki', 'tpiwiki', 'tpiwiktionary', 'trvwiki', 'trwiki', 'trwikibooks', 'trwikimedia', 'trwikinews', 'trwikiquote', 'trwikisource', 'trwikivoyage', 'trwiktionary', 'tswiki', 'tswiktionary', 'ttwiki', 'ttwikibooks', 'ttwikiquote', 'ttwiktionary', 'tumwiki', 'twwiki', 'twwiktionary', 'tyvwiki', 'tywiki', 'uawikimedia', 'udmwiki', 'ugwiki', 'ugwiktionary', 'ukwiki', 'ukwikibooks', 'ukwikinews', 'ukwikiquote', 'ukwikisource', 'ukwikivoyage', 'ukwiktionary', 'urwiki', 'urwikibooks', 'urwikiquote', 'urwikisource', 'urwiktionary', 'uzwiki', 'uzwikiquote', 'uzwiktionary', 'vecwiki', 'vecwikisource', 'vecwiktionary', 'vepwiki', 'vewiki', 'viwiki', 'viwikibooks', 'viwikiquote', 'viwikisource', 'viwikivoyage', 'viwiktionary', 'vlswiki', 'vowiki', 'vowiktionary', 'warwiki', 'wawiki', 'wawikisource', 'wawiktionary', 'wikidatawiki', 'wikimaniawiki', 'wowiki', 'wowiktionary', 'wuuwiki', 'xalwiki', 'xhwiki', 'xmfwiki', 'yiwiki', 'yiwikisource', 'yiwiktionary', 'yowiki', 'zawiki', 'zeawiki', 'zghwiki', 'zghwiktionary', 'zh_classicalwiki', 'zh_min_nanwiki', 'zh_min_nanwikisource', 'zh_min_nanwiktionary', 'zh_yuewiki', 'zhwiki', 'zhwikibooks', 'zhwikinews', 'zhwikiquote', 'zhwikisource', 'zhwikiversity', 'zhwikivoyage', 'zhwiktionary', 'zuwiki', 'zuwiktionary', 'test2wiki', 'testwiki'];
const DELAY_MS = 800;
const sleep = ms => new Promise(r => setTimeout(r, ms));
let userCache = {};
let historyCache = {};
// Helper function to handle API calls with up to 3 retries on 429 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?.status === 429) {
retries++;
const retryAfter = parseInt(err.xhr?.getResponseHeader('Retry-After')) || (30 * retries);
$('#status-msg').html(`<span style="color:orange; font-weight:bold;">Rate limit hit! Resting ${retryAfter}s... (Attempt ${retries}/${maxRetries})</span>`);
await sleep(retryAfter * 1000);
} else {
throw err;
}
}
}
throw new Error("Max retries reached");
}
function getDomain(db) {
const mapping = {
'commonswiki': 'commons.wikimedia.org', 'metawiki': 'meta.wikimedia.org', 'wikidatawiki': 'www.wikidata.org',
'mediawikiwiki': 'www.mediawiki.org', 'sourceswiki': 'wikisource.org', 'foundationwiki': 'foundation.wikimedia.org',
'incubatorwiki': 'incubator.wikimedia.org', 'outreachwiki': 'outreach.wikimedia.org',
'be_x_oldwiki': 'be-tarask.wikipedia.org', 'labswiki': 'wikitech.wikimedia.org',
'wikimaniawiki': 'wikimania.wikimedia.org', 'specieswiki': 'species.wikimedia.org',
'abstractwiki': 'abstract.wikipedia.org', 'betawikiversity': 'beta.wikiversity.org',
'mo_wiki': 'ro.wikipedia.org',
'testwiki': 'test.wikipedia.org', 'test2wiki': 'test2.wikipedia.org',
'wikifunctionswiki': 'www.wikifunctions.org',
'testcommonswiki': 'test-commons.wikimedia.org',
'testwikidatawiki': 'test.wikidata.org',
};
if (mapping[db]) return mapping[db];
const name = db.replace(/_/g, '-');
if (name.endsWith('wikisource')) return name.slice(0, -10) + '.wikisource.org';
if (name.endsWith('wikiversity')) return name.slice(0, -11) + '.wikiversity.org';
if (name.endsWith('wiktionary')) return name.slice(0, -10) + '.wiktionary.org';
if (name.endsWith('wikivoyage')) return name.slice(0, -10) + '.wikivoyage.org';
if (name.endsWith('wikibooks')) return name.slice(0, -9) + '.wikibooks.org';
if (name.endsWith('wikiquote')) return name.slice(0, -9) + '.wikiquote.org';
if (name.endsWith('wikinews')) return name.slice(0, -8) + '.wikinews.org';
if (name.endsWith('wikimedia')) return name.slice(0, -9) + '.wikimedia.org';
if (name.endsWith('wiki')) return name.slice(0, -4) + '.wikipedia.org';
return name + '.wikipedia.org';
}
function getInterwikiPrefix(db) {
const specials = {
'commonswiki': 'commons', 'metawiki': 'm', 'wikidatawiki': 'd',
'mediawikiwiki': 'mw', 'specieswiki': 'wikispecies',
'foundationwiki': 'wikimedia', 'sourceswiki': 'wikisource'
};
if (specials[db]) return specials[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 'wikimedia:' + name.slice(0, -9);
if (name.endsWith('wiki')) return 'w:' + name.slice(0, -4);
return 'w:' + name;
}
mw.loader.using(['mediawiki.api', 'mediawiki.ForeignApi']).then(function () {
const metaApi = new mw.ForeignApi('https://meta.wikimedia.org/w/api.php');
const now = new Date();
let results = {}, emptyWikis = [], failedWikis = [], scannedWikis = [], isRunning = false;
let currentFilterMode = 'all';
let currentUserFilter = 'all';
function setupUI() {
const currentYear = now.getFullYear();
let yearOpts = '';
for (let y = currentYear; y >= 2005; y--) yearOpts += `<option value="${y}">${y}</option>`;
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>`;
}
$('#firstHeading').text('GlobalCheckUserStats.js');
$('#mw-content-text').empty().append(`
<div style="border:1px solid #a2a9b1; padding:15px; background:#f8f9fa;">
<h3>Stats Range</h3>
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 style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>Wiki Filter:</strong>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="wiki-mode" id="btn-all" checked> All wikis</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="wiki-mode" id="btn-except"> All wikis except</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><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; font-size:12px; line-height:18px;" title="Show all wiki DB names">?</span>
</div>
<div id="filter-input-container" style="display:none; margin-top:10px;">
<input id="wiki-filter" type="text" style="width:100%; max-width:600px;" placeholder="">
</div>
<div style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>User Filter:</strong>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-all" checked> All users</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-local"> Only Local CUs</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-steward"> Only Stewards</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; color:#202122;">
<strong>Available Wikis (alphabetical):</strong><br>${[...rawWikis].sort().join(', ')}
</div>
<div style="margin-top:20px;">
<button id="start" class="mw-ui-button mw-ui-progressive">Run GlobalCheckUserStats.js</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="${rawWikis.length}" style="width:100%"></progress></div>
<textarea id="out" style="width:100%; height:450px; margin-top:10px; display:none; font-family:monospace; font-size:12px;"></textarea>
</div>
`);
$('#btn-all').click(() => { currentFilterMode = 'all'; $('#filter-input-container').hide(); });
$('#btn-except').click(() => { currentFilterMode = 'exclude'; $('#filter-input-container').show(); $('#wiki-filter').attr('placeholder', 'Exclude (dbname1, dbname2)...').focus(); });
$('#btn-only').click(() => { currentFilterMode = 'include'; $('#filter-input-container').show(); $('#wiki-filter').attr('placeholder', 'Only (dbname1, dbname2)...').focus(); });
$('#u-all').click(() => { currentUserFilter = 'all'; });
$('#u-local').click(() => { currentUserFilter = 'local'; });
$('#u-steward').click(() => { currentUserFilter = 'steward'; });
$('#wiki-help-trigger').click(() => $('#wiki-list-help').toggle());
$('#start').click(() => runAudit($('#y-f').val(), $('#m-f').val(), $('#y-t').val(), $('#m-t').val()));
$('#stop').click(() => isRunning = false);
}
async function fetchWikiMetrics(db) {
try {
const api = new mw.ForeignApi(`https://${getDomain(db)}/w/api.php`);
const res = await robustCall(api, { action: 'query', meta: 'siteinfo', siprop: 'statistics', formatversion: 2 });
return { active: res.query.statistics.activeusers || 0 };
} catch (e) { return { active: 0 }; }
}
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), auditEnd = new Date(end);
const sortedLogs = logs.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
const stateAtStart = { steward: false, staff: false, ombuds: false };
const entryAtStart = sortedLogs.find(l => new Date(l.timestamp) <= auditStart);
if (entryAtStart) {
const p = entryAtStart.params || {};
const groups = p.newGroups || p[1] || [];
stateAtStart.steward = groups.includes('steward');
stateAtStart.staff = groups.includes('staff');
stateAtStart.ombuds = groups.includes('ombuds') || groups.includes('ombudsman');
}
let held = { wasSteward: stateAtStart.steward, wasStaff: stateAtStart.staff, wasOmbuds: stateAtStart.ombuds };
logs.filter(l => { const ts = new Date(l.timestamp); return ts > auditStart && ts < auditEnd; }).forEach(e => {
const p = e.params || {};
const added = p.newGroups || p[1] || [];
if (added.includes('steward')) held.wasSteward = true;
if (added.includes('staff')) held.wasStaff = true;
if (added.includes('ombuds') || added.includes('ombudsman')) held.wasOmbuds = true;
});
return held;
} catch (e) { return { wasSteward: false, wasStaff: false, wasOmbuds: false }; }
}
async function fetchUserData(user, db, start, end) {
const auditStart = new Date(start), auditEnd = new Date(end);
// 1. FETCH GLOBAL HISTORY (To identify roles held during the period)
if (!historyCache[user]) historyCache[user] = await checkGlobalHistory(user, start, end);
const historyRes = historyCache[user];
// 2. CHECK CURRENT LOCAL CHECKUSER STATUS
let isCurrentLocal = false;
try {
const localApi = new mw.ForeignApi(`https://${getDomain(db)}/w/api.php`);
const ures = await robustCall(localApi, { action: 'query', list: 'users', ususers: user, usprop: 'groups', formatversion: 2 });
const lGroups = (ures.query.users[0] && ures.query.users[0].groups) || [];
isCurrentLocal = lGroups.includes('checkuser');
} catch (e) {}
const target = 'User:' + user + '@' + db;
let logText = '', addTime = null, removeTime = null, isSelfAssign = false, maxDurationMins = -1, longestTimeStr = "", assignCount = 0;
// --- 3. DEEP RIGHTS LOG SCAN ---
let events = [];
let continueToken = null;
let finished = false;
let hasLocalRightsInPeriod = false;
try {
while (!finished) {
let params = { action: 'query', list: 'logevents', letype: 'rights', letitle: target, ledir: 'older', lelimit: 'max', formatversion: 2 };
if (continueToken) Object.assign(params, continueToken);
const res = await robustCall(metaApi, params);
events = events.concat(res.query.logevents || []);
if (res.continue) continueToken = res.continue;
else finished = true;
}
let logEntries = [];
let pendingRemoved = null;
let lastAddedTime = null; // Track last processed ADD to avoid duplicates
for (let i = 0; i < events.length; i++) {
const e = events[i], p = e.params || {};
const cuAdded = (p.add || []).includes('checkuser') || ((p.newgroups || []).includes('checkuser') && !(p.oldgroups || []).includes('checkuser'));
const cuRemoved = (p.remove || []).includes('checkuser') || (!(p.newgroups || []).includes('checkuser') && (p.oldgroups || []).includes('checkuser'));
const eventDate = new Date(e.timestamp);
const exactTime = e.timestamp.replace('T', ' ').replace('Z', '');
if (cuAdded) addTime = eventDate;
if (cuRemoved) removeTime = eventDate;
if (addTime && addTime <= auditEnd && (!removeTime || removeTime >= auditStart)) hasLocalRightsInPeriod = true;
if (eventDate >= auditStart && eventDate <= auditEnd && (cuAdded || cuRemoved)) {
let expiryDate = null;
if (p.newmetadata) {
const cuMeta = p.newmetadata.find(m => m.group === 'checkuser');
if (cuMeta && cuMeta.expiry && cuMeta.expiry !== 'infinity') expiryDate = new Date(cuMeta.expiry);
}
const isSelf = (e.user === user || !e.user) ? " (Self-assign)" : "";
if (e.user === user || !e.user) isSelfAssign = true;
if (cuAdded) {
if (pendingRemoved) {
const diffMins = Math.round(Math.abs(pendingRemoved.date - eventDate) / 60000);
const d = Math.floor(diffMins / 1440), h = Math.floor((diffMins % 1440) / 60), m = diffMins % 60;
const durStr = `${d > 0 ? d+'d ' : ''}${h > 0 ? h+'h ' : ''}${m}m`;
logEntries.unshift(`* ADDED: ${exactTime} | REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf} (Duration: ${durStr})`);
if (diffMins > maxDurationMins) { maxDurationMins = diffMins; longestTimeStr = durStr; }
assignCount++;
pendingRemoved = null;
lastAddedTime = exactTime;
} else if (expiryDate) {
const diffMins = Math.round(Math.abs(expiryDate - eventDate) / 60000);
const d = Math.floor(diffMins / 1440), h = Math.floor((diffMins % 1440) / 60), m = diffMins % 60;
const durStr = `${d > 0 ? d+'d ' : ''}${h > 0 ? h+'h ' : ''}${m}m`;
const exactExpiryTime = expiryDate.toISOString().replace('T', ' ').substring(0, 19);
logEntries.unshift(`* ADDED: ${exactTime} | EXPIRED: ${exactExpiryTime} by ${e.user}${isSelf} (Duration: ${durStr})`);
if (diffMins > maxDurationMins) { maxDurationMins = diffMins; longestTimeStr = durStr; }
assignCount++;
lastAddedTime = exactTime;
} else if (lastAddedTime !== exactTime) {
logEntries.unshift(`* ADDED: ${exactTime} | REMOVED: (Active/Not removed) by ${e.user}${isSelf}`);
lastAddedTime = exactTime;
}
} else if (cuRemoved) {
if (pendingRemoved) logEntries.unshift(`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf}`);
pendingRemoved = { time: exactTime, date: eventDate, user: e.user, isSelf: isSelf };
}
}
}
if (pendingRemoved) logEntries.unshift(`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf}`);
if (logEntries.length) logText = '\n' + logEntries.join('\n');
} catch (err) {
console.error(err);
}
// --- ROLE CLASSIFICATION ---
let roleLabel = "";
const hasActions = (results[db] && results[db][user] && results[db][user].total > 0);
let wasCU = hasLocalRightsInPeriod || hasActions;
if (historyRes.wasStaff) {
roleLabel = "Staff (in period)";
} else if (historyRes.wasOmbuds) {
roleLabel = "Ombudsman (in period)";
} else if (isCurrentLocal) {
roleLabel = "Current Local CheckUser";
} else if (wasCU) {
roleLabel = "Former Local CheckUser (in period)";
} else if (longestTimeStr && isSelfAssign && historyRes.wasSteward) {
roleLabel = `Steward action (Self-assign: ${assignCount > 1 ? assignCount + 'x, longest ' : ''}${longestTimeStr})`;
} else if (historyRes.wasSteward) {
roleLabel = "Steward (in period)";
} else {
roleLabel = "Unknown role";
}
return { role: roleLabel, log: logText };
}
async function runAudit(yf, mf, yt, mt) {
isRunning = true; userCache = {}; historyCache = {}; results = {}; emptyWikis = []; failedWikis = []; scannedWikis = [];
const filterText = $('#wiki-filter').val().trim().toLowerCase();
const filterList = filterText ? filterText.split(',').map(s => s.trim()).filter(s => s !== "") : [];
let wikisToScan = rawWikis;
if (currentFilterMode === 'include') wikisToScan = rawWikis.filter(w => filterList.includes(w));
else if (currentFilterMode === 'exclude') wikisToScan = rawWikis.filter(w => !filterList.includes(w));
if (wikisToScan.length === 0) { alert("No wikis selected!"); return; }
$('#start').prop('disabled', true); $('#stop').prop('disabled', false); $('#out').hide();
$('#bar').attr('max', wikisToScan.length).val(0);
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`;
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++; }
}
for (let i = 0; i < wikisToScan.length; i++) {
if (!isRunning) break;
const db = wikisToScan[i]; scannedWikis.push(db);
$('#status-msg').text(`Scanning ${db} (${i + 1}/${wikisToScan.length})...`);
let successLocal = false, continueToken = null;
while (!successLocal && isRunning) {
try {
const api = new mw.ForeignApi(`https://${getDomain(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 ent = res.query?.checkuserlog?.entries || [];
if (ent.length) {
if (!results[db]) results[db] = {};
ent.forEach(e => {
const u = e.checkuser;
if (!results[db][u]) results[db][u] = { total: 0, months: {} };
const mKey = e.timestamp.slice(0, 7);
results[db][u].total++; results[db][u].months[mKey] = (results[db][u].months[mKey] || 0) + 1;
});
}
if (res.continue && isRunning) {
continueToken = res.continue;
} else {
if (!results[db]) emptyWikis.push(db);
successLocal = true;
}
} catch (err) {
failedWikis.push(db);
successLocal = true;
}
}
$('#bar').val(i + 1); await sleep(DELAY_MS);
}
$('#status-msg').text(`Generating report...`);
const today = new Date().toISOString().split('T')[0];
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' (UTC)';
let wt = `== Global CheckUser Stats (${mf}/${yf} - ${mt}/${yt}) ==\n''Report generated on: ${timestamp}''\n\n`;
let rightsLog = `\n== Rights Log (In Period) ==\n`;
wt += `{| class="wikitable sortable" style="font-size:90%; text-align:right;"\n! Wiki / User !! Category !! '''Total''' !! per 1k active users ${monthCols.map(m => `!! ${m.label}`).join(' ')}\n`;
const sortedDBs = Object.keys(results).sort();
for (const db of sortedDBs) {
// Safeguard against missing wikis during report generation
let metrics = { active: 0 };
try {
metrics = await fetchWikiMetrics(db);
} catch (e) {
console.warn(`Metrics failed for ${db}`);
}
const fmtActive = metrics.active.toLocaleString('en-US');
const iwPrefix = getInterwikiPrefix(db);
const projectLink = `[[${iwPrefix}:|${db}]]`;
const logLink = `[[${iwPrefix}:Special:CheckUserLog|(log)]]`;
wt += `|-\n! colspan="${4 + monthCols.length}" style="background:#eaecf0; text-align:center;" | ${projectLink} <small style="font-weight:normal; color:#54595d;">(Active Users: ${fmtActive})</small> — ${logLink}\n`;
let wikiRows = [], wT = 0, wMS = {}; monthCols.forEach(col => wMS[col.key] = 0);
const projectUsers = Object.keys(results[db]).sort();
for (const user of projectUsers) {
// Safeguard against missing users during report generation
let m = { role: "Unknown/Error", log: "" };
try {
m = await fetchUserData(user, db, START, END);
} catch (e) {
m.role = "Data Error (429)";
}
const isSteward = m.role.includes("Steward") || m.role.includes("Staff") || m.role.includes("Ombudsman");
const isLocalCU = m.role.includes("Local CheckUser");
if (currentUserFilter === 'local' && !isLocalCU) continue;
if (currentUserFilter === 'steward' && !isSteward) continue;
const uD = results[db][user];
wT += uD.total; monthCols.forEach(col => wMS[col.key] += (uD.months[col.key] || 0));
let uP1kU = metrics.active > 0 ? ((uD.total / metrics.active) * 1000).toFixed(1) : "0.0";
let row = `|-\n| style="text-align:left;" | ${user}@${db} || <small>${m.role}</small> || '''${uD.total}''' || ${uP1kU}`;
monthCols.forEach(col => row += ` || ${uD.months[col.key] || 0}`);
wikiRows.push({ html: row + "\n", log: m.log ? `'''${user}@${db}''':${m.log}\n\n` : "" });
}
let wP1kU = metrics.active > 0 ? ((wT / metrics.active) * 1000).toFixed(1) : "0.0";
let totalRow = `|- style="background:#f8f9fa; font-weight:bold;"\n| style="text-align:left;" | TOTAL ${db} || — || ${wT} || ${wP1kU}`;
monthCols.forEach(col => totalRow += ` || ${wMS[col.key]}`);
wt += totalRow + "\n";
wikiRows.forEach(r => { wt += r.html; if (r.log) rightsLog += r.log; });
}
wt += `|}\n\n== Projects with 0 actions ==\n<div style="font-size:85%; color:#54595d;">${emptyWikis.sort().join(', ')}</div>\n`;
wt += rightsLog + `\n\n<references />\n`;
$('#out').val(wt).show();
$('#status-msg').text(`Done.`); $('#start').prop('disabled', false); $('#stop').prop('disabled', true);
}
setupUI();
});
})();
9jkoynsf7nxrni73cvzchld8t6tr70l
737753
737752
2026-04-12T12:09:37Z
MrJaroslavik
44012
revert back
737753
javascript
text/javascript
// GlobalCheckUserStats.js
// Features:
// - Global Audit: Scans CheckUser logs across 900+ Wikimedia projects at once.
// - Smart Categorization: Identifies roles (Local CU, Steward, Staff, etc.).
// - Steward Logic: Detects temporary access, exact durations, and longest active periods.
// - Deep Scan: Automated pagination to bypass the 500-entry API limit.
// - Robust Connection: Automated retries for 429 rate-limits and custom domain mapping.
// - Full Reporting: Outputs sortable Wikitables and detailed rights change logs.
// - Custom UI: Integrated control panel on [[Special:BlankPage/GlobalCheckUserStats]].
// - With help of Gemini 3
(function () {
'use strict';
if (mw.config.get('wgCanonicalSpecialPageName') !== 'Blankpage' || mw.config.get('wgTitle').indexOf('GlobalCheckUserStats') === -1) return;
const rawWikis = ['abstractwiki', 'abwiki', 'acewiki', 'adywiki', 'afwiki', 'afwikibooks', 'afwikiquote', 'afwiktionary', 'alswiki', 'altwiki', 'amiwiki', 'amwiki', 'amwiktionary', 'angwiki', 'angwiktionary', 'annwiki', 'anpwiki', 'anwiki', 'anwiktionary', 'arcwiki', 'arwiki', 'arwikibooks', 'arwikimedia', 'arwikinews', 'arwikiquote', 'arwikisource', 'arwikiversity', 'arwiktionary', 'arywiki', 'arzwiki', 'astwiki', 'astwiktionary', 'aswiki', 'aswikiquote', 'aswikisource', 'atjwiki', 'avkwiki', 'avwiki', 'awawiki', 'aywiki', 'aywiktionary', 'azbwiki', 'azwiki', 'azwikibooks', 'azwikiquote', 'azwikisource', 'azwiktionary', 'banwiki', 'banwikisource', 'barwiki', 'bat_smgwiki', 'bawiki', 'bawikibooks', 'bbcwiki', 'bclwiki', 'bclwikiquote', 'bclwikisource', 'bclwiktionary', 'bdrwiki', 'bdwikimedia', 'be_x_oldwiki', 'betawikiversity', 'bewiki', 'bewikibooks', 'bewikimedia', 'bewikiquote', 'bewikisource', 'bewiktionary', 'bewwiki', 'bewwiktionary', 'bgwiki', 'bgwikibooks', 'bgwikiquote', 'bgwikisource', 'bgwiktionary', 'bhwiki', 'biwiki', 'bjnwiki', 'bjnwikiquote', 'bjnwiktionary', 'blkwiki', 'blkwiktionary', 'bmwiki', 'bnwiki', 'bnwikibooks', 'bnwikiquote', 'bnwikisource', 'bnwikivoyage', 'bnwiktionary', 'bowiki', 'bpywiki', 'brwiki', 'brwikimedia', 'brwikiquote', 'brwikisource', 'brwiktionary', 'bswiki', 'bswikibooks', 'bswikinews', 'bswikiquote', 'bswikisource', 'bswiktionary', 'btmwiki', 'btmwiktionary', 'bugwiki', 'bxrwiki', 'cawiki', 'cawikibooks', 'cawikimedia', 'cawikinews', 'cawikiquote', 'cawikisource', 'cawiktionary', 'cbk_zamwiki', 'cdowiki', 'cebwiki', 'cewiki', 'chrwiki', 'chrwiktionary', 'chwiki', 'chywiki', 'ckbwiki', 'ckbwiktionary', 'commonswiki', 'cowiki', 'cowikimedia', 'cowiktionary', 'crhwiki', 'csbwiki', 'csbwiktionary', 'cswiki', 'cswikibooks', 'cswikinews', 'cswikiquote', 'cswikisource', 'cswikiversity', 'cswikivoyage', 'cswiktionary', 'cuwiki', 'cvwiki', 'cvwikibooks', 'cywiki', 'cywikibooks', 'cywikiquote', 'cywikisource', 'cywiktionary', 'dagwiki', 'dawiki', 'dawikibooks', 'dawikiquote', 'dawikisource', 'dawiktionary', 'dewiki', 'dewikibooks', 'dewikinews', 'dewikiquote', 'dewikisource', 'dewikiversity', 'dewikivoyage', 'dewiktionary', 'dgawiki', 'dinwiki', 'diqwiki', 'diqwiktionary', 'dkwikimedia', 'dsbwiki', 'dtpwiki', 'dtywiki', 'dvwiki', 'dvwiktionary', 'dzwiki', 'eewiki', 'elwiki', 'elwikibooks', 'elwikinews', 'elwikiquote', 'elwikisource', 'elwikiversity', 'elwikivoyage', 'elwiktionary', 'emlwiki', 'enwiki', 'enwikibooks', 'enwikinews', 'enwikiquote', 'enwikisource', 'enwikiversity', 'enwikivoyage', 'enwiktionary', 'eowiki', 'eowikibooks', 'eowikinews', 'eowikiquote', 'eowikisource', 'eowikivoyage', 'eowiktionary', 'eswiki', 'eswikibooks', 'eswikinews', 'eswikiquote', 'eswikisource', 'eswikiversity', 'eswikivoyage', 'eswiktionary', 'etwiki', 'etwikibooks', 'eewikimedia', 'etwikiquote', 'etwikisource', 'etwiktionary', 'euwiki', 'euwikibooks', 'euwikiquote', 'euwikisource', 'euwiktionary', 'extwiki', 'fatwiki', 'fawiki', 'fawikibooks', 'fawikinews', 'fawikiquote', 'fawikisource', 'fawikivoyage', 'fawiktionary', 'ffwiki', 'fiu_vrowiki', 'fiwiki', 'fiwikibooks', 'fiwikimedia', 'fiwikinews', 'fiwikiquote', 'fiwikisource', 'fiwikiversity', 'fiwikivoyage', 'fiwiktionary', 'fjwiki', 'fjwiktionary', 'fonwiki', 'foundationwiki', 'fowiki', 'fowikisource', 'fowiktionary', 'frpwiki', 'frrwiki', 'frwiki', 'frwikibooks', 'frwikinews', 'frwikiquote', 'frwikisource', 'frwikiversity', 'frwikivoyage', 'frwiktionary', 'furwiki', 'fywiki', 'fywikibooks', 'fywiktionary', 'gagwiki', 'ganwiki', 'gawiki', 'gawiktionary', 'gcrwiki', 'gdwiki', 'gdwiktionary', 'glkwiki', 'glwiki', 'glwikibooks', 'glwikiquote', 'glwikisource', 'glwiktionary', 'gnwiki', 'gnwiktionary', 'gomwiki', 'gomwiktionary', 'gorwiki', 'gorwikiquote', 'gorwiktionary', 'gotwiki', 'gpewiki', 'gucwiki', 'gurwiki', 'guwiki', 'guwikiquote', 'guwikisource', 'guwiktionary', 'guwwiki', 'guwwikinews', 'guwwikiquote', 'guwwiktionary', 'gvwiki', 'gvwiktionary', 'hakwiki', 'hawiki', 'hawiktionary', 'hawwiki', 'hewiki', 'hewikibooks', 'hewikinews', 'hewikiquote', 'hewikisource', 'hewikivoyage', 'hewiktionary', 'hifwiki', 'hifwiktionary', 'hiwiki', 'hiwikibooks', 'hiwikiquote', 'hiwikisource', 'hiwikiversity', 'hiwikivoyage', 'hiwiktionary', 'hrwiki', 'hrwikibooks', 'hrwikiquote', 'hrwikisource', 'hrwiktionary', 'hsbwiki', 'hsbwiktionary', 'htwiki', 'huwiki', 'huwikibooks', 'huwikisource', 'huwiktionary', 'hywiki', 'hywikibooks', 'hywikiquote', 'hywikisource', 'hywiktionary', 'hywwiki', 'iawiki', 'iawikibooks', 'iawiktionary', 'ibawiki', 'idwiki', 'idwikibooks', 'idwikiquote', 'idwikisource', 'idwikivoyage', 'idwiktionary', 'iewiki', 'iewiktionary', 'iglwiki', 'igwiki', 'igwikiquote', 'igwiktionary', 'ikwiki', 'ilowiki', 'incubatorwiki', 'inhwiki', 'iowiki', 'iowiktionary', 'iswiki', 'iswikibooks', 'iswikiquote', 'iswikisource', 'iswiktionary', 'itwiki', 'itwikibooks', 'itwikinews', 'itwikiquote', 'itwikisource', 'itwikiversity', 'itwikivoyage', 'itwiktionary', 'iuwiki', 'iuwiktionary', 'jamwiki', 'jawiki', 'jawikibooks', 'jawikinews', 'jawikisource', 'jawikiversity', 'jawikivoyage', 'jawiktionary', 'jbowiki', 'jbowiktionary', 'jvwiki', 'jvwikisource', 'jvwiktionary', 'kaawiki', 'kaawiktionary', 'kabwiki', 'kaiwiki', 'kajwiki', 'kawiki', 'kawikibooks', 'kawikiquote', 'kawikisource', 'kawiktionary', 'kbdwiki', 'kbdwiktionary', 'kbpwiki', 'kcgwiki', 'kcgwiktionary', 'kgewiki', 'kgwiki', 'kiwiki', 'kkwiki', 'kkwikibooks', 'kkwiktionary', 'klwiktionary', 'kmwiki', 'kmwikibooks', 'kmwiktionary', 'kncwiki', 'knwiki', 'knwikiquote', 'knwikisource', 'knwiktionary', 'koiwiki', 'kowiki', 'kowikibooks', 'kowikinews', 'kowikiquote', 'kowikisource', 'kowikiversity', 'kowiktionary', 'krcwiki', 'kshwiki', 'kswiki', 'kswiktionary', 'kuswiki', 'kuwiki', 'kuwikibooks', 'kuwikiquote', 'kuwiktionary', 'kvwiki', 'kwwiki', 'kwwiktionary', 'kywiki', 'kywikiquote', 'kywiktionary', 'labswiki', 'ladwiki', 'lawiki', 'lawikibooks', 'lawikiquote', 'lawikisource', 'lawiktionary', 'lbewiki', 'lbwiki', 'lbwiktionary', 'lezwiki', 'lfnwiki', 'lgwiki', 'lijwiki', 'lijwikisource', 'liwiki', 'liwikibooks', 'liwikinews', 'liwikiquote', 'liwikisource', 'liwiktionary', 'lldwiki', 'lmowiki', 'lmowiktionary', 'lnwiki', 'lnwiktionary', 'lowiki', 'lowiktionary', 'ltgwiki', 'ltwiki', 'ltwikibooks', 'ltwikiquote', 'ltwikisource', 'ltwiktionary', 'lvwiki', 'lvwiktionary', 'madwiki', 'madwikisource', 'madwiktionary', 'maiwiki', 'map_bmswiki', 'mdfwiki', 'mediawikiwiki', 'metawiki', 'mgwiki', 'mgwikibooks', 'mgwiktionary', 'mhrwiki', 'minwiki', 'minwikibooks', 'minwikisource', 'minwiktionary', 'miwiki', 'miwiktionary', 'mkwiki', 'mkwikibooks', 'mkwikimedia', 'mkwikisource', 'mkwiktionary', 'mlwiki', 'mlwikibooks', 'mlwikiquote', 'mlwikisource', 'mlwiktionary', 'mniwiki', 'mniwiktionary', 'mnwiki', 'mnwiktionary', 'mnwwiki', 'mnwwiktionary', 'moswiki', 'mrjwiki', 'mrwiki', 'mrwikibooks', 'mrwikiquote', 'mrwikisource', 'mrwiktionary', 'mswiki', 'mswikibooks', 'mswikiquote', 'mswikisource', 'mswiktionary', 'mtwiki', 'mtwiktionary', 'mwlwiki', 'mxwikimedia', 'myvwiki', 'mywiki', 'mywikisource', 'mywiktionary', 'mznwiki', 'nahwiki', 'nahwiktionary', 'napwiki', 'napwikisource', 'nawiktionary', 'nds_nlwiki', 'ndswiki', 'ndswiktionary', 'newiki', 'newikibooks', 'newiktionary', 'newwiki', 'niawiki', 'niawiktionary', 'nlwiki', 'nlwikibooks', 'nlwikimedia', 'nlwikinews', 'nlwikiquote', 'nlwikisource', 'nlwikivoyage', 'nlwiktionary', 'nnwiki', 'nnwikiquote', 'nnwiktionary', 'novwiki', 'nowiki', 'nowikibooks', 'nowikimedia', 'nowikinews', 'nowikiquote', 'nowikisource', 'nowiktionary', 'nqowiki', 'nrmwiki', 'nrwiki', 'nsowiki', 'nupwiki', 'nvwiki', 'nycwikimedia', 'nywiki', 'ocwiki', 'ocwikibooks', 'ocwiktionary', 'olowiki', 'omwiki', 'omwiktionary', 'orwiki', 'orwikisource', 'orwiktionary', 'oswiki', 'outreachwiki', 'pagwiki', 'pamwiki', 'papwiki', 'pawiki', 'pawikibooks', 'pawikisource', 'pawiktionary', 'pcdwiki', 'pcmwiki', 'pcmwikiquote', 'pdcwiki', 'pflwiki', 'piwiki', 'plwiki', 'plwikibooks', 'plwikimedia', 'plwikinews', 'plwikiquote', 'plwikisource', 'plwikivoyage', 'plwiktionary', 'pmswiki', 'pmswikisource', 'pnbwiki', 'pnbwiktionary', 'pntwiki', 'pplwiki', 'pswiki', 'pswikivoyage', 'pswiktionary', 'ptwiki', 'ptwikibooks', 'ptwikimedia', 'ptwikinews', 'ptwikiquote', 'ptwikisource', 'ptwikiversity', 'ptwikivoyage', 'ptwiktionary', 'pwnwiki', 'quwiktionary', 'rkiwiki', 'rmwiki', 'rmywiki', 'rnwiki', 'roa_rupwiki', 'roa_rupwiktionary', 'roa_tarawiki', 'rowiki', 'rowikibooks', 'rowikinews', 'rowikiquote', 'rowiktionary', 'rskwiki', 'ruewiki', 'ruwiki', 'ruwikibooks', 'ruwikinews', 'ruwikiquote', 'ruwikisource', 'ruwikiversity', 'ruwikivoyage', 'ruwiktionary', 'rwwiki', 'rwwiktionary', 'sahwiki', 'sahwikiquote', 'sahwikisource', 'satwiki', 'satwiktionary', 'sawiki', 'sawikibooks', 'sawikiquote', 'sawikisource', 'sawiktionary', 'scnwiki', 'scnwiktionary', 'scowiki', 'scwiki', 'sdwiki', 'sdwiktionary', 'sewiki', 'sewikimedia', 'sgwiki', 'sgwiktionary', 'shiwiki', 'shnwiki', 'shnwikibooks', 'shnwikinews', 'shnwikivoyage', 'shnwiktionary', 'shwiki', 'shwiktionary', 'shywiktionary', 'simplewiki', 'simplewiktionary', 'siwiki', 'siwikibooks', 'siwiktionary', 'skwiki', 'skrwiki', 'skrwiktionary', 'slwiki', 'slwikibooks', 'slwikiquote', 'slwikisource', 'slwikiversity', 'slwiktionary', 'smnwiki', 'smwiki', 'smwiktionary', 'snwiki', 'sourceswiki', 'sowiki', 'sowiktionary', 'specieswiki', 'sqwiki', 'sqwikibooks', 'sqwikinews', 'sqwikiquote', 'sqwiktionary', 'srnwiki', 'srwiki', 'srwikibooks', 'srwikinews', 'srwikiquote', 'srwikisource', 'srwiktionary', 'sswiki', 'sswiktionary', 'stqwiki', 'stwiki', 'stwiktionary', 'suwiki', 'suwikiquote', 'suwikisource', 'suwiktionary', 'svwiki', 'svwikibooks', 'svwikinews', 'svwikiquote', 'svwikisource', 'svwikiversity', 'svwikivoyage', 'svwiktionary', 'swwiki', 'swwiktionary', 'sylwiki', 'szlwiki', 'szywiki', 'tawiki', 'tawikibooks', 'tawikinews', 'tawikiquote', 'tawikisource', 'tawiktionary', 'taywiki', 'tcywiki', 'tcywikisource', 'tcywiktionary', 'tddwiki', 'tewiki', 'tewikibooks', 'tewikiquote', 'tewikisource', 'tewiktionary', 'tgwiki', 'tgwikibooks', 'tgwiktionary', 'thwiki', 'thwikibooks', 'thwikimedia', 'thwikiquote', 'thwikisource', 'thwiktionary', 'tigwiki', 'tiwiki', 'tiwiktionary', 'tkwiki', 'tkwiktionary', 'tlwiki', 'tlwikibooks', 'tlwikiquote', 'tlwikisource', 'tlwiktionary', 'tlywiki', 'tnwiki', 'tnwiktionary', 'tokwiki', 'towiki', 'tpiwiki', 'tpiwiktionary', 'trvwiki', 'trwiki', 'trwikibooks', 'trwikimedia', 'trwikinews', 'trwikiquote', 'trwikisource', 'trwikivoyage', 'trwiktionary', 'tswiki', 'tswiktionary', 'ttwiki', 'ttwikibooks', 'ttwikiquote', 'ttwiktionary', 'tumwiki', 'twwiki', 'twwiktionary', 'tyvwiki', 'tywiki', 'uawikimedia', 'udmwiki', 'ugwiki', 'ugwiktionary', 'ukwiki', 'ukwikibooks', 'ukwikinews', 'ukwikiquote', 'ukwikisource', 'ukwikivoyage', 'ukwiktionary', 'urwiki', 'urwikibooks', 'urwikiquote', 'urwikisource', 'urwiktionary', 'uzwiki', 'uzwikiquote', 'uzwiktionary', 'vecwiki', 'vecwikisource', 'vecwiktionary', 'vepwiki', 'vewiki', 'viwiki', 'viwikibooks', 'viwikiquote', 'viwikisource', 'viwikivoyage', 'viwiktionary', 'vlswiki', 'vowiki', 'vowiktionary', 'warwiki', 'wawiki', 'wawikisource', 'wawiktionary', 'wikidatawiki', 'wikimaniawiki', 'wowiki', 'wowiktionary', 'wuuwiki', 'xalwiki', 'xhwiki', 'xmfwiki', 'yiwiki', 'yiwikisource', 'yiwiktionary', 'yowiki', 'zawiki', 'zeawiki', 'zghwiki', 'zghwiktionary', 'zh_classicalwiki', 'zh_min_nanwiki', 'zh_min_nanwikisource', 'zh_min_nanwiktionary', 'zh_yuewiki', 'zhwiki', 'zhwikibooks', 'zhwikinews', 'zhwikiquote', 'zhwikisource', 'zhwikiversity', 'zhwikivoyage', 'zhwiktionary', 'zuwiki', 'zuwiktionary', 'test2wiki', 'testwiki'];
const DELAY_MS = 800;
const sleep = ms => new Promise(r => setTimeout(r, ms));
let userCache = {};
let historyCache = {};
function getDomain(db) {
const mapping = {
'commonswiki': 'commons.wikimedia.org', 'metawiki': 'meta.wikimedia.org', 'wikidatawiki': 'www.wikidata.org',
'mediawikiwiki': 'www.mediawiki.org', 'sourceswiki': 'wikisource.org', 'foundationwiki': 'foundation.wikimedia.org',
'incubatorwiki': 'incubator.wikimedia.org', 'outreachwiki': 'outreach.wikimedia.org',
'be_x_oldwiki': 'be-tarask.wikipedia.org', 'labswiki': 'wikitech.wikimedia.org',
'wikimaniawiki': 'wikimania.wikimedia.org', 'specieswiki': 'species.wikimedia.org',
'abstractwiki': 'abstract.wikipedia.org', 'betawikiversity': 'beta.wikiversity.org',
'mo_wiki': 'ro.wikipedia.org',
'testwiki': 'test.wikipedia.org', 'test2wiki': 'test2.wikipedia.org',
'wikifunctionswiki': 'www.wikifunctions.org',
'testcommonswiki': 'test-commons.wikimedia.org',
'testwikidatawiki': 'test.wikidata.org',
};
if (mapping[db]) return mapping[db];
const name = db.replace(/_/g, '-');
if (name.endsWith('wikisource')) return name.slice(0, -10) + '.wikisource.org';
if (name.endsWith('wikiversity')) return name.slice(0, -11) + '.wikiversity.org';
if (name.endsWith('wiktionary')) return name.slice(0, -10) + '.wiktionary.org';
if (name.endsWith('wikivoyage')) return name.slice(0, -10) + '.wikivoyage.org';
if (name.endsWith('wikibooks')) return name.slice(0, -9) + '.wikibooks.org';
if (name.endsWith('wikiquote')) return name.slice(0, -9) + '.wikiquote.org';
if (name.endsWith('wikinews')) return name.slice(0, -8) + '.wikinews.org';
if (name.endsWith('wikimedia')) return name.slice(0, -9) + '.wikimedia.org';
if (name.endsWith('wiki')) return name.slice(0, -4) + '.wikipedia.org';
return name + '.wikipedia.org';
}
function getInterwikiPrefix(db) {
const specials = {
'commonswiki': 'commons', 'metawiki': 'm', 'wikidatawiki': 'd',
'mediawikiwiki': 'mw', 'specieswiki': 'wikispecies',
'foundationwiki': 'wikimedia', 'sourceswiki': 'wikisource'
};
if (specials[db]) return specials[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 'wikimedia:' + name.slice(0, -9);
if (name.endsWith('wiki')) return 'w:' + name.slice(0, -4);
return 'w:' + name;
}
mw.loader.using(['mediawiki.api', 'mediawiki.ForeignApi']).then(function () {
const metaApi = new mw.ForeignApi('https://meta.wikimedia.org/w/api.php');
const now = new Date();
let results = {}, emptyWikis = [], failedWikis = [], scannedWikis = [], isRunning = false;
let currentFilterMode = 'all';
let currentUserFilter = 'all';
function setupUI() {
const currentYear = now.getFullYear();
let yearOpts = '';
for (let y = currentYear; y >= 2005; y--) yearOpts += `<option value="${y}">${y}</option>`;
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>`;
}
$('#firstHeading').text('GlobalCheckUserStats.js');
$('#mw-content-text').empty().append(`
<div style="border:1px solid #a2a9b1; padding:15px; background:#f8f9fa;">
<h3>Stats Range</h3>
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 style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>Wiki Filter:</strong>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="wiki-mode" id="btn-all" checked> All wikis</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="wiki-mode" id="btn-except"> All wikis except</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><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; font-size:12px; line-height:18px;" title="Show all wiki DB names">?</span>
</div>
<div id="filter-input-container" style="display:none; margin-top:10px;">
<input id="wiki-filter" type="text" style="width:100%; max-width:600px;" placeholder="">
</div>
<div style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>User Filter:</strong>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-all" checked> All users</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-local"> Only Local CUs</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-steward"> Only Stewards</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; color:#202122;">
<strong>Available Wikis (alphabetical):</strong><br>${[...rawWikis].sort().join(', ')}
</div>
<div style="margin-top:20px;">
<button id="start" class="mw-ui-button mw-ui-progressive">Run GlobalCheckUserStats.js</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="${rawWikis.length}" style="width:100%"></progress></div>
<textarea id="out" style="width:100%; height:450px; margin-top:10px; display:none; font-family:monospace; font-size:12px;"></textarea>
</div>
`);
$('#btn-all').click(() => { currentFilterMode = 'all'; $('#filter-input-container').hide(); });
$('#btn-except').click(() => { currentFilterMode = 'exclude'; $('#filter-input-container').show(); $('#wiki-filter').attr('placeholder', 'Exclude (dbname1, dbname2)...').focus(); });
$('#btn-only').click(() => { currentFilterMode = 'include'; $('#filter-input-container').show(); $('#wiki-filter').attr('placeholder', 'Only (dbname1, dbname2)...').focus(); });
$('#u-all').click(() => { currentUserFilter = 'all'; });
$('#u-local').click(() => { currentUserFilter = 'local'; });
$('#u-steward').click(() => { currentUserFilter = 'steward'; });
$('#wiki-help-trigger').click(() => $('#wiki-list-help').toggle());
$('#start').click(() => runAudit($('#y-f').val(), $('#m-f').val(), $('#y-t').val(), $('#m-t').val()));
$('#stop').click(() => isRunning = false);
}
async function fetchWikiMetrics(db) {
try {
const api = new mw.ForeignApi(`https://${getDomain(db)}/w/api.php`);
const res = await api.get({
action: 'query', meta: 'siteinfo', siprop: 'statistics', formatversion: 2
});
return { active: res.query.statistics.activeusers || 0 };
} catch (e) { return { active: 0 }; }
}
async function checkGlobalHistory(user, start, end) {
try {
const res = await metaApi.get({
action: 'query', list: 'logevents', letype: 'gblrights', letitle: 'User:' + user, lelimit: 'max', formatversion: 2
});
const logs = res.query.logevents || [];
const auditStart = new Date(start), auditEnd = new Date(end);
let state = { steward: false, staff: false, ombuds: false };
const priorLogs = logs.filter(l => new Date(l.timestamp) <= auditStart).sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
if (priorLogs.length > 0) {
const p = priorLogs[0].params || {};
const groupsInLog = p.newGroups || p[1] || [];
state.steward = groupsInLog.includes('steward');
state.staff = groupsInLog.includes('staff');
state.ombuds = groupsInLog.includes('ombuds');
}
let held = { wasSteward: state.steward, wasStaff: state.staff, wasOmbuds: state.ombuds };
logs.filter(l => { const ts = new Date(l.timestamp); return ts > auditStart && ts < auditEnd; }).forEach(e => {
const p = e.params || {};
const added = p.newGroups || p[1] || [];
if (added.includes('steward')) held.wasSteward = true;
if (added.includes('staff')) held.wasStaff = true;
if (added.includes('ombuds')) held.wasOmbuds = true;
});
return held;
} catch (e) { return { wasSteward: false, wasStaff: false, wasOmbuds: false }; }
}
async function fetchUserData(user, db, start, end) {
const auditStart = new Date(start), auditEnd = new Date(end);
// 1. FETCH CURRENT GLOBAL GROUPS (Cached)
if (!userCache[user]) {
try {
const gres = await metaApi.get({ action: 'query', meta: 'globaluserinfo', guiprop: 'groups', guiuser: user, formatversion: 2 });
userCache[user] = gres.query.globaluserinfo.groups || [];
} catch (e) { userCache[user] = []; }
}
const gGroups = userCache[user];
const isGloballySteward = gGroups.includes('steward');
const isGloballyStaff = gGroups.includes('staff');
const isGloballyOmbuds = gGroups.includes('ombuds') || gGroups.includes('ombudsman');
// 2. FETCH GLOBAL HISTORY (To identify roles held during the period but lost since)
if (!historyCache[user]) historyCache[user] = await checkGlobalHistory(user, start, end);
const historyRes = historyCache[user];
// 3. CHECK CURRENT LOCAL CHECKUSER STATUS
let isCurrentLocal = false;
try {
const localApi = new mw.ForeignApi(`https://${getDomain(db)}/w/api.php`);
const ures = await localApi.get({ action: 'query', list: 'users', ususers: user, usprop: 'groups', formatversion: 2 });
const lGroups = (ures.query.users[0] && ures.query.users[0].groups) || [];
isCurrentLocal = lGroups.includes('checkuser');
} catch (e) {}
const target = 'User:' + user + '@' + db;
let logText = '', addTime = null, removeTime = null, isSelfAssign = false, maxDurationMins = -1, longestTimeStr = "", assignCount = 0;
// --- 4. DEEP RIGHTS LOG SCAN (Pagination to bypass 500-limit) ---
let events = [];
let continueToken = null;
let finished = false;
try {
while (!finished) {
let params = {
action: 'query', list: 'logevents', letype: 'rights',
letitle: target, ledir: 'older', lelimit: 'max', formatversion: 2
};
if (continueToken) Object.assign(params, continueToken);
const res = await metaApi.get(params);
const batch = res.query.logevents || [];
events = events.concat(batch);
if (res.continue) continueToken = res.continue;
else finished = true;
}
let logEntries = [];
let pendingRemoved = null;
let hasLocalRightsInPeriod = false;
const LIMIT_COMBINE_MINS = 43200; // 30 days
for (let i = 0; i < events.length; i++) {
const e = events[i], p = e.params || {};
const cuAdded = (p.add || []).includes('checkuser') || ((p.newgroups || []).includes('checkuser') && !(p.oldgroups || []).includes('checkuser'));
const cuRemoved = (p.remove || []).includes('checkuser') || (!(p.newgroups || []).includes('checkuser') && (p.oldgroups || []).includes('checkuser'));
const eventDate = new Date(e.timestamp);
const exactTime = e.timestamp.replace('T', ' ').replace('Z', '');
// Track adding/revoking dates across entire history to determine status in audit period
if (cuAdded) addTime = eventDate;
if (cuRemoved) removeTime = eventDate;
// Verification logic: Held rights in period if added before end AND (still active OR removed after start)
if (addTime && addTime <= auditEnd && (!removeTime || removeTime >= auditStart)) {
hasLocalRightsInPeriod = true;
}
// Rights Log Formatting (Only for events within the audit window)
if (eventDate >= auditStart && eventDate <= auditEnd && (cuAdded || cuRemoved)) {
let expiryDate = null;
if (p.newmetadata) {
const cuMeta = p.newmetadata.find(m => m.group === 'checkuser');
if (cuMeta && cuMeta.expiry && cuMeta.expiry !== 'infinity') expiryDate = new Date(cuMeta.expiry);
}
const isSelf = (e.user === user || !e.user) ? " (Self-assign)" : "";
if (e.user === user || !e.user) isSelfAssign = true;
if (cuAdded) {
if (pendingRemoved) {
const diffMins = Math.round(Math.abs(pendingRemoved.date - eventDate) / 60000);
const d = Math.floor(diffMins / 1440), h = Math.floor((diffMins % 1440) / 60), m = diffMins % 60;
const durStr = `${d > 0 ? d+'d ' : ''}${h > 0 ? h+'h ' : ''}${m}m`;
if (diffMins <= LIMIT_COMBINE_MINS) {
logEntries.unshift(`* ADDED: ${exactTime} | REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf} (Duration: ${durStr})`);
} else {
logEntries.unshift(`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf}`);
logEntries.unshift(`* ADDED: ${exactTime} by ${e.user}${isSelf} (Duration: ${durStr})`);
}
if (diffMins > maxDurationMins) { maxDurationMins = diffMins; longestTimeStr = durStr; }
assignCount++; pendingRemoved = null;
} else if (expiryDate) {
const diffMins = Math.round(Math.abs(expiryDate - eventDate) / 60000);
const d = Math.floor(diffMins / 1440), h = Math.floor((diffMins % 1440) / 60), m = diffMins % 60;
const durStr = `${d > 0 ? d+'d ' : ''}${h > 0 ? h+'h ' : ''}${m}m`;
const exactExpiryTime = expiryDate.toISOString().replace('T', ' ').substring(0, 19);
logEntries.unshift(`* ADDED: ${exactTime} | EXPIRED: ${exactExpiryTime} by ${e.user}${isSelf} (Duration: ${durStr})`);
if (diffMins > maxDurationMins) { maxDurationMins = diffMins; longestTimeStr = durStr; }
assignCount++;
} else {
logEntries.unshift(`* ADDED: ${exactTime} | REMOVED: (Active/Not removed) by ${e.user}${isSelf}`);
}
} else if (cuRemoved) {
if (pendingRemoved) logEntries.unshift(`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf}`);
pendingRemoved = { time: exactTime, date: eventDate, user: e.user, isSelf: isSelf };
}
}
}
if (pendingRemoved) logEntries.unshift(`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf}`);
if (logEntries.length) logText = '\n' + logEntries.join('\n');
// --- 5. ROLE CLASSIFICATION ---
let roleLabel = "";
const hasActions = (results[db] && results[db][user] && results[db][user].total > 0);
let wasCU = hasLocalRightsInPeriod || hasActions;
if (isGloballyStaff || historyRes.wasStaff) {
roleLabel = isGloballyStaff ? "Current Staff" : "Former Staff (in period)";
}
else if (isCurrentLocal) {
roleLabel = "Current Local CheckUser";
}
else if (longestTimeStr && isSelfAssign && (isGloballySteward || historyRes.wasSteward)) {
roleLabel = `Steward action (Self-assign: ${assignCount > 1 ? assignCount + 'x, longest ' : ''}${longestTimeStr})`;
}
else if (isGloballySteward || historyRes.wasSteward) {
roleLabel = isGloballySteward ? "Current Steward" : "Former Steward (in period)";
}
else if (isGloballyOmbuds || historyRes.wasOmbuds) {
roleLabel = isGloballyOmbuds ? "Current Ombudsman" : "Former Ombudsman (in period)";
}
else if (wasCU) {
roleLabel = "Former Local CheckUser (in period)";
}
else {
roleLabel = "Unknown role";
}
return { role: roleLabel, log: logText };
} catch (err) {
return { role: "Error fetching data", log: "" };
}
}
async function runAudit(yf, mf, yt, mt) {
isRunning = true; userCache = {}; historyCache = {}; results = {}; emptyWikis = []; failedWikis = []; scannedWikis = [];
const filterText = $('#wiki-filter').val().trim().toLowerCase();
const filterList = filterText ? filterText.split(',').map(s => s.trim()).filter(s => s !== "") : [];
let wikisToScan = rawWikis;
if (currentFilterMode === 'include') wikisToScan = rawWikis.filter(w => filterList.includes(w));
else if (currentFilterMode === 'exclude') wikisToScan = rawWikis.filter(w => !filterList.includes(w));
if (wikisToScan.length === 0) { alert("No wikis selected!"); return; }
$('#start').prop('disabled', true); $('#stop').prop('disabled', false); $('#out').hide();
$('#bar').attr('max', wikisToScan.length).val(0);
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`;
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++; }
}
for (let i = 0; i < wikisToScan.length; i++) {
if (!isRunning) break;
const db = wikisToScan[i]; scannedWikis.push(db);
$('#status-msg').text(`Scanning ${db} (${i + 1}/${wikisToScan.length})...`);
let successLocal = false, continueToken = null;
while (!successLocal && isRunning) {
try {
const api = new mw.ForeignApi(`https://${getDomain(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 api.get(params);
const ent = res.query?.checkuserlog?.entries || [];
if (ent.length) {
if (!results[db]) results[db] = {};
ent.forEach(e => {
const u = e.checkuser;
if (!results[db][u]) results[db][u] = { total: 0, months: {} };
const mKey = e.timestamp.slice(0, 7);
results[db][u].total++; results[db][u].months[mKey] = (results[db][u].months[mKey] || 0) + 1;
});
}
if (res.continue && isRunning) continueToken = res.continue;
else { if (!results[db]) emptyWikis.push(db); successLocal = true; }
} catch (err) {
if (err?.status === 429) {
const retryAfter = parseInt(err.xhr?.getResponseHeader('Retry-After')) || 60;
$('#status-msg').html(`<span style="color:red; font-weight:bold;">Rate limit! Waiting ${retryAfter}s...</span>`);
await sleep(retryAfter * 1000);
continue;
} else {
failedWikis.push(db);
successLocal = true;
}
}
}
$('#bar').val(i + 1); await sleep(DELAY_MS);
}
$('#status-msg').text(`Generating report...`);
const today = new Date().toISOString().split('T')[0];
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' (UTC)';
let wt = `== Global CheckUser Stats (${mf}/${yf} - ${mt}/${yt}) ==\n''Report generated on: ${timestamp}''\n\n`;
let rightsLog = `\n== Rights Log (In Period) ==\n`;
wt += `{| class="wikitable sortable" style="font-size:90%; text-align:right;"\n! Wiki / User !! Category !! '''Total''' !! per 1k active users ${monthCols.map(m => `!! ${m.label}`).join(' ')}\n`;
const sortedDBs = Object.keys(results).sort();
for (const db of sortedDBs) {
const metrics = await fetchWikiMetrics(db);
const fmtActive = metrics.active.toLocaleString('en-US');
const iwPrefix = getInterwikiPrefix(db);
const projectLink = `[[${iwPrefix}:|${db}]]`;
const logLink = `[[${iwPrefix}:Special:CheckUserLog|(log)]]`;
wt += `|-\n! colspan="${4 + monthCols.length}" style="background:#eaecf0; text-align:center;" | ${projectLink} <small style="font-weight:normal; color:#54595d;">(Active Users as of ${today}: ${fmtActive})</small> — ${logLink}\n`;
// ----------------------------
let wikiRows = [], wT = 0, wMS = {}; monthCols.forEach(col => wMS[col.key] = 0);
const projectUsers = Object.keys(results[db]).sort();
for (const user of projectUsers) {
const m = await fetchUserData(user, db, START, END);
const isSteward = m.role.includes("Steward") || m.role.includes("Staff") || m.role.includes("Ombudsman");
const isLocalCU = m.role.includes("Local CheckUser");
if (currentUserFilter === 'local' && !isLocalCU) continue;
if (currentUserFilter === 'steward' && !isSteward) continue;
const uD = results[db][user];
wT += uD.total; monthCols.forEach(col => wMS[col.key] += (uD.months[col.key] || 0));
let uP1kU = metrics.active > 0 ? ((uD.total / metrics.active) * 1000).toFixed(1) : "0.0";
let row = `|-\n| style="text-align:left;" | ${user}@${db} || <small>${m.role}</small> || '''${uD.total}''' || ${uP1kU}`;
monthCols.forEach(col => row += ` || ${uD.months[col.key] || 0}`);
wikiRows.push({ html: row + "\n", log: m.log ? `'''${user}@${db}''':${m.log}\n\n` : "" });
}
let wP1kU = metrics.active > 0 ? ((wT / metrics.active) * 1000).toFixed(1) : "0.0";
let totalRow = `|- style="background:#f8f9fa; font-weight:bold;"\n| style="text-align:left;" | TOTAL ${db} || — || ${wT} || ${wP1kU}`;
monthCols.forEach(col => totalRow += ` || ${wMS[col.key]}`);
wt += totalRow + "\n";
wikiRows.forEach(r => { wt += r.html; if (r.log) rightsLog += r.log; });
}
wt += `|}\n\n== Projects with 0 actions ==\n<div style="font-size:85%; color:#54595d;">${emptyWikis.sort().join(', ')}</div>\n`;
wt += rightsLog + `\n\n<references />\n`;
$('#out').val(wt).show();
$('#status-msg').text(`Done.`); $('#start').prop('disabled', false); $('#stop').prop('disabled', true);
}
setupUI();
});
})();
515en0qcr8c1m88bgob8ipvxls8bzvu
737754
737753
2026-04-12T12:33:57Z
MrJaroslavik
44012
E
737754
javascript
text/javascript
// GlobalCheckUserStats.js
// Features:
// - Global Audit: Scans CheckUser logs across 900+ Wikimedia projects at once.
// - Smart Categorization: Identifies roles (Local CU, Steward, Staff, etc.).
// - Steward Logic: Detects temporary access, exact durations, and longest active periods.
// - Deep Scan: Automated pagination to bypass the 500-entry API limit.
// - Robust Connection: Automated retries for 429 rate-limits and custom domain mapping.
// - Full Reporting: Outputs sortable Wikitables and detailed rights change logs.
// - Custom UI: Integrated control panel on [[Special:BlankPage/GlobalCheckUserStats]].
// - With help of Gemini 3
(function () {
'use strict';
if (mw.config.get('wgCanonicalSpecialPageName') !== 'Blankpage' || mw.config.get('wgTitle').indexOf('GlobalCheckUserStats') === -1) return;
const rawWikis = ['abstractwiki', 'abwiki', 'acewiki', 'adywiki', 'afwiki', 'afwikibooks', 'afwikiquote', 'afwiktionary', 'alswiki', 'altwiki', 'amiwiki', 'amwiki', 'amwiktionary', 'angwiki', 'angwiktionary', 'annwiki', 'anpwiki', 'anwiki', 'anwiktionary', 'arcwiki', 'arwiki', 'arwikibooks', 'arwikimedia', 'arwikinews', 'arwikiquote', 'arwikisource', 'arwikiversity', 'arwiktionary', 'arywiki', 'arzwiki', 'astwiki', 'astwiktionary', 'aswiki', 'aswikiquote', 'aswikisource', 'atjwiki', 'avkwiki', 'avwiki', 'awawiki', 'aywiki', 'aywiktionary', 'azbwiki', 'azwiki', 'azwikibooks', 'azwikiquote', 'azwikisource', 'azwiktionary', 'banwiki', 'banwikisource', 'barwiki', 'bat_smgwiki', 'bawiki', 'bawikibooks', 'bbcwiki', 'bclwiki', 'bclwikiquote', 'bclwikisource', 'bclwiktionary', 'bdrwiki', 'bdwikimedia', 'be_x_oldwiki', 'betawikiversity', 'bewiki', 'bewikibooks', 'bewikimedia', 'bewikiquote', 'bewikisource', 'bewiktionary', 'bewwiki', 'bewwiktionary', 'bgwiki', 'bgwikibooks', 'bgwikiquote', 'bgwikisource', 'bgwiktionary', 'bhwiki', 'biwiki', 'bjnwiki', 'bjnwikiquote', 'bjnwiktionary', 'blkwiki', 'blkwiktionary', 'bmwiki', 'bnwiki', 'bnwikibooks', 'bnwikiquote', 'bnwikisource', 'bnwikivoyage', 'bnwiktionary', 'bowiki', 'bpywiki', 'brwiki', 'brwikimedia', 'brwikiquote', 'brwikisource', 'brwiktionary', 'bswiki', 'bswikibooks', 'bswikinews', 'bswikiquote', 'bswikisource', 'bswiktionary', 'btmwiki', 'btmwiktionary', 'bugwiki', 'bxrwiki', 'cawiki', 'cawikibooks', 'cawikimedia', 'cawikinews', 'cawikiquote', 'cawikisource', 'cawiktionary', 'cbk_zamwiki', 'cdowiki', 'cebwiki', 'cewiki', 'chrwiki', 'chrwiktionary', 'chwiki', 'chywiki', 'ckbwiki', 'ckbwiktionary', 'commonswiki', 'cowiki', 'cowikimedia', 'cowiktionary', 'crhwiki', 'csbwiki', 'csbwiktionary', 'cswiki', 'cswikibooks', 'cswikinews', 'cswikiquote', 'cswikisource', 'cswikiversity', 'cswikivoyage', 'cswiktionary', 'cuwiki', 'cvwiki', 'cvwikibooks', 'cywiki', 'cywikibooks', 'cywikiquote', 'cywikisource', 'cywiktionary', 'dagwiki', 'dawiki', 'dawikibooks', 'dawikiquote', 'dawikisource', 'dawiktionary', 'dewiki', 'dewikibooks', 'dewikinews', 'dewikiquote', 'dewikisource', 'dewikiversity', 'dewikivoyage', 'dewiktionary', 'dgawiki', 'dinwiki', 'diqwiki', 'diqwiktionary', 'dkwikimedia', 'dsbwiki', 'dtpwiki', 'dtywiki', 'dvwiki', 'dvwiktionary', 'dzwiki', 'eewiki', 'elwiki', 'elwikibooks', 'elwikinews', 'elwikiquote', 'elwikisource', 'elwikiversity', 'elwikivoyage', 'elwiktionary', 'emlwiki', 'enwiki', 'enwikibooks', 'enwikinews', 'enwikiquote', 'enwikisource', 'enwikiversity', 'enwikivoyage', 'enwiktionary', 'eowiki', 'eowikibooks', 'eowikinews', 'eowikiquote', 'eowikisource', 'eowikivoyage', 'eowiktionary', 'eswiki', 'eswikibooks', 'eswikinews', 'eswikiquote', 'eswikisource', 'eswikiversity', 'eswikivoyage', 'eswiktionary', 'etwiki', 'etwikibooks', 'eewikimedia', 'etwikiquote', 'etwikisource', 'etwiktionary', 'euwiki', 'euwikibooks', 'euwikiquote', 'euwikisource', 'euwiktionary', 'extwiki', 'fatwiki', 'fawiki', 'fawikibooks', 'fawikinews', 'fawikiquote', 'fawikisource', 'fawikivoyage', 'fawiktionary', 'ffwiki', 'fiu_vrowiki', 'fiwiki', 'fiwikibooks', 'fiwikimedia', 'fiwikinews', 'fiwikiquote', 'fiwikisource', 'fiwikiversity', 'fiwikivoyage', 'fiwiktionary', 'fjwiki', 'fjwiktionary', 'fonwiki', 'foundationwiki', 'fowiki', 'fowikisource', 'fowiktionary', 'frpwiki', 'frrwiki', 'frwiki', 'frwikibooks', 'frwikinews', 'frwikiquote', 'frwikisource', 'frwikiversity', 'frwikivoyage', 'frwiktionary', 'furwiki', 'fywiki', 'fywikibooks', 'fywiktionary', 'gagwiki', 'ganwiki', 'gawiki', 'gawiktionary', 'gcrwiki', 'gdwiki', 'gdwiktionary', 'glkwiki', 'glwiki', 'glwikibooks', 'glwikiquote', 'glwikisource', 'glwiktionary', 'gnwiki', 'gnwiktionary', 'gomwiki', 'gomwiktionary', 'gorwiki', 'gorwikiquote', 'gorwiktionary', 'gotwiki', 'gpewiki', 'gucwiki', 'gurwiki', 'guwiki', 'guwikiquote', 'guwikisource', 'guwiktionary', 'guwwiki', 'guwwikinews', 'guwwikiquote', 'guwwiktionary', 'gvwiki', 'gvwiktionary', 'hakwiki', 'hawiki', 'hawiktionary', 'hawwiki', 'hewiki', 'hewikibooks', 'hewikinews', 'hewikiquote', 'hewikisource', 'hewikivoyage', 'hewiktionary', 'hifwiki', 'hifwiktionary', 'hiwiki', 'hiwikibooks', 'hiwikiquote', 'hiwikisource', 'hiwikiversity', 'hiwikivoyage', 'hiwiktionary', 'hrwiki', 'hrwikibooks', 'hrwikiquote', 'hrwikisource', 'hrwiktionary', 'hsbwiki', 'hsbwiktionary', 'htwiki', 'huwiki', 'huwikibooks', 'huwikisource', 'huwiktionary', 'hywiki', 'hywikibooks', 'hywikiquote', 'hywikisource', 'hywiktionary', 'hywwiki', 'iawiki', 'iawikibooks', 'iawiktionary', 'ibawiki', 'idwiki', 'idwikibooks', 'idwikiquote', 'idwikisource', 'idwikivoyage', 'idwiktionary', 'iewiki', 'iewiktionary', 'iglwiki', 'igwiki', 'igwikiquote', 'igwiktionary', 'ikwiki', 'ilowiki', 'incubatorwiki', 'inhwiki', 'iowiki', 'iowiktionary', 'iswiki', 'iswikibooks', 'iswikiquote', 'iswikisource', 'iswiktionary', 'itwiki', 'itwikibooks', 'itwikinews', 'itwikiquote', 'itwikisource', 'itwikiversity', 'itwikivoyage', 'itwiktionary', 'iuwiki', 'iuwiktionary', 'jamwiki', 'jawiki', 'jawikibooks', 'jawikinews', 'jawikisource', 'jawikiversity', 'jawikivoyage', 'jawiktionary', 'jbowiki', 'jbowiktionary', 'jvwiki', 'jvwikisource', 'jvwiktionary', 'kaawiki', 'kaawiktionary', 'kabwiki', 'kaiwiki', 'kajwiki', 'kawiki', 'kawikibooks', 'kawikiquote', 'kawikisource', 'kawiktionary', 'kbdwiki', 'kbdwiktionary', 'kbpwiki', 'kcgwiki', 'kcgwiktionary', 'kgewiki', 'kgwiki', 'kiwiki', 'kkwiki', 'kkwikibooks', 'kkwiktionary', 'klwiktionary', 'kmwiki', 'kmwikibooks', 'kmwiktionary', 'kncwiki', 'knwiki', 'knwikiquote', 'knwikisource', 'knwiktionary', 'koiwiki', 'kowiki', 'kowikibooks', 'kowikinews', 'kowikiquote', 'kowikisource', 'kowikiversity', 'kowiktionary', 'krcwiki', 'kshwiki', 'kswiki', 'kswiktionary', 'kuswiki', 'kuwiki', 'kuwikibooks', 'kuwikiquote', 'kuwiktionary', 'kvwiki', 'kwwiki', 'kwwiktionary', 'kywiki', 'kywikiquote', 'kywiktionary', 'labswiki', 'ladwiki', 'lawiki', 'lawikibooks', 'lawikiquote', 'lawikisource', 'lawiktionary', 'lbewiki', 'lbwiki', 'lbwiktionary', 'lezwiki', 'lfnwiki', 'lgwiki', 'lijwiki', 'lijwikisource', 'liwiki', 'liwikibooks', 'liwikinews', 'liwikiquote', 'liwikisource', 'liwiktionary', 'lldwiki', 'lmowiki', 'lmowiktionary', 'lnwiki', 'lnwiktionary', 'lowiki', 'lowiktionary', 'ltgwiki', 'ltwiki', 'ltwikibooks', 'ltwikiquote', 'ltwikisource', 'ltwiktionary', 'lvwiki', 'lvwiktionary', 'madwiki', 'madwikisource', 'madwiktionary', 'maiwiki', 'map_bmswiki', 'mdfwiki', 'mediawikiwiki', 'metawiki', 'mgwiki', 'mgwikibooks', 'mgwiktionary', 'mhrwiki', 'minwiki', 'minwikibooks', 'minwikisource', 'minwiktionary', 'miwiki', 'miwiktionary', 'mkwiki', 'mkwikibooks', 'mkwikimedia', 'mkwikisource', 'mkwiktionary', 'mlwiki', 'mlwikibooks', 'mlwikiquote', 'mlwikisource', 'mlwiktionary', 'mniwiki', 'mniwiktionary', 'mnwiki', 'mnwiktionary', 'mnwwiki', 'mnwwiktionary', 'moswiki', 'mrjwiki', 'mrwiki', 'mrwikibooks', 'mrwikiquote', 'mrwikisource', 'mrwiktionary', 'mswiki', 'mswikibooks', 'mswikiquote', 'mswikisource', 'mswiktionary', 'mtwiki', 'mtwiktionary', 'mwlwiki', 'mxwikimedia', 'myvwiki', 'mywiki', 'mywikisource', 'mywiktionary', 'mznwiki', 'nahwiki', 'nahwiktionary', 'napwiki', 'napwikisource', 'nawiktionary', 'nds_nlwiki', 'ndswiki', 'ndswiktionary', 'newiki', 'newikibooks', 'newiktionary', 'newwiki', 'niawiki', 'niawiktionary', 'nlwiki', 'nlwikibooks', 'nlwikimedia', 'nlwikinews', 'nlwikiquote', 'nlwikisource', 'nlwikivoyage', 'nlwiktionary', 'nnwiki', 'nnwikiquote', 'nnwiktionary', 'novwiki', 'nowiki', 'nowikibooks', 'nowikimedia', 'nowikinews', 'nowikiquote', 'nowikisource', 'nowiktionary', 'nqowiki', 'nrmwiki', 'nrwiki', 'nsowiki', 'nupwiki', 'nvwiki', 'nycwikimedia', 'nywiki', 'ocwiki', 'ocwikibooks', 'ocwiktionary', 'olowiki', 'omwiki', 'omwiktionary', 'orwiki', 'orwikisource', 'orwiktionary', 'oswiki', 'outreachwiki', 'pagwiki', 'pamwiki', 'papwiki', 'pawiki', 'pawikibooks', 'pawikisource', 'pawiktionary', 'pcdwiki', 'pcmwiki', 'pcmwikiquote', 'pdcwiki', 'pflwiki', 'piwiki', 'plwiki', 'plwikibooks', 'plwikimedia', 'plwikinews', 'plwikiquote', 'plwikisource', 'plwikivoyage', 'plwiktionary', 'pmswiki', 'pmswikisource', 'pnbwiki', 'pnbwiktionary', 'pntwiki', 'pplwiki', 'pswiki', 'pswikivoyage', 'pswiktionary', 'ptwiki', 'ptwikibooks', 'ptwikimedia', 'ptwikinews', 'ptwikiquote', 'ptwikisource', 'ptwikiversity', 'ptwikivoyage', 'ptwiktionary', 'pwnwiki', 'quwiktionary', 'rkiwiki', 'rmwiki', 'rmywiki', 'rnwiki', 'roa_rupwiki', 'roa_rupwiktionary', 'roa_tarawiki', 'rowiki', 'rowikibooks', 'rowikinews', 'rowikiquote', 'rowiktionary', 'rskwiki', 'ruewiki', 'ruwiki', 'ruwikibooks', 'ruwikinews', 'ruwikiquote', 'ruwikisource', 'ruwikiversity', 'ruwikivoyage', 'ruwiktionary', 'rwwiki', 'rwwiktionary', 'sahwiki', 'sahwikiquote', 'sahwikisource', 'satwiki', 'satwiktionary', 'sawiki', 'sawikibooks', 'sawikiquote', 'sawikisource', 'sawiktionary', 'scnwiki', 'scnwiktionary', 'scowiki', 'scwiki', 'sdwiki', 'sdwiktionary', 'sewiki', 'sewikimedia', 'sgwiki', 'sgwiktionary', 'shiwiki', 'shnwiki', 'shnwikibooks', 'shnwikinews', 'shnwikivoyage', 'shnwiktionary', 'shwiki', 'shwiktionary', 'shywiktionary', 'simplewiki', 'simplewiktionary', 'siwiki', 'siwikibooks', 'siwiktionary', 'skwiki', 'skrwiki', 'skrwiktionary', 'slwiki', 'slwikibooks', 'slwikiquote', 'slwikisource', 'slwikiversity', 'slwiktionary', 'smnwiki', 'smwiki', 'smwiktionary', 'snwiki', 'sourceswiki', 'sowiki', 'sowiktionary', 'specieswiki', 'sqwiki', 'sqwikibooks', 'sqwikinews', 'sqwikiquote', 'sqwiktionary', 'srnwiki', 'srwiki', 'srwikibooks', 'srwikinews', 'srwikiquote', 'srwikisource', 'srwiktionary', 'sswiki', 'sswiktionary', 'stqwiki', 'stwiki', 'stwiktionary', 'suwiki', 'suwikiquote', 'suwikisource', 'suwiktionary', 'svwiki', 'svwikibooks', 'svwikinews', 'svwikiquote', 'svwikisource', 'svwikiversity', 'svwikivoyage', 'svwiktionary', 'swwiki', 'swwiktionary', 'sylwiki', 'szlwiki', 'szywiki', 'tawiki', 'tawikibooks', 'tawikinews', 'tawikiquote', 'tawikisource', 'tawiktionary', 'taywiki', 'tcywiki', 'tcywikisource', 'tcywiktionary', 'tddwiki', 'tewiki', 'tewikibooks', 'tewikiquote', 'tewikisource', 'tewiktionary', 'tgwiki', 'tgwikibooks', 'tgwiktionary', 'thwiki', 'thwikibooks', 'thwikimedia', 'thwikiquote', 'thwikisource', 'thwiktionary', 'tigwiki', 'tiwiki', 'tiwiktionary', 'tkwiki', 'tkwiktionary', 'tlwiki', 'tlwikibooks', 'tlwikiquote', 'tlwikisource', 'tlwiktionary', 'tlywiki', 'tnwiki', 'tnwiktionary', 'tokwiki', 'towiki', 'tpiwiki', 'tpiwiktionary', 'trvwiki', 'trwiki', 'trwikibooks', 'trwikimedia', 'trwikinews', 'trwikiquote', 'trwikisource', 'trwikivoyage', 'trwiktionary', 'tswiki', 'tswiktionary', 'ttwiki', 'ttwikibooks', 'ttwikiquote', 'ttwiktionary', 'tumwiki', 'twwiki', 'twwiktionary', 'tyvwiki', 'tywiki', 'uawikimedia', 'udmwiki', 'ugwiki', 'ugwiktionary', 'ukwiki', 'ukwikibooks', 'ukwikinews', 'ukwikiquote', 'ukwikisource', 'ukwikivoyage', 'ukwiktionary', 'urwiki', 'urwikibooks', 'urwikiquote', 'urwikisource', 'urwiktionary', 'uzwiki', 'uzwikiquote', 'uzwiktionary', 'vecwiki', 'vecwikisource', 'vecwiktionary', 'vepwiki', 'vewiki', 'viwiki', 'viwikibooks', 'viwikiquote', 'viwikisource', 'viwikivoyage', 'viwiktionary', 'vlswiki', 'vowiki', 'vowiktionary', 'warwiki', 'wawiki', 'wawikisource', 'wawiktionary', 'wikidatawiki', 'wikimaniawiki', 'wowiki', 'wowiktionary', 'wuuwiki', 'xalwiki', 'xhwiki', 'xmfwiki', 'yiwiki', 'yiwikisource', 'yiwiktionary', 'yowiki', 'zawiki', 'zeawiki', 'zghwiki', 'zghwiktionary', 'zh_classicalwiki', 'zh_min_nanwiki', 'zh_min_nanwikisource', 'zh_min_nanwiktionary', 'zh_yuewiki', 'zhwiki', 'zhwikibooks', 'zhwikinews', 'zhwikiquote', 'zhwikisource', 'zhwikiversity', 'zhwikivoyage', 'zhwiktionary', 'zuwiki', 'zuwiktionary', 'test2wiki', 'testwiki'];
const DELAY_MS = 800;
const sleep = ms => new Promise(r => setTimeout(r, ms));
let userCache = {};
let historyCache = {};
// Helper function to handle API calls with up to 3 retries on 429 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?.status === 429) {
retries++;
const retryAfter = parseInt(err.xhr?.getResponseHeader('Retry-After')) || (30 * retries);
$('#status-msg').html(`<span style="color:orange; font-weight:bold;">Rate limit hit! Resting ${retryAfter}s... (Attempt ${retries}/${maxRetries})</span>`);
await sleep(retryAfter * 1000);
} else {
throw err;
}
}
}
throw new Error("Max retries reached");
}
function getDomain(db) {
const mapping = {
'commonswiki': 'commons.wikimedia.org', 'metawiki': 'meta.wikimedia.org', 'wikidatawiki': 'www.wikidata.org',
'mediawikiwiki': 'www.mediawiki.org', 'sourceswiki': 'wikisource.org', 'foundationwiki': 'foundation.wikimedia.org',
'incubatorwiki': 'incubator.wikimedia.org', 'outreachwiki': 'outreach.wikimedia.org',
'be_x_oldwiki': 'be-tarask.wikipedia.org', 'labswiki': 'wikitech.wikimedia.org',
'wikimaniawiki': 'wikimania.wikimedia.org', 'specieswiki': 'species.wikimedia.org',
'abstractwiki': 'abstract.wikipedia.org', 'betawikiversity': 'beta.wikiversity.org',
'mo_wiki': 'ro.wikipedia.org',
'testwiki': 'test.wikipedia.org', 'test2wiki': 'test2.wikipedia.org',
'wikifunctionswiki': 'www.wikifunctions.org',
'testcommonswiki': 'test-commons.wikimedia.org',
'testwikidatawiki': 'test.wikidata.org',
};
if (mapping[db]) return mapping[db];
const name = db.replace(/_/g, '-');
if (name.endsWith('wikisource')) return name.slice(0, -10) + '.wikisource.org';
if (name.endsWith('wikiversity')) return name.slice(0, -11) + '.wikiversity.org';
if (name.endsWith('wiktionary')) return name.slice(0, -10) + '.wiktionary.org';
if (name.endsWith('wikivoyage')) return name.slice(0, -10) + '.wikivoyage.org';
if (name.endsWith('wikibooks')) return name.slice(0, -9) + '.wikibooks.org';
if (name.endsWith('wikiquote')) return name.slice(0, -9) + '.wikiquote.org';
if (name.endsWith('wikinews')) return name.slice(0, -8) + '.wikinews.org';
if (name.endsWith('wikimedia')) return name.slice(0, -9) + '.wikimedia.org';
if (name.endsWith('wiki')) return name.slice(0, -4) + '.wikipedia.org';
return name + '.wikipedia.org';
}
function getInterwikiPrefix(db) {
const specials = {
'commonswiki': 'commons', 'metawiki': 'm', 'wikidatawiki': 'd',
'mediawikiwiki': 'mw', 'specieswiki': 'wikispecies',
'foundationwiki': 'wikimedia', 'sourceswiki': 'wikisource'
};
if (specials[db]) return specials[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 'wikimedia:' + name.slice(0, -9);
if (name.endsWith('wiki')) return 'w:' + name.slice(0, -4);
return 'w:' + name;
}
mw.loader.using(['mediawiki.api', 'mediawiki.ForeignApi']).then(function () {
const metaApi = new mw.ForeignApi('https://meta.wikimedia.org/w/api.php');
const now = new Date();
let results = {}, emptyWikis = [], failedWikis = [], scannedWikis = [], isRunning = false;
let currentFilterMode = 'all';
let currentUserFilter = 'all';
function setupUI() {
const currentYear = now.getFullYear();
let yearOpts = '';
for (let y = currentYear; y >= 2005; y--) yearOpts += `<option value="${y}">${y}</option>`;
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>`;
}
$('#firstHeading').text('GlobalCheckUserStats.js');
$('#mw-content-text').empty().append(`
<div style="border:1px solid #a2a9b1; padding:15px; background:#f8f9fa;">
<h3>Stats Range</h3>
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 style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>Wiki Filter:</strong>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="wiki-mode" id="btn-all" checked> All wikis</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="wiki-mode" id="btn-except"> All wikis except</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><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; font-size:12px; line-height:18px;" title="Show all wiki DB names">?</span>
</div>
<div id="filter-input-container" style="display:none; margin-top:10px;">
<input id="wiki-filter" type="text" style="width:100%; max-width:600px;" placeholder="">
</div>
<div style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>User Filter:</strong>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-all" checked> All users</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-local"> Only Local CUs</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-steward"> Only Stewards</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; color:#202122;">
<strong>Available Wikis (alphabetical):</strong><br>${[...rawWikis].sort().join(', ')}
</div>
<div style="margin-top:20px;">
<button id="start" class="mw-ui-button mw-ui-progressive">Run GlobalCheckUserStats.js</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="${rawWikis.length}" style="width:100%"></progress></div>
<textarea id="out" style="width:100%; height:450px; margin-top:10px; display:none; font-family:monospace; font-size:12px;"></textarea>
</div>
`);
$('#btn-all').click(() => { currentFilterMode = 'all'; $('#filter-input-container').hide(); });
$('#btn-except').click(() => { currentFilterMode = 'exclude'; $('#filter-input-container').show(); $('#wiki-filter').attr('placeholder', 'Exclude (dbname1, dbname2)...').focus(); });
$('#btn-only').click(() => { currentFilterMode = 'include'; $('#filter-input-container').show(); $('#wiki-filter').attr('placeholder', 'Only (dbname1, dbname2)...').focus(); });
$('#u-all').click(() => { currentUserFilter = 'all'; });
$('#u-local').click(() => { currentUserFilter = 'local'; });
$('#u-steward').click(() => { currentUserFilter = 'steward'; });
$('#wiki-help-trigger').click(() => $('#wiki-list-help').toggle());
$('#start').click(() => runAudit($('#y-f').val(), $('#m-f').val(), $('#y-t').val(), $('#m-t').val()));
$('#stop').click(() => isRunning = false);
}
async function fetchWikiMetrics(db) {
try {
const api = new mw.ForeignApi(`https://${getDomain(db)}/w/api.php`);
const res = await robustCall(api, {
action: 'query', meta: 'siteinfo', siprop: 'statistics', formatversion: 2
});
return { active: res.query.statistics.activeusers || 0 };
} catch (e) { return { active: 0 }; }
}
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), auditEnd = new Date(end);
let state = { steward: false, staff: false, ombuds: false };
const priorLogs = logs.filter(l => new Date(l.timestamp) <= auditStart).sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
if (priorLogs.length > 0) {
const p = priorLogs[0].params || {};
const groupsInLog = p.newGroups || p[1] || [];
state.steward = groupsInLog.includes('steward');
state.staff = groupsInLog.includes('staff');
state.ombuds = groupsInLog.includes('ombuds');
}
let held = { wasSteward: state.steward, wasStaff: state.staff, wasOmbuds: state.ombuds };
logs.filter(l => { const ts = new Date(l.timestamp); return ts > auditStart && ts < auditEnd; }).forEach(e => {
const p = e.params || {};
const added = p.newGroups || p[1] || [];
if (added.includes('steward')) held.wasSteward = true;
if (added.includes('staff')) held.wasStaff = true;
if (added.includes('ombuds')) held.wasOmbuds = true;
});
return held;
} catch (e) { return { wasSteward: false, wasStaff: false, wasOmbuds: false }; }
}
async function fetchUserData(user, db, start, end) {
const auditStart = new Date(start), auditEnd = new Date(end);
// 1. FETCH CURRENT GLOBAL GROUPS (Cached)
if (!userCache[user]) {
try {
const gres = await robustCall(metaApi, { action: 'query', meta: 'globaluserinfo', guiprop: 'groups', guiuser: user, formatversion: 2 });
userCache[user] = gres.query.globaluserinfo.groups || [];
} catch (e) { userCache[user] = []; }
}
const gGroups = userCache[user];
const isGloballySteward = gGroups.includes('steward');
const isGloballyStaff = gGroups.includes('staff');
const isGloballyOmbuds = gGroups.includes('ombuds') || gGroups.includes('ombudsman');
// 2. FETCH GLOBAL HISTORY (To identify roles held during the period but lost since)
if (!historyCache[user]) historyCache[user] = await checkGlobalHistory(user, start, end);
const historyRes = historyCache[user];
// 3. CHECK CURRENT LOCAL CHECKUSER STATUS
let isCurrentLocal = false;
try {
const localApi = new mw.ForeignApi(`https://${getDomain(db)}/w/api.php`);
const ures = await robustCall(localApi, { action: 'query', list: 'users', ususers: user, usprop: 'groups', formatversion: 2 });
const lGroups = (ures.query.users[0] && ures.query.users[0].groups) || [];
isCurrentLocal = lGroups.includes('checkuser');
} catch (e) {}
const target = 'User:' + user + '@' + db;
let logText = '', addTime = null, removeTime = null, isSelfAssign = false, maxDurationMins = -1, longestTimeStr = "", assignCount = 0, hasLocalRightsInPeriod = false;
// --- 4. DEEP RIGHTS LOG SCAN (Pagination to bypass 500-limit) ---
let events = [];
let continueToken = null;
let finished = false;
try {
while (!finished) {
let params = {
action: 'query', list: 'logevents', letype: 'rights',
letitle: target, ledir: 'older', lelimit: 'max', formatversion: 2
};
if (continueToken) Object.assign(params, continueToken);
const res = await robustCall(metaApi, params);
const batch = res.query.logevents || [];
events = events.concat(batch);
if (res.continue) continueToken = res.continue;
else finished = true;
}
let logEntries = [];
let pendingRemoved = null;
let lastPairedDate = null; // Track last processed ADD to avoid duplicates
const LIMIT_COMBINE_MINS = 43200; // 30 days
for (let i = 0; i < events.length; i++) {
const e = events[i], p = e.params || {};
let cuAdded = (p.add || []).includes('checkuser') || ((p.newgroups || []).includes('checkuser') && !(p.oldgroups || []).includes('checkuser'));
const cuRemoved = (p.remove || []).includes('checkuser') || (!(p.newgroups || []).includes('checkuser') && (p.oldgroups || []).includes('checkuser'));
const cuModified = (p.newgroups || []).includes('checkuser') && (p.oldgroups || []).includes('checkuser');
if (cuModified) cuAdded = true; // Update expiry counts as ADD
const eventDate = new Date(e.timestamp);
const exactTime = e.timestamp.replace('T', ' ').replace('Z', '');
if (cuAdded) addTime = eventDate;
if (cuRemoved) removeTime = eventDate;
if (addTime && addTime <= auditEnd && (!removeTime || removeTime >= auditStart)) hasLocalRightsInPeriod = true;
if (eventDate >= auditStart && eventDate <= auditEnd && (cuAdded || cuRemoved)) {
let expiryDate = null;
if (p.newmetadata) {
const cuMeta = p.newmetadata.find(m => m.group === 'checkuser');
if (cuMeta && cuMeta.expiry && cuMeta.expiry !== 'infinity') expiryDate = new Date(cuMeta.expiry);
}
const isSelf = (e.user === user || !e.user) ? " (Self-assign)" : "";
if (e.user === user || !e.user) isSelfAssign = true;
if (cuAdded) {
if (pendingRemoved) {
const diffMins = Math.round(Math.abs(pendingRemoved.date - eventDate) / 60000);
const d = Math.floor(diffMins / 1440), h = Math.floor((diffMins % 1440) / 60), m = diffMins % 60;
const durStr = `${d > 0 ? d+'d ' : ''}${h > 0 ? h+'h ' : ''}${m}m`;
if (diffMins <= LIMIT_COMBINE_MINS) {
logEntries.unshift(`* ADDED: ${exactTime} | REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf} (Duration: ${durStr})`);
if (diffMins > maxDurationMins) { maxDurationMins = diffMins; longestTimeStr = durStr; }
assignCount++;
pendingRemoved = null;
lastPairedDate = eventDate;
} else {
logEntries.unshift(`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf}`);
pendingRemoved = null;
logEntries.unshift(`* ADDED: ${exactTime} | REMOVED: (Active/Not removed) by ${e.user}${isSelf}`);
lastPairedDate = eventDate;
}
} else if (expiryDate) {
const diffMins = Math.round(Math.abs(expiryDate - eventDate) / 60000);
const d = Math.floor(diffMins / 1440), h = Math.floor((diffMins % 1440) / 60), m = diffMins % 60;
const durStr = `${d > 0 ? d+'d ' : ''}${h > 0 ? h+'h ' : ''}${m}m`;
const exactExpiryTime = expiryDate.toISOString().replace('T', ' ').substring(0, 19);
logEntries.unshift(`* ADDED: ${exactTime} | EXPIRED: ${exactExpiryTime} by ${e.user}${isSelf} (Duration: ${durStr})`);
if (diffMins > maxDurationMins) { maxDurationMins = diffMins; longestTimeStr = durStr; }
assignCount++;
lastPairedDate = eventDate;
} else {
if (lastPairedDate && Math.abs(lastPairedDate - eventDate) < 86400000) {
// Ignore duplicate entry from the same session
} else {
logEntries.unshift(`* ADDED: ${exactTime} | REMOVED: (Active/Not removed) by ${e.user}${isSelf}`);
lastPairedDate = eventDate;
}
}
} else if (cuRemoved) {
if (pendingRemoved) logEntries.unshift(`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf}`);
pendingRemoved = { time: exactTime, date: eventDate, user: e.user, isSelf: isSelf };
}
}
}
if (pendingRemoved) logEntries.unshift(`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf}`);
if (logEntries.length) logText = '\n' + logEntries.join('\n');
// --- 5. ROLE CLASSIFICATION ---
let roleLabel = "";
const hasActions = (results[db] && results[db][user] && results[db][user].total > 0);
let wasCU = hasLocalRightsInPeriod || hasActions;
if (isGloballyStaff || historyRes.wasStaff) {
roleLabel = isGloballyStaff ? "Current Staff" : "Former Staff (in period)";
}
else if (isCurrentLocal) {
roleLabel = "Current Local CheckUser";
}
else if (longestTimeStr && isSelfAssign && (isGloballySteward || historyRes.wasSteward)) {
roleLabel = `Steward action (Self-assign: ${assignCount > 1 ? assignCount + 'x, longest ' : ''}${longestTimeStr})`;
}
else if (isGloballySteward || historyRes.wasSteward) {
roleLabel = isGloballySteward ? "Current Steward" : "Former Steward (in period)";
}
else if (isGloballyOmbuds || historyRes.wasOmbuds) {
roleLabel = isGloballyOmbuds ? "Current Ombudsman" : "Former Ombudsman (in period)";
}
else if (wasCU) {
roleLabel = "Former Local CheckUser (in period)";
}
else {
roleLabel = "Unknown role";
}
return { role: roleLabel, log: logText };
} catch (err) {
return { role: "Error fetching data", log: "" };
}
}
async function runAudit(yf, mf, yt, mt) {
isRunning = true; userCache = {}; historyCache = {}; results = {}; emptyWikis = []; failedWikis = []; scannedWikis = [];
const filterText = $('#wiki-filter').val().trim().toLowerCase();
const filterList = filterText ? filterText.split(',').map(s => s.trim()).filter(s => s !== "") : [];
let wikisToScan = rawWikis;
if (currentFilterMode === 'include') wikisToScan = rawWikis.filter(w => filterList.includes(w));
else if (currentFilterMode === 'exclude') wikisToScan = rawWikis.filter(w => !filterList.includes(w));
if (wikisToScan.length === 0) { alert("No wikis selected!"); return; }
$('#start').prop('disabled', true); $('#stop').prop('disabled', false); $('#out').hide();
$('#bar').attr('max', wikisToScan.length).val(0);
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`;
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++; }
}
for (let i = 0; i < wikisToScan.length; i++) {
if (!isRunning) break;
const db = wikisToScan[i]; scannedWikis.push(db);
$('#status-msg').text(`Scanning ${db} (${i + 1}/${wikisToScan.length})...`);
let successLocal = false, continueToken = null;
while (!successLocal && isRunning) {
try {
const api = new mw.ForeignApi(`https://${getDomain(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 ent = res.query?.checkuserlog?.entries || [];
if (ent.length) {
if (!results[db]) results[db] = {};
ent.forEach(e => {
const u = e.checkuser;
if (!results[db][u]) results[db][u] = { total: 0, months: {} };
const mKey = e.timestamp.slice(0, 7);
results[db][u].total++; results[db][u].months[mKey] = (results[db][u].months[mKey] || 0) + 1;
});
}
if (res.continue && isRunning) continueToken = res.continue;
else { if (!results[db]) emptyWikis.push(db); successLocal = true; }
} catch (err) {
failedWikis.push(db);
successLocal = true;
}
}
$('#bar').val(i + 1); await sleep(DELAY_MS);
}
$('#status-msg').text(`Generating report...`);
const today = new Date().toISOString().split('T')[0];
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' (UTC)';
let wt = `== Global CheckUser Stats (${mf}/${yf} - ${mt}/${yt}) ==\n''Report generated on: ${timestamp}''\n\n`;
let rightsLog = `\n== Rights Log (In Period) ==\n`;
wt += `{| class="wikitable sortable" style="font-size:90%; text-align:right;"\n! Wiki / User !! Category !! '''Total''' !! per 1k active users ${monthCols.map(m => `!! ${m.label}`).join(' ')}\n`;
const sortedDBs = Object.keys(results).sort();
for (const db of sortedDBs) {
const metrics = await fetchWikiMetrics(db);
const fmtActive = metrics.active.toLocaleString('en-US');
const iwPrefix = getInterwikiPrefix(db);
const projectLink = `[[${iwPrefix}:|${db}]]`;
const logLink = `[[${iwPrefix}:Special:CheckUserLog|(log)]]`;
wt += `|-\n! colspan="${4 + monthCols.length}" style="background:#eaecf0; text-align:center;" | ${projectLink} <small style="font-weight:normal; color:#54595d;">(Active Users as of ${today}: ${fmtActive})</small> — ${logLink}\n`;
// ----------------------------
let wikiRows = [], wT = 0, wMS = {}; monthCols.forEach(col => wMS[col.key] = 0);
const projectUsers = Object.keys(results[db]).sort();
for (const user of projectUsers) {
const m = await fetchUserData(user, db, START, END);
const isSteward = m.role.includes("Steward") || m.role.includes("Staff") || m.role.includes("Ombudsman");
const isLocalCU = m.role.includes("Local CheckUser");
if (currentUserFilter === 'local' && !isLocalCU) continue;
if (currentUserFilter === 'steward' && !isSteward) continue;
const uD = results[db][user];
wT += uD.total; monthCols.forEach(col => wMS[col.key] += (uD.months[col.key] || 0));
let uP1kU = metrics.active > 0 ? ((uD.total / metrics.active) * 1000).toFixed(1) : "0.0";
let row = `|-\n| style="text-align:left;" | ${user}@${db} || <small>${m.role}</small> || '''${uD.total}''' || ${uP1kU}`;
monthCols.forEach(col => row += ` || ${uD.months[col.key] || 0}`);
wikiRows.push({ html: row + "\n", log: m.log ? `'''${user}@${db}''':${m.log}\n\n` : "" });
}
let wP1kU = metrics.active > 0 ? ((wT / metrics.active) * 1000).toFixed(1) : "0.0";
let totalRow = `|- style="background:#f8f9fa; font-weight:bold;"\n| style="text-align:left;" | TOTAL ${db} || — || ${wT} || ${wP1kU}`;
monthCols.forEach(col => totalRow += ` || ${wMS[col.key]}`);
wt += totalRow + "\n";
wikiRows.forEach(r => { wt += r.html; if (r.log) rightsLog += r.log; });
}
wt += `|}\n\n== Projects with 0 actions ==\n<div style="font-size:85%; color:#54595d;">${emptyWikis.sort().join(', ')}</div>\n`;
if (failedWikis.length > 0) {
wt += `\n== API Errors / Skipped Projects ==\n<div style="font-size:85%; color:#d33;">${failedWikis.sort().join(', ')}</div>\n`;
}
wt += rightsLog + `\n\n<references />\n`;
$('#out').val(wt).show();
$('#status-msg').text(`Done.`); $('#start').prop('disabled', false); $('#stop').prop('disabled', true);
}
setupUI();
});
})();
niuxvp133viiw7ne7c7n6bb6b2qbth0
737755
737754
2026-04-12T12:37:55Z
MrJaroslavik
44012
e
737755
javascript
text/javascript
// GlobalCheckUserStats.js
// Features:
// - Global Audit: Scans CheckUser logs across 900+ Wikimedia projects at once.
// - Smart Categorization: Identifies roles (Local CU, Steward, Staff, etc.).
// - Steward Logic: Detects temporary access, exact durations, and longest active periods.
// - Deep Scan: Automated pagination to bypass the 500-entry API limit.
// - Robust Connection: Automated retries for 429 rate-limits and custom domain mapping.
// - Full Reporting: Outputs sortable Wikitables and detailed rights change logs.
// - Custom UI: Integrated control panel on [[Special:BlankPage/GlobalCheckUserStats]].
// - With help of Gemini 3
(function () {
'use strict';
if (mw.config.get('wgCanonicalSpecialPageName') !== 'Blankpage' || mw.config.get('wgTitle').indexOf('GlobalCheckUserStats') === -1) return;
const rawWikis = ['abstractwiki', 'abwiki', 'acewiki', 'adywiki', 'afwiki', 'afwikibooks', 'afwikiquote', 'afwiktionary', 'alswiki', 'altwiki', 'amiwiki', 'amwiki', 'amwiktionary', 'angwiki', 'angwiktionary', 'annwiki', 'anpwiki', 'anwiki', 'anwiktionary', 'arcwiki', 'arwiki', 'arwikibooks', 'arwikimedia', 'arwikinews', 'arwikiquote', 'arwikisource', 'arwikiversity', 'arwiktionary', 'arywiki', 'arzwiki', 'astwiki', 'astwiktionary', 'aswiki', 'aswikiquote', 'aswikisource', 'atjwiki', 'avkwiki', 'avwiki', 'awawiki', 'aywiki', 'aywiktionary', 'azbwiki', 'azwiki', 'azwikibooks', 'azwikiquote', 'azwikisource', 'azwiktionary', 'banwiki', 'banwikisource', 'barwiki', 'bat_smgwiki', 'bawiki', 'bawikibooks', 'bbcwiki', 'bclwiki', 'bclwikiquote', 'bclwikisource', 'bclwiktionary', 'bdrwiki', 'bdwikimedia', 'be_x_oldwiki', 'betawikiversity', 'bewiki', 'bewikibooks', 'bewikimedia', 'bewikiquote', 'bewikisource', 'bewiktionary', 'bewwiki', 'bewwiktionary', 'bgwiki', 'bgwikibooks', 'bgwikiquote', 'bgwikisource', 'bgwiktionary', 'bhwiki', 'biwiki', 'bjnwiki', 'bjnwikiquote', 'bjnwiktionary', 'blkwiki', 'blkwiktionary', 'bmwiki', 'bnwiki', 'bnwikibooks', 'bnwikiquote', 'bnwikisource', 'bnwikivoyage', 'bnwiktionary', 'bowiki', 'bpywiki', 'brwiki', 'brwikimedia', 'brwikiquote', 'brwikisource', 'brwiktionary', 'bswiki', 'bswikibooks', 'bswikinews', 'bswikiquote', 'bswikisource', 'bswiktionary', 'btmwiki', 'btmwiktionary', 'bugwiki', 'bxrwiki', 'cawiki', 'cawikibooks', 'cawikimedia', 'cawikinews', 'cawikiquote', 'cawikisource', 'cawiktionary', 'cbk_zamwiki', 'cdowiki', 'cebwiki', 'cewiki', 'chrwiki', 'chrwiktionary', 'chwiki', 'chywiki', 'ckbwiki', 'ckbwiktionary', 'commonswiki', 'cowiki', 'cowikimedia', 'cowiktionary', 'crhwiki', 'csbwiki', 'csbwiktionary', 'cswiki', 'cswikibooks', 'cswikinews', 'cswikiquote', 'cswikisource', 'cswikiversity', 'cswikivoyage', 'cswiktionary', 'cuwiki', 'cvwiki', 'cvwikibooks', 'cywiki', 'cywikibooks', 'cywikiquote', 'cywikisource', 'cywiktionary', 'dagwiki', 'dawiki', 'dawikibooks', 'dawikiquote', 'dawikisource', 'dawiktionary', 'dewiki', 'dewikibooks', 'dewikinews', 'dewikiquote', 'dewikisource', 'dewikiversity', 'dewikivoyage', 'dewiktionary', 'dgawiki', 'dinwiki', 'diqwiki', 'diqwiktionary', 'dkwikimedia', 'dsbwiki', 'dtpwiki', 'dtywiki', 'dvwiki', 'dvwiktionary', 'dzwiki', 'eewiki', 'elwiki', 'elwikibooks', 'elwikinews', 'elwikiquote', 'elwikisource', 'elwikiversity', 'elwikivoyage', 'elwiktionary', 'emlwiki', 'enwiki', 'enwikibooks', 'enwikinews', 'enwikiquote', 'enwikisource', 'enwikiversity', 'enwikivoyage', 'enwiktionary', 'eowiki', 'eowikibooks', 'eowikinews', 'eowikiquote', 'eowikisource', 'eowikivoyage', 'eowiktionary', 'eswiki', 'eswikibooks', 'eswikinews', 'eswikiquote', 'eswikisource', 'eswikiversity', 'eswikivoyage', 'eswiktionary', 'etwiki', 'etwikibooks', 'eewikimedia', 'etwikiquote', 'etwikisource', 'etwiktionary', 'euwiki', 'euwikibooks', 'euwikiquote', 'euwikisource', 'euwiktionary', 'extwiki', 'fatwiki', 'fawiki', 'fawikibooks', 'fawikinews', 'fawikiquote', 'fawikisource', 'fawikivoyage', 'fawiktionary', 'ffwiki', 'fiu_vrowiki', 'fiwiki', 'fiwikibooks', 'fiwikimedia', 'fiwikinews', 'fiwikiquote', 'fiwikisource', 'fiwikiversity', 'fiwikivoyage', 'fiwiktionary', 'fjwiki', 'fjwiktionary', 'fonwiki', 'foundationwiki', 'fowiki', 'fowikisource', 'fowiktionary', 'frpwiki', 'frrwiki', 'frwiki', 'frwikibooks', 'frwikinews', 'frwikiquote', 'frwikisource', 'frwikiversity', 'frwikivoyage', 'frwiktionary', 'furwiki', 'fywiki', 'fywikibooks', 'fywiktionary', 'gagwiki', 'ganwiki', 'gawiki', 'gawiktionary', 'gcrwiki', 'gdwiki', 'gdwiktionary', 'glkwiki', 'glwiki', 'glwikibooks', 'glwikiquote', 'glwikisource', 'glwiktionary', 'gnwiki', 'gnwiktionary', 'gomwiki', 'gomwiktionary', 'gorwiki', 'gorwikiquote', 'gorwiktionary', 'gotwiki', 'gpewiki', 'gucwiki', 'gurwiki', 'guwiki', 'guwikiquote', 'guwikisource', 'guwiktionary', 'guwwiki', 'guwwikinews', 'guwwikiquote', 'guwwiktionary', 'gvwiki', 'gvwiktionary', 'hakwiki', 'hawiki', 'hawiktionary', 'hawwiki', 'hewiki', 'hewikibooks', 'hewikinews', 'hewikiquote', 'hewikisource', 'hewikivoyage', 'hewiktionary', 'hifwiki', 'hifwiktionary', 'hiwiki', 'hiwikibooks', 'hiwikiquote', 'hiwikisource', 'hiwikiversity', 'hiwikivoyage', 'hiwiktionary', 'hrwiki', 'hrwikibooks', 'hrwikiquote', 'hrwikisource', 'hrwiktionary', 'hsbwiki', 'hsbwiktionary', 'htwiki', 'huwiki', 'huwikibooks', 'huwikisource', 'huwiktionary', 'hywiki', 'hywikibooks', 'hywikiquote', 'hywikisource', 'hywiktionary', 'hywwiki', 'iawiki', 'iawikibooks', 'iawiktionary', 'ibawiki', 'idwiki', 'idwikibooks', 'idwikiquote', 'idwikisource', 'idwikivoyage', 'idwiktionary', 'iewiki', 'iewiktionary', 'iglwiki', 'igwiki', 'igwikiquote', 'igwiktionary', 'ikwiki', 'ilowiki', 'incubatorwiki', 'inhwiki', 'iowiki', 'iowiktionary', 'iswiki', 'iswikibooks', 'iswikiquote', 'iswikisource', 'iswiktionary', 'itwiki', 'itwikibooks', 'itwikinews', 'itwikiquote', 'itwikisource', 'itwikiversity', 'itwikivoyage', 'itwiktionary', 'iuwiki', 'iuwiktionary', 'jamwiki', 'jawiki', 'jawikibooks', 'jawikinews', 'jawikisource', 'jawikiversity', 'jawikivoyage', 'jawiktionary', 'jbowiki', 'jbowiktionary', 'jvwiki', 'jvwikisource', 'jvwiktionary', 'kaawiki', 'kaawiktionary', 'kabwiki', 'kaiwiki', 'kajwiki', 'kawiki', 'kawikibooks', 'kawikiquote', 'kawikisource', 'kawiktionary', 'kbdwiki', 'kbdwiktionary', 'kbpwiki', 'kcgwiki', 'kcgwiktionary', 'kgewiki', 'kgwiki', 'kiwiki', 'kkwiki', 'kkwikibooks', 'kkwiktionary', 'klwiktionary', 'kmwiki', 'kmwikibooks', 'kmwiktionary', 'kncwiki', 'knwiki', 'knwikiquote', 'knwikisource', 'knwiktionary', 'koiwiki', 'kowiki', 'kowikibooks', 'kowikinews', 'kowikiquote', 'kowikisource', 'kowikiversity', 'kowiktionary', 'krcwiki', 'kshwiki', 'kswiki', 'kswiktionary', 'kuswiki', 'kuwiki', 'kuwikibooks', 'kuwikiquote', 'kuwiktionary', 'kvwiki', 'kwwiki', 'kwwiktionary', 'kywiki', 'kywikiquote', 'kywiktionary', 'labswiki', 'ladwiki', 'lawiki', 'lawikibooks', 'lawikiquote', 'lawikisource', 'lawiktionary', 'lbewiki', 'lbwiki', 'lbwiktionary', 'lezwiki', 'lfnwiki', 'lgwiki', 'lijwiki', 'lijwikisource', 'liwiki', 'liwikibooks', 'liwikinews', 'liwikiquote', 'liwikisource', 'liwiktionary', 'lldwiki', 'lmowiki', 'lmowiktionary', 'lnwiki', 'lnwiktionary', 'lowiki', 'lowiktionary', 'ltgwiki', 'ltwiki', 'ltwikibooks', 'ltwikiquote', 'ltwikisource', 'ltwiktionary', 'lvwiki', 'lvwiktionary', 'madwiki', 'madwikisource', 'madwiktionary', 'maiwiki', 'map_bmswiki', 'mdfwiki', 'mediawikiwiki', 'metawiki', 'mgwiki', 'mgwikibooks', 'mgwiktionary', 'mhrwiki', 'minwiki', 'minwikibooks', 'minwikisource', 'minwiktionary', 'miwiki', 'miwiktionary', 'mkwiki', 'mkwikibooks', 'mkwikimedia', 'mkwikisource', 'mkwiktionary', 'mlwiki', 'mlwikibooks', 'mlwikiquote', 'mlwikisource', 'mlwiktionary', 'mniwiki', 'mniwiktionary', 'mnwiki', 'mnwiktionary', 'mnwwiki', 'mnwwiktionary', 'moswiki', 'mrjwiki', 'mrwiki', 'mrwikibooks', 'mrwikiquote', 'mrwikisource', 'mrwiktionary', 'mswiki', 'mswikibooks', 'mswikiquote', 'mswikisource', 'mswiktionary', 'mtwiki', 'mtwiktionary', 'mwlwiki', 'mxwikimedia', 'myvwiki', 'mywiki', 'mywikisource', 'mywiktionary', 'mznwiki', 'nahwiki', 'nahwiktionary', 'napwiki', 'napwikisource', 'nawiktionary', 'nds_nlwiki', 'ndswiki', 'ndswiktionary', 'newiki', 'newikibooks', 'newiktionary', 'newwiki', 'niawiki', 'niawiktionary', 'nlwiki', 'nlwikibooks', 'nlwikimedia', 'nlwikinews', 'nlwikiquote', 'nlwikisource', 'nlwikivoyage', 'nlwiktionary', 'nnwiki', 'nnwikiquote', 'nnwiktionary', 'novwiki', 'nowiki', 'nowikibooks', 'nowikimedia', 'nowikinews', 'nowikiquote', 'nowikisource', 'nowiktionary', 'nqowiki', 'nrmwiki', 'nrwiki', 'nsowiki', 'nupwiki', 'nvwiki', 'nycwikimedia', 'nywiki', 'ocwiki', 'ocwikibooks', 'ocwiktionary', 'olowiki', 'omwiki', 'omwiktionary', 'orwiki', 'orwikisource', 'orwiktionary', 'oswiki', 'outreachwiki', 'pagwiki', 'pamwiki', 'papwiki', 'pawiki', 'pawikibooks', 'pawikisource', 'pawiktionary', 'pcdwiki', 'pcmwiki', 'pcmwikiquote', 'pdcwiki', 'pflwiki', 'piwiki', 'plwiki', 'plwikibooks', 'plwikimedia', 'plwikinews', 'plwikiquote', 'plwikisource', 'plwikivoyage', 'plwiktionary', 'pmswiki', 'pmswikisource', 'pnbwiki', 'pnbwiktionary', 'pntwiki', 'pplwiki', 'pswiki', 'pswikivoyage', 'pswiktionary', 'ptwiki', 'ptwikibooks', 'ptwikimedia', 'ptwikinews', 'ptwikiquote', 'ptwikisource', 'ptwikiversity', 'ptwikivoyage', 'ptwiktionary', 'pwnwiki', 'quwiktionary', 'rkiwiki', 'rmwiki', 'rmywiki', 'rnwiki', 'roa_rupwiki', 'roa_rupwiktionary', 'roa_tarawiki', 'rowiki', 'rowikibooks', 'rowikinews', 'rowikiquote', 'rowiktionary', 'rskwiki', 'ruewiki', 'ruwiki', 'ruwikibooks', 'ruwikinews', 'ruwikiquote', 'ruwikisource', 'ruwikiversity', 'ruwikivoyage', 'ruwiktionary', 'rwwiki', 'rwwiktionary', 'sahwiki', 'sahwikiquote', 'sahwikisource', 'satwiki', 'satwiktionary', 'sawiki', 'sawikibooks', 'sawikiquote', 'sawikisource', 'sawiktionary', 'scnwiki', 'scnwiktionary', 'scowiki', 'scwiki', 'sdwiki', 'sdwiktionary', 'sewiki', 'sewikimedia', 'sgwiki', 'sgwiktionary', 'shiwiki', 'shnwiki', 'shnwikibooks', 'shnwikinews', 'shnwikivoyage', 'shnwiktionary', 'shwiki', 'shwiktionary', 'shywiktionary', 'simplewiki', 'simplewiktionary', 'siwiki', 'siwikibooks', 'siwiktionary', 'skwiki', 'skrwiki', 'skrwiktionary', 'slwiki', 'slwikibooks', 'slwikiquote', 'slwikisource', 'slwikiversity', 'slwiktionary', 'smnwiki', 'smwiki', 'smwiktionary', 'snwiki', 'sourceswiki', 'sowiki', 'sowiktionary', 'specieswiki', 'sqwiki', 'sqwikibooks', 'sqwikinews', 'sqwikiquote', 'sqwiktionary', 'srnwiki', 'srwiki', 'srwikibooks', 'srwikinews', 'srwikiquote', 'srwikisource', 'srwiktionary', 'sswiki', 'sswiktionary', 'stqwiki', 'stwiki', 'stwiktionary', 'suwiki', 'suwikiquote', 'suwikisource', 'suwiktionary', 'svwiki', 'svwikibooks', 'svwikinews', 'svwikiquote', 'svwikisource', 'svwikiversity', 'svwikivoyage', 'svwiktionary', 'swwiki', 'swwiktionary', 'sylwiki', 'szlwiki', 'szywiki', 'tawiki', 'tawikibooks', 'tawikinews', 'tawikiquote', 'tawikisource', 'tawiktionary', 'taywiki', 'tcywiki', 'tcywikisource', 'tcywiktionary', 'tddwiki', 'tewiki', 'tewikibooks', 'tewikiquote', 'tewikisource', 'tewiktionary', 'tgwiki', 'tgwikibooks', 'tgwiktionary', 'thwiki', 'thwikibooks', 'thwikimedia', 'thwikiquote', 'thwikisource', 'thwiktionary', 'tigwiki', 'tiwiki', 'tiwiktionary', 'tkwiki', 'tkwiktionary', 'tlwiki', 'tlwikibooks', 'tlwikiquote', 'tlwikisource', 'tlwiktionary', 'tlywiki', 'tnwiki', 'tnwiktionary', 'tokwiki', 'towiki', 'tpiwiki', 'tpiwiktionary', 'trvwiki', 'trwiki', 'trwikibooks', 'trwikimedia', 'trwikinews', 'trwikiquote', 'trwikisource', 'trwikivoyage', 'trwiktionary', 'tswiki', 'tswiktionary', 'ttwiki', 'ttwikibooks', 'ttwikiquote', 'ttwiktionary', 'tumwiki', 'twwiki', 'twwiktionary', 'tyvwiki', 'tywiki', 'uawikimedia', 'udmwiki', 'ugwiki', 'ugwiktionary', 'ukwiki', 'ukwikibooks', 'ukwikinews', 'ukwikiquote', 'ukwikisource', 'ukwikivoyage', 'ukwiktionary', 'urwiki', 'urwikibooks', 'urwikiquote', 'urwikisource', 'urwiktionary', 'uzwiki', 'uzwikiquote', 'uzwiktionary', 'vecwiki', 'vecwikisource', 'vecwiktionary', 'vepwiki', 'vewiki', 'viwiki', 'viwikibooks', 'viwikiquote', 'viwikisource', 'viwikivoyage', 'viwiktionary', 'vlswiki', 'vowiki', 'vowiktionary', 'warwiki', 'wawiki', 'wawikisource', 'wawiktionary', 'wikidatawiki', 'wikimaniawiki', 'wowiki', 'wowiktionary', 'wuuwiki', 'xalwiki', 'xhwiki', 'xmfwiki', 'yiwiki', 'yiwikisource', 'yiwiktionary', 'yowiki', 'zawiki', 'zeawiki', 'zghwiki', 'zghwiktionary', 'zh_classicalwiki', 'zh_min_nanwiki', 'zh_min_nanwikisource', 'zh_min_nanwiktionary', 'zh_yuewiki', 'zhwiki', 'zhwikibooks', 'zhwikinews', 'zhwikiquote', 'zhwikisource', 'zhwikiversity', 'zhwikivoyage', 'zhwiktionary', 'zuwiki', 'zuwiktionary', 'test2wiki', 'testwiki'];
const DELAY_MS = 800;
const sleep = ms => new Promise(r => setTimeout(r, ms));
let userCache = {};
let historyCache = {};
// Helper function to handle API calls with up to 3 retries on 429 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?.status === 429) {
retries++;
const retryAfter = parseInt(err.xhr?.getResponseHeader('Retry-After')) || (30 * retries);
$('#status-msg').html(`<span style="color:orange; font-weight:bold;">Rate limit hit! Resting ${retryAfter}s... (Attempt ${retries}/${maxRetries})</span>`);
await sleep(retryAfter * 1000);
} else {
throw err;
}
}
}
throw new Error("Max retries reached");
}
function getDomain(db) {
const mapping = {
'commonswiki': 'commons.wikimedia.org', 'metawiki': 'meta.wikimedia.org', 'wikidatawiki': 'www.wikidata.org',
'mediawikiwiki': 'www.mediawiki.org', 'sourceswiki': 'wikisource.org', 'foundationwiki': 'foundation.wikimedia.org',
'incubatorwiki': 'incubator.wikimedia.org', 'outreachwiki': 'outreach.wikimedia.org',
'be_x_oldwiki': 'be-tarask.wikipedia.org', 'labswiki': 'wikitech.wikimedia.org',
'wikimaniawiki': 'wikimania.wikimedia.org', 'specieswiki': 'species.wikimedia.org',
'abstractwiki': 'abstract.wikipedia.org', 'betawikiversity': 'beta.wikiversity.org',
'mo_wiki': 'ro.wikipedia.org',
'testwiki': 'test.wikipedia.org', 'test2wiki': 'test2.wikipedia.org',
'wikifunctionswiki': 'www.wikifunctions.org',
'testcommonswiki': 'test-commons.wikimedia.org',
'testwikidatawiki': 'test.wikidata.org',
};
if (mapping[db]) return mapping[db];
const name = db.replace(/_/g, '-');
if (name.endsWith('wikisource')) return name.slice(0, -10) + '.wikisource.org';
if (name.endsWith('wikiversity')) return name.slice(0, -11) + '.wikiversity.org';
if (name.endsWith('wiktionary')) return name.slice(0, -10) + '.wiktionary.org';
if (name.endsWith('wikivoyage')) return name.slice(0, -10) + '.wikivoyage.org';
if (name.endsWith('wikibooks')) return name.slice(0, -9) + '.wikibooks.org';
if (name.endsWith('wikiquote')) return name.slice(0, -9) + '.wikiquote.org';
if (name.endsWith('wikinews')) return name.slice(0, -8) + '.wikinews.org';
if (name.endsWith('wikimedia')) return name.slice(0, -9) + '.wikimedia.org';
if (name.endsWith('wiki')) return name.slice(0, -4) + '.wikipedia.org';
return name + '.wikipedia.org';
}
function getInterwikiPrefix(db) {
const specials = {
'commonswiki': 'commons', 'metawiki': 'm', 'wikidatawiki': 'd',
'mediawikiwiki': 'mw', 'specieswiki': 'wikispecies',
'foundationwiki': 'wikimedia', 'sourceswiki': 'wikisource'
};
if (specials[db]) return specials[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 'wikimedia:' + name.slice(0, -9);
if (name.endsWith('wiki')) return 'w:' + name.slice(0, -4);
return 'w:' + name;
}
mw.loader.using(['mediawiki.api', 'mediawiki.ForeignApi']).then(function () {
const metaApi = new mw.ForeignApi('https://meta.wikimedia.org/w/api.php');
const now = new Date();
let results = {}, emptyWikis = [], failedWikis = [], scannedWikis = [], isRunning = false;
let currentFilterMode = 'all';
let currentUserFilter = 'all';
function setupUI() {
const currentYear = now.getFullYear();
let yearOpts = '';
for (let y = currentYear; y >= 2005; y--) yearOpts += `<option value="${y}">${y}</option>`;
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>`;
}
$('#firstHeading').text('GlobalCheckUserStats.js');
$('#mw-content-text').empty().append(`
<div style="border:1px solid #a2a9b1; padding:15px; background:#f8f9fa;">
<h3>Stats Range</h3>
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 style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>Wiki Filter:</strong>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="wiki-mode" id="btn-all" checked> All wikis</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="wiki-mode" id="btn-except"> All wikis except</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><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; font-size:12px; line-height:18px;" title="Show all wiki DB names">?</span>
</div>
<div id="filter-input-container" style="display:none; margin-top:10px;">
<input id="wiki-filter" type="text" style="width:100%; max-width:600px;" placeholder="">
</div>
<div style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>User Filter:</strong>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-all" checked> All users</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-local"> Only Local CUs</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-steward"> Only Stewards</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; color:#202122;">
<strong>Available Wikis (alphabetical):</strong><br>${[...rawWikis].sort().join(', ')}
</div>
<div style="margin-top:20px;">
<button id="start" class="mw-ui-button mw-ui-progressive">Run GlobalCheckUserStats.js</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="${rawWikis.length}" style="width:100%"></progress></div>
<textarea id="out" style="width:100%; height:450px; margin-top:10px; display:none; font-family:monospace; font-size:12px;"></textarea>
</div>
`);
$('#btn-all').click(() => { currentFilterMode = 'all'; $('#filter-input-container').hide(); });
$('#btn-except').click(() => { currentFilterMode = 'exclude'; $('#filter-input-container').show(); $('#wiki-filter').attr('placeholder', 'Exclude (dbname1, dbname2)...').focus(); });
$('#btn-only').click(() => { currentFilterMode = 'include'; $('#filter-input-container').show(); $('#wiki-filter').attr('placeholder', 'Only (dbname1, dbname2)...').focus(); });
$('#u-all').click(() => { currentUserFilter = 'all'; });
$('#u-local').click(() => { currentUserFilter = 'local'; });
$('#u-steward').click(() => { currentUserFilter = 'steward'; });
$('#wiki-help-trigger').click(() => $('#wiki-list-help').toggle());
$('#start').click(() => runAudit($('#y-f').val(), $('#m-f').val(), $('#y-t').val(), $('#m-t').val()));
$('#stop').click(() => isRunning = false);
}
async function fetchWikiMetrics(db) {
try {
const api = new mw.ForeignApi(`https://${getDomain(db)}/w/api.php`);
const res = await robustCall(api, {
action: 'query', meta: 'siteinfo', siprop: 'statistics', formatversion: 2
});
return { active: res.query.statistics.activeusers || 0 };
} catch (e) { return { active: 0 }; }
}
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), auditEnd = new Date(end);
let state = { steward: false, staff: false, ombuds: false };
const priorLogs = logs.filter(l => new Date(l.timestamp) <= auditStart).sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
if (priorLogs.length > 0) {
const p = priorLogs[0].params || {};
const groupsInLog = p.newGroups || p[1] || [];
state.steward = groupsInLog.includes('steward');
state.staff = groupsInLog.includes('staff');
state.ombuds = groupsInLog.includes('ombuds');
}
let held = { wasSteward: state.steward, wasStaff: state.staff, wasOmbuds: state.ombuds };
logs.filter(l => { const ts = new Date(l.timestamp); return ts > auditStart && ts < auditEnd; }).forEach(e => {
const p = e.params || {};
const added = p.newGroups || p[1] || [];
if (added.includes('steward')) held.wasSteward = true;
if (added.includes('staff')) held.wasStaff = true;
if (added.includes('ombuds')) held.wasOmbuds = true;
});
return held;
} catch (e) { return { wasSteward: false, wasStaff: false, wasOmbuds: false }; }
}
async function fetchUserData(user, db, start, end) {
const auditStart = new Date(start), auditEnd = new Date(end);
// 1. FETCH CURRENT GLOBAL GROUPS (Cached)
if (!userCache[user]) {
try {
const gres = await robustCall(metaApi, { action: 'query', meta: 'globaluserinfo', guiprop: 'groups', guiuser: user, formatversion: 2 });
userCache[user] = gres.query.globaluserinfo.groups || [];
} catch (e) { userCache[user] = []; }
}
const gGroups = userCache[user];
const isGloballySteward = gGroups.includes('steward');
const isGloballyStaff = gGroups.includes('staff');
const isGloballyOmbuds = gGroups.includes('ombuds') || gGroups.includes('ombudsman');
// 2. FETCH GLOBAL HISTORY (To identify roles held during the period but lost since)
if (!historyCache[user]) historyCache[user] = await checkGlobalHistory(user, start, end);
const historyRes = historyCache[user];
// 3. CHECK CURRENT LOCAL CHECKUSER STATUS
let isCurrentLocal = false;
try {
const localApi = new mw.ForeignApi(`https://${getDomain(db)}/w/api.php`);
const ures = await robustCall(localApi, { action: 'query', list: 'users', ususers: user, usprop: 'groups', formatversion: 2 });
const lGroups = (ures.query.users[0] && ures.query.users[0].groups) || [];
isCurrentLocal = lGroups.includes('checkuser');
} catch (e) {}
const target = 'User:' + user + '@' + db;
let logText = '', addTime = null, removeTime = null, isSelfAssign = false, maxDurationMins = -1, longestTimeStr = "", assignCount = 0, hasLocalRightsInPeriod = false;
// --- 4. DEEP RIGHTS LOG SCAN (Pagination to bypass 500-limit) ---
let events = [];
let continueToken = null;
let finished = false;
try {
while (!finished) {
let params = {
action: 'query', list: 'logevents', letype: 'rights',
letitle: target, ledir: 'older', lelimit: 'max', formatversion: 2
};
if (continueToken) Object.assign(params, continueToken);
const res = await robustCall(metaApi, params);
const batch = res.query.logevents || [];
events = events.concat(batch);
if (res.continue) continueToken = res.continue;
else finished = true;
}
let logEntries = [];
let pendingRemoved = null;
let lastPairedDate = null; // Track last processed ADD to avoid duplicates
for (let i = 0; i < events.length; i++) {
const e = events[i], p = e.params || {};
let cuAdded = (p.add || []).includes('checkuser') || ((p.newgroups || []).includes('checkuser') && !(p.oldgroups || []).includes('checkuser'));
const cuRemoved = (p.remove || []).includes('checkuser') || (!(p.newgroups || []).includes('checkuser') && (p.oldgroups || []).includes('checkuser'));
const cuModified = (p.newgroups || []).includes('checkuser') && (p.oldgroups || []).includes('checkuser');
if (cuModified) cuAdded = true;
const eventDate = new Date(e.timestamp);
const exactTime = e.timestamp.replace('T', ' ').replace('Z', '');
if (cuAdded) addTime = eventDate;
if (cuRemoved) removeTime = eventDate;
if (addTime && addTime <= auditEnd && (!removeTime || removeTime >= auditStart)) hasLocalRightsInPeriod = true;
if (eventDate >= auditStart && eventDate <= auditEnd && (cuAdded || cuRemoved)) {
let expiryDate = null;
if (p.newmetadata) {
const cuMeta = p.newmetadata.find(m => m.group === 'checkuser');
if (cuMeta && cuMeta.expiry && cuMeta.expiry !== 'infinity') expiryDate = new Date(cuMeta.expiry);
}
const isSelf = (e.user === user || !e.user) ? " (Self-assign)" : "";
if (e.user === user || !e.user) isSelfAssign = true;
if (cuAdded) {
if (pendingRemoved) {
const diffMins = Math.round(Math.abs(pendingRemoved.date - eventDate) / 60000);
const d = Math.floor(diffMins / 1440), h = Math.floor((diffMins % 1440) / 60), m = diffMins % 60;
const durStr = `${d > 0 ? d+'d ' : ''}${h > 0 ? h+'h ' : ''}${m}m`;
logEntries.unshift(`* ADDED: ${exactTime} | REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf} (Duration: ${durStr})`);
if (diffMins > maxDurationMins) { maxDurationMins = diffMins; longestTimeStr = durStr; }
assignCount++;
pendingRemoved = null;
lastPairedDate = eventDate;
} else if (expiryDate) {
const diffMins = Math.round(Math.abs(expiryDate - eventDate) / 60000);
const d = Math.floor(diffMins / 1440), h = Math.floor((diffMins % 1440) / 60), m = diffMins % 60;
const durStr = `${d > 0 ? d+'d ' : ''}${h > 0 ? h+'h ' : ''}${m}m`;
const exactExpiryTime = expiryDate.toISOString().replace('T', ' ').substring(0, 19);
logEntries.unshift(`* ADDED: ${exactTime} | EXPIRED: ${exactExpiryTime} by ${e.user}${isSelf} (Duration: ${durStr})`);
if (diffMins > maxDurationMins) { maxDurationMins = diffMins; longestTimeStr = durStr; }
assignCount++;
lastPairedDate = eventDate;
} else {
if (lastPairedDate && Math.abs(lastPairedDate - eventDate) < 86400000) {
// Ignore duplicate entry from the same session
} else {
logEntries.unshift(`* ADDED: ${exactTime} | REMOVED: (Active/Not removed) by ${e.user}${isSelf}`);
lastPairedDate = eventDate;
}
}
} else if (cuRemoved) {
if (pendingRemoved) logEntries.unshift(`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf}`);
pendingRemoved = { time: exactTime, date: eventDate, user: e.user, isSelf: isSelf };
}
}
}
if (pendingRemoved) logEntries.unshift(`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf}`);
if (logEntries.length) logText = '\n' + logEntries.join('\n');
// --- 5. ROLE CLASSIFICATION ---
let roleLabel = "";
const hasActions = (results[db] && results[db][user] && results[db][user].total > 0);
let wasCU = hasLocalRightsInPeriod || hasActions;
if (isGloballyStaff || historyRes.wasStaff) {
roleLabel = isGloballyStaff ? "Current Staff" : "Former Staff (in period)";
}
else if (isCurrentLocal) {
roleLabel = "Current Local CheckUser";
}
else if (longestTimeStr && isSelfAssign && (isGloballySteward || historyRes.wasSteward)) {
roleLabel = `Steward action (Self-assign: ${assignCount > 1 ? assignCount + 'x, longest ' : ''}${longestTimeStr})`;
}
else if (isGloballySteward || historyRes.wasSteward) {
roleLabel = isGloballySteward ? "Current Steward" : "Former Steward (in period)";
}
else if (isGloballyOmbuds || historyRes.wasOmbuds) {
roleLabel = isGloballyOmbuds ? "Current Ombudsman" : "Former Ombudsman (in period)";
}
else if (wasCU) {
roleLabel = "Former Local CheckUser (in period)";
}
else {
roleLabel = "Unknown role";
}
return { role: roleLabel, log: logText };
} catch (err) {
return { role: "Error fetching data", log: "" };
}
}
async function runAudit(yf, mf, yt, mt) {
isRunning = true; userCache = {}; historyCache = {}; results = {}; emptyWikis = []; failedWikis = []; scannedWikis = [];
const filterText = $('#wiki-filter').val().trim().toLowerCase();
const filterList = filterText ? filterText.split(',').map(s => s.trim()).filter(s => s !== "") : [];
let wikisToScan = rawWikis;
if (currentFilterMode === 'include') wikisToScan = rawWikis.filter(w => filterList.includes(w));
else if (currentFilterMode === 'exclude') wikisToScan = rawWikis.filter(w => !filterList.includes(w));
if (wikisToScan.length === 0) { alert("No wikis selected!"); return; }
$('#start').prop('disabled', true); $('#stop').prop('disabled', false); $('#out').hide();
$('#bar').attr('max', wikisToScan.length).val(0);
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`;
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++; }
}
for (let i = 0; i < wikisToScan.length; i++) {
if (!isRunning) break;
const db = wikisToScan[i]; scannedWikis.push(db);
$('#status-msg').text(`Scanning ${db} (${i + 1}/${wikisToScan.length})...`);
let successLocal = false, continueToken = null;
while (!successLocal && isRunning) {
try {
const api = new mw.ForeignApi(`https://${getDomain(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 ent = res.query?.checkuserlog?.entries || [];
if (ent.length) {
if (!results[db]) results[db] = {};
ent.forEach(e => {
const u = e.checkuser;
if (!results[db][u]) results[db][u] = { total: 0, months: {} };
const mKey = e.timestamp.slice(0, 7);
results[db][u].total++; results[db][u].months[mKey] = (results[db][u].months[mKey] || 0) + 1;
});
}
if (res.continue && isRunning) continueToken = res.continue;
else { if (!results[db]) emptyWikis.push(db); successLocal = true; }
} catch (err) {
failedWikis.push(db);
successLocal = true;
}
}
$('#bar').val(i + 1); await sleep(DELAY_MS);
}
$('#status-msg').text(`Generating report...`);
const today = new Date().toISOString().split('T')[0];
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' (UTC)';
let wt = `== Global CheckUser Stats (${mf}/${yf} - ${mt}/${yt}) ==\n''Report generated on: ${timestamp}''\n\n`;
let rightsLog = `\n== Rights Log (In Period) ==\n`;
wt += `{| class="wikitable sortable" style="font-size:90%; text-align:right;"\n! Wiki / User !! Category !! '''Total''' !! per 1k active users ${monthCols.map(m => `!! ${m.label}`).join(' ')}\n`;
const sortedDBs = Object.keys(results).sort();
for (const db of sortedDBs) {
const metrics = await fetchWikiMetrics(db);
const fmtActive = metrics.active.toLocaleString('en-US');
const iwPrefix = getInterwikiPrefix(db);
const projectLink = `[[${iwPrefix}:|${db}]]`;
const logLink = `[[${iwPrefix}:Special:CheckUserLog|(log)]]`;
wt += `|-\n! colspan="${4 + monthCols.length}" style="background:#eaecf0; text-align:center;" | ${projectLink} <small style="font-weight:normal; color:#54595d;">(Active Users as of ${today}: ${fmtActive})</small> — ${logLink}\n`;
// ----------------------------
let wikiRows = [], wT = 0, wMS = {}; monthCols.forEach(col => wMS[col.key] = 0);
const projectUsers = Object.keys(results[db]).sort();
for (const user of projectUsers) {
const m = await fetchUserData(user, db, START, END);
const isSteward = m.role.includes("Steward") || m.role.includes("Staff") || m.role.includes("Ombudsman");
const isLocalCU = m.role.includes("Local CheckUser");
if (currentUserFilter === 'local' && !isLocalCU) continue;
if (currentUserFilter === 'steward' && !isSteward) continue;
const uD = results[db][user];
wT += uD.total; monthCols.forEach(col => wMS[col.key] += (uD.months[col.key] || 0));
let uP1kU = metrics.active > 0 ? ((uD.total / metrics.active) * 1000).toFixed(1) : "0.0";
let row = `|-\n| style="text-align:left;" | ${user}@${db} || <small>${m.role}</small> || '''${uD.total}''' || ${uP1kU}`;
monthCols.forEach(col => row += ` || ${uD.months[col.key] || 0}`);
wikiRows.push({ html: row + "\n", log: m.log ? `'''${user}@${db}''':${m.log}\n\n` : "" });
}
let wP1kU = metrics.active > 0 ? ((wT / metrics.active) * 1000).toFixed(1) : "0.0";
let totalRow = `|- style="background:#f8f9fa; font-weight:bold;"\n| style="text-align:left;" | TOTAL ${db} || — || ${wT} || ${wP1kU}`;
monthCols.forEach(col => totalRow += ` || ${wMS[col.key]}`);
wt += totalRow + "\n";
wikiRows.forEach(r => { wt += r.html; if (r.log) rightsLog += r.log; });
}
wt += `|}\n\n== Projects with 0 actions ==\n<div style="font-size:85%; color:#54595d;">${emptyWikis.sort().join(', ')}</div>\n`;
if (failedWikis.length > 0) {
wt += `\n== API Errors / Skipped Projects ==\n<div style="font-size:85%; color:#d33;">${failedWikis.sort().join(', ')}</div>\n`;
}
wt += rightsLog + `\n\n<references />\n`;
$('#out').val(wt).show();
$('#status-msg').text(`Done.`); $('#start').prop('disabled', false); $('#stop').prop('disabled', true);
}
setupUI();
});
})();
be8ov900exmt0kbmf4158mb7tytsd3r
737756
737755
2026-04-12T16:17:47Z
MrJaroslavik
44012
e
737756
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 divide Former/Current.
// - 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]].
// - Full Reporting: Outputs sortable Wikitables and detailed rights change logs.
// - With help of Gemini 3
// -------------------------------------------------------
// -------------------------------------------------------
// Wrap everything so this script doesn't break other Wikipedia scripts
(function () {
'use strict';
// Stop immediately if we are NOT on the specific GlobalCheckUserStats page
if (mw.config.get('wgCanonicalSpecialPageName') !== 'Blankpage' || mw.config.get('wgTitle').indexOf('GlobalCheckUserStats') === -1) return;
// A fixed list of all valid Wikimedia projects to scan
const rawWikis = ['abstractwiki', 'abwiki', 'acewiki', 'adywiki', 'afwiki', 'afwikibooks', 'afwikiquote', 'afwiktionary', 'alswiki', 'altwiki', 'amiwiki', 'amwiki', 'amwiktionary', 'angwiki', 'angwiktionary', 'annwiki', 'anpwiki', 'anwiki', 'anwiktionary', 'arcwiki', 'arwiki', 'arwikibooks', 'arwikimedia', 'arwikinews', 'arwikiquote', 'arwikisource', 'arwikiversity', 'arwiktionary', 'arywiki', 'arzwiki', 'astwiki', 'astwiktionary', 'aswiki', 'aswikiquote', 'aswikisource', 'atjwiki', 'avkwiki', 'avwiki', 'awawiki', 'aywiki', 'aywiktionary', 'azbwiki', 'azwiki', 'azwikibooks', 'azwikiquote', 'azwikisource', 'azwiktionary', 'banwiki', 'banwikisource', 'barwiki', 'bat_smgwiki', 'bawiki', 'bawikibooks', 'bbcwiki', 'bclwiki', 'bclwikiquote', 'bclwikisource', 'bclwiktionary', 'bdrwiki', 'bdwikimedia', 'be_x_oldwiki', 'betawikiversity', 'bewiki', 'bewikibooks', 'bewikimedia', 'bewikiquote', 'bewikisource', 'bewiktionary', 'bewwiki', 'bewwiktionary', 'bgwiki', 'bgwikibooks', 'bgwikiquote', 'bgwikisource', 'bgwiktionary', 'bhwiki', 'biwiki', 'bjnwiki', 'bjnwikiquote', 'bjnwiktionary', 'blkwiki', 'blkwiktionary', 'bmwiki', 'bnwiki', 'bnwikibooks', 'bnwikiquote', 'bnwikisource', 'bnwikivoyage', 'bnwiktionary', 'bowiki', 'bpywiki', 'brwiki', 'brwikimedia', 'brwikiquote', 'brwikisource', 'brwiktionary', 'bswiki', 'bswikibooks', 'bswikinews', 'bswikiquote', 'bswikisource', 'bswiktionary', 'btmwiki', 'btmwiktionary', 'bugwiki', 'bxrwiki', 'cawiki', 'cawikibooks', 'cawikimedia', 'cawikinews', 'cawikiquote', 'cawikisource', 'cawiktionary', 'cbk_zamwiki', 'cdowiki', 'cebwiki', 'cewiki', 'chrwiki', 'chrwiktionary', 'chwiki', 'chywiki', 'ckbwiki', 'ckbwiktionary', 'commonswiki', 'cowiki', 'cowikimedia', 'cowiktionary', 'crhwiki', 'csbwiki', 'csbwiktionary', 'cswiki', 'cswikibooks', 'cswikinews', 'cswikiquote', 'cswikisource', 'cswikiversity', 'cswikivoyage', 'cswiktionary', 'cuwiki', 'cvwiki', 'cvwikibooks', 'cywiki', 'cywikibooks', 'cywikiquote', 'cywikisource', 'cywiktionary', 'dagwiki', 'dawiki', 'dawikibooks', 'dawikiquote', 'dawikisource', 'dawiktionary', 'dewiki', 'dewikibooks', 'dewikinews', 'dewikiquote', 'dewikisource', 'dewikiversity', 'dewikivoyage', 'dewiktionary', 'dgawiki', 'dinwiki', 'diqwiki', 'diqwiktionary', 'dkwikimedia', 'dsbwiki', 'dtpwiki', 'dtywiki', 'dvwiki', 'dvwiktionary', 'dzwiki', 'eewiki', 'elwiki', 'elwikibooks', 'elwikinews', 'elwikiquote', 'elwikisource', 'elwikiversity', 'elwikivoyage', 'elwiktionary', 'emlwiki', 'enwiki', 'enwikibooks', 'enwikinews', 'enwikiquote', 'enwikisource', 'enwikiversity', 'enwikivoyage', 'enwiktionary', 'eowiki', 'eowikibooks', 'eowikinews', 'eowikiquote', 'eowikisource', 'eowikivoyage', 'eowiktionary', 'eswiki', 'eswikibooks', 'eswikinews', 'eswikiquote', 'eswikisource', 'eswikiversity', 'eswikivoyage', 'eswiktionary', 'etwiki', 'etwikibooks', 'eewikimedia', 'etwikiquote', 'etwikisource', 'etwiktionary', 'euwiki', 'euwikibooks', 'euwikiquote', 'euwikisource', 'euwiktionary', 'extwiki', 'fatwiki', 'fawiki', 'fawikibooks', 'fawikinews', 'fawikiquote', 'fawikisource', 'fawikivoyage', 'fawiktionary', 'ffwiki', 'fiu_vrowiki', 'fiwiki', 'fiwikibooks', 'fiwikimedia', 'fiwikinews', 'fiwikiquote', 'fiwikisource', 'fiwikiversity', 'fiwikivoyage', 'fiwiktionary', 'fjwiki', 'fjwiktionary', 'fonwiki', 'foundationwiki', 'fowiki', 'fowikisource', 'fowiktionary', 'frpwiki', 'frrwiki', 'frwiki', 'frwikibooks', 'frwikinews', 'frwikiquote', 'frwikisource', 'frwikiversity', 'frwikivoyage', 'frwiktionary', 'furwiki', 'fywiki', 'fywikibooks', 'fywiktionary', 'gagwiki', 'ganwiki', 'gawiki', 'gawiktionary', 'gcrwiki', 'gdwiki', 'gdwiktionary', 'glkwiki', 'glwiki', 'glwikibooks', 'glwikiquote', 'glwikisource', 'glwiktionary', 'gnwiki', 'gnwiktionary', 'gomwiki', 'gomwiktionary', 'gorwiki', 'gorwikiquote', 'gorwiktionary', 'gotwiki', 'gpewiki', 'gucwiki', 'gurwiki', 'guwiki', 'guwikiquote', 'guwikisource', 'guwiktionary', 'guwwiki', 'guwwikinews', 'guwwikiquote', 'guwwiktionary', 'gvwiki', 'gvwiktionary', 'hakwiki', 'hawiki', 'hawiktionary', 'hawwiki', 'hewiki', 'hewikibooks', 'hewikinews', 'hewikiquote', 'hewikisource', 'hewikivoyage', 'hewiktionary', 'hifwiki', 'hifwiktionary', 'hiwiki', 'hiwikibooks', 'hiwikiquote', 'hiwikisource', 'hiwikiversity', 'hiwikivoyage', 'hiwiktionary', 'hrwiki', 'hrwikibooks', 'hrwikiquote', 'hrwikisource', 'hrwiktionary', 'hsbwiki', 'hsbwiktionary', 'htwiki', 'huwiki', 'huwikibooks', 'huwikisource', 'huwiktionary', 'hywiki', 'hywikibooks', 'hywikiquote', 'hywikisource', 'hywiktionary', 'hywwiki', 'iawiki', 'iawikibooks', 'iawiktionary', 'ibawiki', 'idwiki', 'idwikibooks', 'idwikiquote', 'idwikisource', 'idwikivoyage', 'idwiktionary', 'iewiki', 'iewiktionary', 'iglwiki', 'igwiki', 'igwikiquote', 'igwiktionary', 'ikwiki', 'ilowiki', 'incubatorwiki', 'inhwiki', 'iowiki', 'iowiktionary', 'iswiki', 'iswikibooks', 'iswikiquote', 'iswikisource', 'iswiktionary', 'itwiki', 'itwikibooks', 'itwikinews', 'itwikiquote', 'itwikisource', 'itwikiversity', 'itwikivoyage', 'itwiktionary', 'iuwiki', 'iuwiktionary', 'jamwiki', 'jawiki', 'jawikibooks', 'jawikinews', 'jawikisource', 'jawikiversity', 'jawikivoyage', 'jawiktionary', 'jbowiki', 'jbowiktionary', 'jvwiki', 'jvwikisource', 'jvwiktionary', 'kaawiki', 'kaawiktionary', 'kabwiki', 'kaiwiki', 'kajwiki', 'kawiki', 'kawikibooks', 'kawikiquote', 'kawikisource', 'kawiktionary', 'kbdwiki', 'kbdwiktionary', 'kbpwiki', 'kcgwiki', 'kcgwiktionary', 'kgewiki', 'kgwiki', 'kiwiki', 'kkwiki', 'kkwikibooks', 'kkwiktionary', 'klwiktionary', 'kmwiki', 'kmwikibooks', 'kmwiktionary', 'kncwiki', 'knwiki', 'knwikiquote', 'knwikisource', 'knwiktionary', 'koiwiki', 'kowiki', 'kowikibooks', 'kowikinews', 'kowikiquote', 'kowikisource', 'kowikiversity', 'kowiktionary', 'krcwiki', 'kshwiki', 'kswiki', 'kswiktionary', 'kuswiki', 'kuwiki', 'kuwikibooks', 'kuwikiquote', 'kuwiktionary', 'kvwiki', 'kwwiki', 'kwwiktionary', 'kywiki', 'kywikiquote', 'kywiktionary', 'labswiki', 'ladwiki', 'lawiki', 'lawikibooks', 'lawikiquote', 'lawikisource', 'lawiktionary', 'lbewiki', 'lbwiki', 'lbwiktionary', 'lezwiki', 'lfnwiki', 'lgwiki', 'lijwiki', 'lijwikisource', 'liwiki', 'liwikibooks', 'liwikinews', 'liwikiquote', 'liwikisource', 'liwiktionary', 'lldwiki', 'lmowiki', 'lmowiktionary', 'lnwiki', 'lnwiktionary', 'lowiki', 'lowiktionary', 'ltgwiki', 'ltwiki', 'ltwikibooks', 'ltwikiquote', 'ltwikisource', 'ltwiktionary', 'lvwiki', 'lvwiktionary', 'madwiki', 'madwikisource', 'madwiktionary', 'maiwiki', 'map_bmswiki', 'mdfwiki', 'mediawikiwiki', 'metawiki', 'mgwiki', 'mgwikibooks', 'mgwiktionary', 'mhrwiki', 'minwiki', 'minwikibooks', 'minwikisource', 'minwiktionary', 'miwiki', 'miwiktionary', 'mkwiki', 'mkwikibooks', 'mkwikimedia', 'mkwikisource', 'mkwiktionary', 'mlwiki', 'mlwikibooks', 'mlwikiquote', 'mlwikisource', 'mlwiktionary', 'mniwiki', 'mniwiktionary', 'mnwiki', 'mnwiktionary', 'mnwwiki', 'mnwwiktionary', 'moswiki', 'mrjwiki', 'mrwiki', 'mrwikibooks', 'mrwikiquote', 'mrwikisource', 'mrwiktionary', 'mswiki', 'mswikibooks', 'mswikiquote', 'mswikisource', 'mswiktionary', 'mtwiki', 'mtwiktionary', 'mwlwiki', 'mxwikimedia', 'myvwiki', 'mywiki', 'mywikisource', 'mywiktionary', 'mznwiki', 'nahwiki', 'nahwiktionary', 'napwiki', 'napwikisource', 'nawiktionary', 'nds_nlwiki', 'ndswiki', 'ndswiktionary', 'newiki', 'newikibooks', 'newiktionary', 'newwiki', 'niawiki', 'niawiktionary', 'nlwiki', 'nlwikibooks', 'nlwikimedia', 'nlwikinews', 'nlwikiquote', 'nlwikisource', 'nlwikivoyage', 'nlwiktionary', 'nnwiki', 'nnwikiquote', 'nnwiktionary', 'novwiki', 'nowiki', 'nowikibooks', 'nowikimedia', 'nowikinews', 'nowikiquote', 'nowikisource', 'nowiktionary', 'nqowiki', 'nrmwiki', 'nrwiki', 'nsowiki', 'nupwiki', 'nvwiki', 'nycwikimedia', 'nywiki', 'ocwiki', 'ocwikibooks', 'ocwiktionary', 'olowiki', 'omwiki', 'omwiktionary', 'orwiki', 'orwikisource', 'orwiktionary', 'oswiki', 'outreachwiki', 'pagwiki', 'pamwiki', 'papwiki', 'pawiki', 'pawikibooks', 'pawikisource', 'pawiktionary', 'pcdwiki', 'pcmwiki', 'pcmwikiquote', 'pdcwiki', 'pflwiki', 'piwiki', 'plwiki', 'plwikibooks', 'plwikimedia', 'plwikinews', 'plwikiquote', 'plwikisource', 'plwikivoyage', 'plwiktionary', 'pmswiki', 'pmswikisource', 'pnbwiki', 'pnbwiktionary', 'pntwiki', 'pplwiki', 'pswiki', 'pswikivoyage', 'pswiktionary', 'ptwiki', 'ptwikibooks', 'ptwikimedia', 'ptwikinews', 'ptwikiquote', 'ptwikisource', 'ptwikiversity', 'ptwikivoyage', 'ptwiktionary', 'pwnwiki', 'quwiktionary', 'rkiwiki', 'rmwiki', 'rmywiki', 'rnwiki', 'roa_rupwiki', 'roa_rupwiktionary', 'roa_tarawiki', 'rowiki', 'rowikibooks', 'rowikinews', 'rowikiquote', 'rowiktionary', 'rskwiki', 'ruewiki', 'ruwiki', 'ruwikibooks', 'ruwikinews', 'ruwikiquote', 'ruwikisource', 'ruwikiversity', 'ruwikivoyage', 'ruwiktionary', 'rwwiki', 'rwwiktionary', 'sahwiki', 'sahwikiquote', 'sahwikisource', 'satwiki', 'satwiktionary', 'sawiki', 'sawikibooks', 'sawikiquote', 'sawikisource', 'sawiktionary', 'scnwiki', 'scnwiktionary', 'scowiki', 'scwiki', 'sdwiki', 'sdwiktionary', 'sewiki', 'sewikimedia', 'sgwiki', 'sgwiktionary', 'shiwiki', 'shnwiki', 'shnwikibooks', 'shnwikinews', 'shnwikivoyage', 'shnwiktionary', 'shwiki', 'shwiktionary', 'shywiktionary', 'simplewiki', 'simplewiktionary', 'siwiki', 'siwikibooks', 'siwiktionary', 'skwiki', 'skrwiki', 'skrwiktionary', 'slwiki', 'slwikibooks', 'slwikiquote', 'slwikisource', 'slwikiversity', 'slwiktionary', 'smnwiki', 'smwiki', 'smwiktionary', 'snwiki', 'sourceswiki', 'sowiki', 'sowiktionary', 'specieswiki', 'sqwiki', 'sqwikibooks', 'sqwikinews', 'sqwikiquote', 'sqwiktionary', 'srnwiki', 'srwiki', 'srwikibooks', 'srwikinews', 'srwikiquote', 'srwikisource', 'srwiktionary', 'sswiki', 'sswiktionary', 'stqwiki', 'stwiki', 'stwiktionary', 'suwiki', 'suwikiquote', 'suwikisource', 'suwiktionary', 'svwiki', 'svwikibooks', 'svwikinews', 'svwikiquote', 'svwikisource', 'svwikiversity', 'svwikivoyage', 'svwiktionary', 'swwiki', 'swwiktionary', 'sylwiki', 'szlwiki', 'szywiki', 'tawiki', 'tawikibooks', 'tawikinews', 'tawikiquote', 'tawikisource', 'tawiktionary', 'taywiki', 'tcywiki', 'tcywikisource', 'tcywiktionary', 'tddwiki', 'tewiki', 'tewikibooks', 'tewikiquote', 'tewikisource', 'tewiktionary', 'tgwiki', 'tgwikibooks', 'tgwiktionary', 'thwiki', 'thwikibooks', 'thwikimedia', 'thwikiquote', 'thwikisource', 'thwiktionary', 'tigwiki', 'tiwiki', 'tiwiktionary', 'tkwiki', 'tkwiktionary', 'tlwiki', 'tlwikibooks', 'tlwikiquote', 'tlwikisource', 'tlwiktionary', 'tlywiki', 'tnwiki', 'tnwiktionary', 'tokwiki', 'towiki', 'tpiwiki', 'tpiwiktionary', 'trvwiki', 'trwiki', 'trwikibooks', 'trwikimedia', 'trwikinews', 'trwikiquote', 'trwikisource', 'trwikivoyage', 'trwiktionary', 'tswiki', 'tswiktionary', 'ttwiki', 'ttwikibooks', 'ttwikiquote', 'ttwiktionary', 'tumwiki', 'twwiki', 'twwiktionary', 'tyvwiki', 'tywiki', 'uawikimedia', 'udmwiki', 'ugwiki', 'ugwiktionary', 'ukwiki', 'ukwikibooks', 'ukwikinews', 'ukwikiquote', 'ukwikisource', 'ukwikivoyage', 'ukwiktionary', 'urwiki', 'urwikibooks', 'urwikiquote', 'urwikisource', 'urwiktionary', 'uzwiki', 'uzwikiquote', 'uzwiktionary', 'vecwiki', 'vecwikisource', 'vecwiktionary', 'vepwiki', 'vewiki', 'viwiki', 'viwikibooks', 'viwikiquote', 'viwikisource', 'viwikivoyage', 'viwiktionary', 'vlswiki', 'vowiki', 'vowiktionary', 'warwiki', 'wawiki', 'wawikisource', 'wawiktionary', 'wikidatawiki', 'wikimaniawiki', 'wowiki', 'wowiktionary', 'wuuwiki', 'xalwiki', 'xhwiki', 'xmfwiki', 'yiwiki', 'yiwikisource', 'yiwiktionary', 'yowiki', 'zawiki', 'zeawiki', 'zghwiki', 'zghwiktionary', 'zh_classicalwiki', 'zh_min_nanwiki', 'zh_min_nanwikisource', 'zh_min_nanwiktionary', 'zh_yuewiki', 'zhwiki', 'zhwikibooks', 'zhwikinews', 'zhwikiquote', 'zhwikisource', 'zhwikiversity', 'zhwikivoyage', 'zhwiktionary', 'zuwiki', 'zuwiktionary', 'test2wiki', 'testwiki'];
// Time delay between API requests to prevent rate limiting (429 errors)
const DELAY_MS = 800;
// Helper function to pause execution for a specified number of milliseconds
const sleep = ms => new Promise(r => setTimeout(r, ms));
// Cache for global user group information to avoid redundant API calls
let userCache = {};
// Cache for historical global rights changes within the audit period
let historyCache = {};
// Helper function to handle API calls with up to 3 retries on 429 errors
async function robustCall(api, params) {
let retries = 0;
const maxRetries = 3; // Maximum number of retry attempts
// Continue attempting the API request as long as we haven't hit the maximum retry limit
while (retries < maxRetries) {
try {
// Attempt to execute the API GET request
return await api.get(params);
} catch (err) {
// If the error is "429 Too Many Requests" (rate limit)
if (err?.status === 429) {
retries++;
// Get wait time from 'Retry-After' header, or use a default backoff (30s, 60s, 90s)
const retryAfter = parseInt(err.xhr?.getResponseHeader('Retry-After')) || (30 * retries);
// Update UI status message with the remaining wait time
$('#status-msg').html(`<span style="color:orange; font-weight:bold;">Rate limit hit! Resting ${retryAfter}s... (Attempt ${retries}/${maxRetries})</span>`);
// Pause execution before the next retry
await sleep(retryAfter * 1000);
} else {
// Rethrow any error that is not a rate limit (e.g., 404, 403, 500)
throw err;
}
}
}
// Fail if max retries are exceeded
throw new Error("Max retries reached");
}
// Converts a database name (like 'enwiki') into a real web address.
function getDomain(db) {
// Static mapping for projects with non-standard naming conventions
const mapping = {
'commonswiki': 'commons.wikimedia.org', 'metawiki': 'meta.wikimedia.org', 'wikidatawiki': 'www.wikidata.org',
'mediawikiwiki': 'www.mediawiki.org', 'sourceswiki': 'wikisource.org', 'foundationwiki': 'foundation.wikimedia.org',
'incubatorwiki': 'incubator.wikimedia.org', 'outreachwiki': 'outreach.wikimedia.org',
'be_x_oldwiki': 'be-tarask.wikipedia.org', 'labswiki': 'wikitech.wikimedia.org',
'wikimaniawiki': 'wikimania.wikimedia.org', 'specieswiki': 'species.wikimedia.org',
'abstractwiki': 'abstract.wikipedia.org', 'betawikiversity': 'beta.wikiversity.org',
'mo_wiki': 'ro.wikipedia.org',
'testwiki': 'test.wikipedia.org', 'test2wiki': 'test2.wikipedia.org',
'wikifunctionswiki': 'www.wikifunctions.org',
'testcommonswiki': 'test-commons.wikimedia.org',
'testwikidatawiki': 'test.wikidata.org',
};
// Return early if an explicit mapping exists
if (mapping[db]) return mapping[db];
// Format language codes: replace underscores with hyphens (e.g., zh_yue -> zh-yue)
const name = db.replace(/_/g, '-');
// Handle sister project families by trimming suffixes and appending correct domains
if (name.endsWith('wikisource')) return name.slice(0, -10) + '.wikisource.org';
if (name.endsWith('wikiversity')) return name.slice(0, -11) + '.wikiversity.org';
if (name.endsWith('wiktionary')) return name.slice(0, -10) + '.wiktionary.org';
if (name.endsWith('wikivoyage')) return name.slice(0, -10) + '.wikivoyage.org';
if (name.endsWith('wikibooks')) return name.slice(0, -9) + '.wikibooks.org';
if (name.endsWith('wikiquote')) return name.slice(0, -9) + '.wikiquote.org';
if (name.endsWith('wikinews')) return name.slice(0, -8) + '.wikinews.org';
if (name.endsWith('wikimedia')) return name.slice(0, -9) + '.wikimedia.org';
// Default logic for Wikipedias: remove 'wiki' suffix and append domain
if (name.endsWith('wiki')) return name.slice(0, -4) + '.wikipedia.org';
// Generic fallback
return name + '.wikipedia.org';
}
function getInterwikiPrefix(db) {
// Mapping for special projects that use non-standard interwiki shortcuts
// Return early if an explicit special prefix is defined
const specials = {
'commonswiki': 'commons', 'metawiki': 'm', 'wikidatawiki': 'd',
'mediawikiwiki': 'mw', 'specieswiki': 'wikispecies',
'foundationwiki': 'wikimedia', 'sourceswiki': 'wikisource'
};
// Return early if an explicit special prefix is defined
if (specials[db]) return specials[db];
// Format name for interwiki consistency (underscores to hyphens)
const name = db.replace(/_/g, '-');
// Logic for sister project families: trim suffixes and append the standard shorthand
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 'wikimedia:' + name.slice(0, -9);
// Standard Wikipedia logic: removes 'wiki' suffix and adds 'w:' prefix
if (name.endsWith('wiki')) return 'w:' + name.slice(0, -4);
// Default fallback for any other database patterns
return 'w:' + name;
}
// Ensure required MediaWiki API and ForeignApi modules are loaded before execution
mw.loader.using(['mediawiki.api', 'mediawiki.ForeignApi']).then(function () {
// Initialize API connection to Meta-Wiki for global user information and rights logs
const metaApi = new mw.ForeignApi('https://meta.wikimedia.org/w/api.php');
// Capture current date/time for report generation and timestamping
const now = new Date();
// State management variables:
let results = {}, // Stores aggregated CU action counts per wiki/user
emptyWikis = [], // List of projects with no recorded CU actions
failedWikis = [], // List of projects that encountered API errors
scannedWikis = [], // Tracking list of successfully processed projects
isRunning = false; // Flag to control the start/stop state of the audit
// UI state variables for filtering wikis and user roles
let currentFilterMode = 'all'; // Current wiki selection mode (all, exclude, include)
let currentUserFilter = 'all'; // Current user role filter (all, local, steward)
// Initializes and builds the User Interface (UI) elements on the page.
function setupUI() {
// Get the current year to ensure the date range is always up-to-date
const currentYear = now.getFullYear();
// Generate HTML options for the year selection (starting from 2005 to current)
let yearOpts = '';
for (let y = currentYear; y >= 2005; y--) yearOpts += `<option value="${y}">${y}</option>`;
// Prepare HTML options for month selection (1-12)
let monthOptsFrom = '';
let monthOptsTo = '';
for (let m = 1; m <= 12; m++) {
// Set default: 'From' dropdown defaults to January (1)
monthOptsFrom += `<option value="${m}" ${m === 1 ? 'selected' : ''}>${m}</option>`;
// Set default: 'To' dropdown defaults to December (12)
monthOptsTo += `<option value="${m}" ${m === 12 ? 'selected' : ''}>${m}</option>`;
}
// Set the page title to identify the script
$('#firstHeading').text('GlobalCheckUserStats.js');
// Clear the main content area and append the control panel HTML
$('#mw-content-text').empty().append(`
<div style="border:1px solid #a2a9b1; padding:15px; background:#f8f9fa;">
<h3>Stats Range</h3>
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 style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>Wiki Filter:</strong>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="wiki-mode" id="btn-all" checked> All wikis</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="wiki-mode" id="btn-except"> All wikis except</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><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; font-size:12px; line-height:18px;" title="Show all wiki DB names">?</span>
</div>
<div id="filter-input-container" style="display:none; margin-top:10px;">
<input id="wiki-filter" type="text" style="width:100%; max-width:600px;" placeholder="">
</div>
<div style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>User Filter:</strong>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-all" checked> All users</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-local"> Only Local CUs</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-steward"> Only Stewards</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; color:#202122;">
<strong>Available Wikis (alphabetical):</strong><br>${[...rawWikis].sort().join(', ')}
</div>
<div style="margin-top:20px;">
<button id="start" class="mw-ui-button mw-ui-progressive">Run GlobalCheckUserStats.js</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="${rawWikis.length}" style="width:100%"></progress></div>
<textarea id="out" style="width:100%; height:450px; margin-top:10px; display:none; font-family:monospace; font-size:12px;"></textarea>
</div>
`);
// Handle clicking 'All wikis': resets filter mode and hides the input field
$('#btn-all').click(() => { currentFilterMode = 'all'; $('#filter-input-container').hide(); });
// Handle clicking 'All wikis except': shows input field, sets mode to 'exclude', and focuses the box
$('#btn-except').click(() => { currentFilterMode = 'exclude'; $('#filter-input-container').show(); $('#wiki-filter').attr('placeholder', 'Exclude (dbname1, dbname2)...').focus(); });
// Handle clicking 'Only these wikis': shows input field, sets mode to 'include', and focuses the box
$('#btn-only').click(() => { currentFilterMode = 'include'; $('#filter-input-container').show(); $('#wiki-filter').attr('placeholder', 'Only (dbname1, dbname2)...').focus(); });
// User role filter buttons: define which user categories will appear in the final table
$('#u-all').click(() => { currentUserFilter = 'all'; });
$('#u-local').click(() => { currentUserFilter = 'local'; });
$('#u-steward').click(() => { currentUserFilter = 'steward'; });
// Toggle visibility of the 'Available Wikis' help list when the question mark icon is clicked
$('#wiki-help-trigger').click(() => $('#wiki-list-help').toggle());
// Main execution trigger: starts the audit using the selected date range from dropdowns
$('#start').click(() => runAudit($('#y-f').val(), $('#m-f').val(), $('#y-t').val(), $('#m-t').val()));
// Stop button: sets isRunning to false to halt the loop between wiki scans
$('#stop').click(() => isRunning = false);
}
// Fetches general statistics for a specific wiki, primarily to get the active user count.
async function fetchWikiMetrics(db) {
try {
const api = new mw.ForeignApi(`https://${getDomain(db)}/w/api.php`);
// Request site statistics via the 'siteinfo' meta module
const res = await robustCall(api, {
action: 'query', meta: 'siteinfo', siprop: 'statistics', formatversion: 2
});
// Extract the active users count; default to 0 if the data is missing
return { active: res.query.statistics.activeusers || 0 };
// Graceful fallback: return zero if the project is unreachable or API fails
} catch (e) { return { active: 0 }; }
}
// Checks if a user held global roles (Steward, Staff, Ombuds) during the audit period.
async function checkGlobalHistory(user, start, end) {
try {
// Fetch global rights logs from Meta-Wiki for the specific user
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), auditEnd = new Date(end);
let state = { steward: false, staff: false, ombuds: false };
// Look at the last log entry BEFORE the audit started to see the initial status
const priorLogs = logs.filter(l => new Date(l.timestamp) <= auditStart).sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
if (priorLogs.length > 0) {
const p = priorLogs[0].params || {};
const groupsInLog = p.newGroups || p[1] || [];
state.steward = groupsInLog.includes('steward');
state.staff = groupsInLog.includes('staff');
state.ombuds = groupsInLog.includes('ombuds');
}
// Keep track of any global roles gained during the audit period itself
let held = { wasSteward: state.steward, wasStaff: state.staff, wasOmbuds: state.ombuds };
logs.filter(l => { const ts = new Date(l.timestamp); return ts > auditStart && ts < auditEnd; }).forEach(e => {
const p = e.params || {};
const added = p.newGroups || p[1] || [];
if (added.includes('steward')) held.wasSteward = true;
if (added.includes('staff')) held.wasStaff = true;
if (added.includes('ombuds')) held.wasOmbuds = true;
});
return held;
} catch (e) {
// If anything fails, assume no global roles were held
return { wasSteward: false, wasStaff: false, wasOmbuds: false }; }
}
// Gathers all necessary data for a specific user on a specific wiki.
async function fetchUserData(user, db, start, end) {
// Convert start and end strings into Date objects for easier comparison
const auditStart = new Date(start), auditEnd = new Date(end);
// 1. FETCH CURRENT GLOBAL GROUPS (Using cache to save time)
if (!userCache[user]) {
try {
// Ask Meta-Wiki for the user's current global groups (steward, staff, etc.)
const gres = await robustCall(metaApi, { action: 'query', meta: 'globaluserinfo', guiprop: 'groups', guiuser: user, formatversion: 2 });
userCache[user] = gres.query.globaluserinfo.groups || [];
// Fallback to an empty list if the request fails
} catch (e) { userCache[user] = []; }
}
// Check if the user currently holds any key global roles
const gGroups = userCache[user];
const isGloballySteward = gGroups.includes('steward');
const isGloballyStaff = gGroups.includes('staff');
const isGloballyOmbuds = gGroups.includes('ombuds') || gGroups.includes('ombudsman');
// 2. FETCH GLOBAL HISTORY (Identify roles held during the period but lost since)
if (!historyCache[user]) historyCache[user] = await checkGlobalHistory(user, start, end);
const historyRes = historyCache[user];
// 3. CHECK CURRENT LOCAL CHECKUSER STATUS
let isCurrentLocal = false;
try {
// Connect to the local wiki to see if the user is currently a CheckUser
const localApi = new mw.ForeignApi(`https://${getDomain(db)}/w/api.php`);
const ures = await robustCall(localApi, { action: 'query', list: 'users', ususers: user, usprop: 'groups', formatversion: 2 });
const lGroups = (ures.query.users[0] && ures.query.users[0].groups) || [];
isCurrentLocal = lGroups.includes('checkuser');
} catch (e) {
// Ignore errors; defaults to false
}
// Define the target string for rights log searching
const target = 'User:' + user + '@' + db;
// Initialize variables for log analysis and role calculation
let logText = '',
addTime = null,
removeTime = null,
isSelfAssign = false,
maxDurationMins = -1,
longestTimeStr = "",
assignCount = 0,
hasLocalRightsInPeriod = false;
// 4. DEEP RIGHTS LOG SCAN (Pagination to bypass 500-limit)
let events = [];
let continueToken = null;
let finished = false;
try {
// Loop through all log pages until we reach the end of history
while (!finished) {
let params = {
action: 'query', list: 'logevents', letype: 'rights',
letitle: target, ledir: 'older', lelimit: 'max', formatversion: 2
};
if (continueToken) Object.assign(params, continueToken);
const res = await robustCall(metaApi, params);
const batch = res.query.logevents || [];
events = events.concat(batch);
// If the API says there is more data, keep going; otherwise, stop
if (res.continue) continueToken = res.continue;
else finished = true;
}
let logEntries = [];
let pendingRemoved = null;
let lastPairedDate = null; // Track last processed ADD to avoid duplicates - e.g., when a Steward self-assigns CU and then updates the duration/reason.
// Process each log event to track when CheckUser rights were granted or removed
for (let i = 0; i < events.length; i++) {
const e = events[i], p = e.params || {};
// Detect if CheckUser group was added or removed in this log entry
let cuAdded = (p.add || []).includes('checkuser') || ((p.newgroups || []).includes('checkuser') && !(p.oldgroups || []).includes('checkuser'));
const cuRemoved = (p.remove || []).includes('checkuser') || (!(p.newgroups || []).includes('checkuser') && (p.oldgroups || []).includes('checkuser'));
// If rights were just updated (e.g. expiry changed), count it as an ADD
const cuModified = (p.newgroups || []).includes('checkuser') && (p.oldgroups || []).includes('checkuser');
if (cuModified) cuAdded = true;
const eventDate = new Date(e.timestamp);
const exactTime = e.timestamp.replace('T', ' ').replace('Z', '');
if (cuAdded) addTime = eventDate;
if (cuRemoved) removeTime = eventDate;
// Check if the user had rights at any point during the selected audit period
if (addTime && addTime <= auditEnd && (!removeTime || removeTime >= auditStart)) hasLocalRightsInPeriod = true;
// Only record details if the event happened within our date range
if (eventDate >= auditStart && eventDate <= auditEnd && (cuAdded || cuRemoved)) {
let expiryDate = null;
// Check for temporary access expiry dates
if (p.newmetadata) {
const cuMeta = p.newmetadata.find(m => m.group === 'checkuser');
if (cuMeta && cuMeta.expiry && cuMeta.expiry !== 'infinity') expiryDate = new Date(cuMeta.expiry);
}
const isSelf = (e.user === user || !e.user) ? " (Self-assign)" : "";
if (e.user === user || !e.user) isSelfAssign = true;
if (cuAdded) {
// Match an ADD event with a previously found REMOVE event to calculate duration
if (pendingRemoved) {
const diffMins = Math.round(Math.abs(pendingRemoved.date - eventDate) / 60000);
const d = Math.floor(diffMins / 1440), h = Math.floor((diffMins % 1440) / 60), m = diffMins % 60;
const durStr = `${d > 0 ? d+'d ' : ''}${h > 0 ? h+'h ' : ''}${m}m`;
logEntries.unshift(`* ADDED: ${exactTime} | REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf} (Duration: ${durStr})`);
if (diffMins > maxDurationMins) { maxDurationMins = diffMins; longestTimeStr = durStr; }
assignCount++;
pendingRemoved = null;
lastPairedDate = eventDate;
}
// If the ADD has its own expiry metadata, use that for duration
else if (expiryDate) {
const diffMins = Math.round(Math.abs(expiryDate - eventDate) / 60000);
const d = Math.floor(diffMins / 1440), h = Math.floor((diffMins % 1440) / 60), m = diffMins % 60;
const durStr = `${d > 0 ? d+'d ' : ''}${h > 0 ? h+'h ' : ''}${m}m`;
const exactExpiryTime = expiryDate.toISOString().replace('T', ' ').substring(0, 19);
logEntries.unshift(`* ADDED: ${exactTime} | EXPIRED: ${exactExpiryTime} by ${e.user}${isSelf} (Duration: ${durStr})`);
if (diffMins > maxDurationMins) { maxDurationMins = diffMins; longestTimeStr = durStr; }
assignCount++;
lastPairedDate = eventDate;
}
// Otherwise, it's an active or indefinite assignment
else {
if (lastPairedDate && Math.abs(lastPairedDate - eventDate) < 86400000) {
// Ignore duplicate entry from the same session
} else {
logEntries.unshift(`* ADDED: ${exactTime} | REMOVED: (Active/Not removed) by ${e.user}${isSelf}`);
lastPairedDate = eventDate;
}
}
}
else if (cuRemoved) {
// If we find a REMOVE event, save it to pair with the next ADD event found
if (pendingRemoved) logEntries.unshift(`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf}`);
pendingRemoved = { time: exactTime, date: eventDate, user: e.user, isSelf: isSelf };
}
}
}
// Cleanup: handle any remaining REMOVE events that didn't have a matching ADD
if (pendingRemoved) logEntries.unshift(`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf}`);
if (logEntries.length) logText = '\n' + logEntries.join('\n');
// 5. ROLE CLASSIFICATION
let roleLabel = "";
// Check if user has any logged actions or held rights during the period
const hasActions = (results[db] && results[db][user] && results[db][user].total > 0);
let wasCU = hasLocalRightsInPeriod || hasActions;
// Determine the most relevant role for the user
if (isGloballyStaff || historyRes.wasStaff) {
// Priority 1: WMF Staff status
roleLabel = isGloballyStaff ? "Current Staff" : "Former Staff (in period)";
}
else if (isCurrentLocal) {
// Priority 2: Currently active local CheckUser
roleLabel = "Current Local CheckUser";
}
else if (longestTimeStr && isSelfAssign && (isGloballySteward || historyRes.wasSteward)) {
// Priority 3: Steward acting temporarily (Self-assign detection)
roleLabel = `Steward action (Self-assign: ${assignCount > 1 ? assignCount + 'x, longest ' : ''}${longestTimeStr})`;
}
else if (isGloballySteward || historyRes.wasSteward) {
// Priority 4: Standard Steward status
roleLabel = isGloballySteward ? "Current Steward" : "Former Steward (in period)";
}
else if (isGloballyOmbuds || historyRes.wasOmbuds) {
// Priority 5: Ombudsman commission status
roleLabel = isGloballyOmbuds ? "Current Ombudsman" : "Former Ombudsman (in period)";
}
else if (wasCU) {
// Priority 6: User who was a local CU but no longer is
roleLabel = "Former Local CheckUser (in period)";
}
else {
// Default if no specific role matches
roleLabel = "Unknown role";
}
// Return the calculated role and the formatted log entries
return { role: roleLabel, log: logText };
} catch (err) {
// Return an error state if anything crashes during user data processing
return { role: "Error fetching data", log: "" };
}
}
// The main engine that iterates through wikis and collects CheckUser logs.
async function runAudit(yf, mf, yt, mt) {
// Reset all data and state variables for a fresh run
isRunning = true;
userCache = {};
historyCache = {};
results = {};
emptyWikis = [];
failedWikis = [];
scannedWikis = [];
// Parse and prepare the wiki filter list from the UI input
const filterText = $('#wiki-filter').val().trim().toLowerCase();
const filterList = filterText ? filterText.split(',').map(s => s.trim()).filter(s => s !== "") : [];
let wikisToScan = rawWikis;
// Apply include/exclude logic to the master wiki list
if (currentFilterMode === 'include') wikisToScan = rawWikis.filter(w => filterList.includes(w));
else if (currentFilterMode === 'exclude') wikisToScan = rawWikis.filter(w => !filterList.includes(w));
if (wikisToScan.length === 0) { alert("No wikis selected!"); return; }
// Update UI buttons and progress bar to "running" state
$('#start').prop('disabled', true); $('#stop').prop('disabled', false); $('#out').hide();
$('#bar').attr('max', wikisToScan.length).val(0);
// Format the date strings into ISO format 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 columns for the table based on the selected month range
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++; }
}
// Loop through each selected wiki and fetch its logs
for (let i = 0; i < wikisToScan.length; i++) {
if (!isRunning) break; // Exit loop if the "Stop" button was clicked
const db = wikisToScan[i]; scannedWikis.push(db);
$('#status-msg').text(`Scanning ${db} (${i + 1}/${wikisToScan.length})...`);
let successLocal = false, continueToken = null;
// Inner loop for handling pagination of the logs on a single wiki
while (!successLocal && isRunning) {
try {
const api = new mw.ForeignApi(`https://${getDomain(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);
// Process each entry in the log batch
const ent = res.query?.checkuserlog?.entries || [];
if (ent.length) {
if (!results[db]) results[db] = {};
ent.forEach(e => {
const u = e.checkuser;
if (!results[db][u]) results[db][u] = { total: 0, months: {} };
const mKey = e.timestamp.slice(0, 7);
results[db][u].total++; results[db][u].months[mKey] = (results[db][u].months[mKey] || 0) + 1;
});
}
// Check if there are more log pages to fetch
if (res.continue && isRunning) continueToken = res.continue;
else { if (!results[db]) emptyWikis.push(db); successLocal = true; }
} catch (err) {
// Record failures (like 429 after retries or 404) and move to the next wiki
failedWikis.push(db);
successLocal = true;
}
}
// Update the progress bar and wait a bit to be kind to the API
$('#bar').val(i + 1); await sleep(DELAY_MS);
}
// Update UI and prepare the Wikitable header and metadata
$('#status-msg').text(`Generating report...`);
const today = new Date().toISOString().split('T')[0];
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' (UTC)';
// Start building the Wikitext report
let wt = `== Global CheckUser Stats (${mf}/${yf} - ${mt}/${yt}) ==\n''Report generated on: ${timestamp}''\n\n`;
let rightsLog = `\n== Rights Log (In Period) ==\n`;
// Create table structure with dynamic month columns
wt += `{| class="wikitable sortable" style="font-size:90%; text-align:right;"\n! Wiki / User !! Category !! '''Total''' !! per 1k active users ${monthCols.map(m => `!! ${m.label}`).join(' ')}\n`;
// Sort databases alphabetically and begin processing each project's results
const sortedDBs = Object.keys(results).sort();
for (const db of sortedDBs) {
// Get active user count; if API fails or is 0, display 'Unknown'
const metrics = await fetchWikiMetrics(db);
const fmtActive = metrics.active > 0 ? metrics.active.toLocaleString('en-US') : 'Unknown';
// Prepare interwiki links for the project and its CheckUser log
const iwPrefix = getInterwikiPrefix(db);
const projectLink = `[[${iwPrefix}:|${db}]]`;
const logLink = `[[${iwPrefix}:Special:CheckUserLog|(log)]]`;
wt += `|-\n! colspan="${4 + monthCols.length}" style="background:#eaecf0; text-align:center;" | ${projectLink} <small style="font-weight:normal; color:#54595d;">(Active Users as of ${today}: ${fmtActive})</small> — ${logLink}\n`;
// Initialize local wiki totals and storage for individual user rows
let wikiRows = [],
wT = 0,
wMS = {};
monthCols.forEach(col => wMS[col.key] = 0);
const projectUsers = Object.keys(results[db]).sort();
for (const user of projectUsers) {
// Get role and rights log details for each user
const m = await fetchUserData(user, db, START, END);
// Identify if user belongs to specific global or local groups
const isSteward = m.role.includes("Steward") || m.role.includes("Staff") || m.role.includes("Ombudsman");
const isLocalCU = m.role.includes("Local CheckUser");
// Filter out users if they don't match the selected User Filter (Local/Steward)
if (currentUserFilter === 'local' && !isLocalCU) continue;
if (currentUserFilter === 'steward' && !isSteward) continue;
const uD = results[db][user];
// Aggregate totals for the current wiki
wT += uD.total; monthCols.forEach(col => wMS[col.key] += (uD.months[col.key] || 0));
// Calculate actions per 1k active users; display 0.0 if project activity is unknown
let uP1kU = metrics.active > 0 ? ((uD.total / metrics.active) * 1000).toFixed(1) : "0.0";
// Build the Wikitext row for the individual user
let row = `|-\n| style="text-align:left;" | ${user}@${db} || <small>${m.role}</small> || '''${uD.total}''' || ${uP1kU}`;
monthCols.forEach(col => row += ` || ${uD.months[col.key] || 0}`);
// Store the formatted row and its corresponding rights log
wikiRows.push({ html: row + "\n", log: m.log ? `'''${user}@${db}''':${m.log}\n\n` : "" });
}
// Create the bold TOTAL row for the current wiki
let wP1kU = metrics.active > 0 ? ((wT / metrics.active) * 1000).toFixed(1) : "0.0";
let totalRow = `|- style="background:#f8f9fa; font-weight:bold;"\n| style="text-align:left;" | TOTAL ${db} || — || ${wT} || ${wP1kU}`;
monthCols.forEach(col => totalRow += ` || ${wMS[col.key]}`);
// Append the total row first, then all individual user rows
wt += totalRow + "\n";
wikiRows.forEach(r => { wt += r.html; if (r.log) rightsLog += r.log; });
}
// Append a list of wikis where no CheckUser actions were found
wt += `|}\n\n== Projects with 0 actions ==\n<div style="font-size:85%; color:#54595d;">${emptyWikis.sort().join(', ')}</div>\n`;
// Append a list of projects that failed to scan (e.g., due to API errors)
if (failedWikis.length > 0) {
wt += `\n== API Errors / Skipped Projects ==\n<div style="font-size:85%; color:#d33;">${failedWikis.sort().join(', ')}</div>\n`;
}
// Finalize the report with the Rights Log section
wt += rightsLog + `\n\n<references />\n`;
// Display the final output and reset UI buttons
$('#out').val(wt).show();
$('#status-msg').text(`Done.`); $('#start').prop('disabled', false); $('#stop').prop('disabled', true);
}
// Initialize the interface on page load
setupUI();
});
})();
nli1zc1e3qnahg6z8jlegqu0lc0jax4
737757
737756
2026-04-12T16:22:36Z
MrJaroslavik
44012
e
737757
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]].
// - Full Reporting: Outputs sortable Wikitables and detailed rights change logs.
// - With help of Gemini 3
// -------------------------------------------------------
// -------------------------------------------------------
// Wrap everything so this script doesn't break other Wikipedia scripts
(function () {
'use strict';
// Stop immediately if we are NOT on the specific GlobalCheckUserStats page
if (mw.config.get('wgCanonicalSpecialPageName') !== 'Blankpage' || mw.config.get('wgTitle').indexOf('GlobalCheckUserStats') === -1) return;
// A fixed list of all valid Wikimedia projects to scan
const rawWikis = ['abstractwiki', 'abwiki', 'acewiki', 'adywiki', 'afwiki', 'afwikibooks', 'afwikiquote', 'afwiktionary', 'alswiki', 'altwiki', 'amiwiki', 'amwiki', 'amwiktionary', 'angwiki', 'angwiktionary', 'annwiki', 'anpwiki', 'anwiki', 'anwiktionary', 'arcwiki', 'arwiki', 'arwikibooks', 'arwikimedia', 'arwikinews', 'arwikiquote', 'arwikisource', 'arwikiversity', 'arwiktionary', 'arywiki', 'arzwiki', 'astwiki', 'astwiktionary', 'aswiki', 'aswikiquote', 'aswikisource', 'atjwiki', 'avkwiki', 'avwiki', 'awawiki', 'aywiki', 'aywiktionary', 'azbwiki', 'azwiki', 'azwikibooks', 'azwikiquote', 'azwikisource', 'azwiktionary', 'banwiki', 'banwikisource', 'barwiki', 'bat_smgwiki', 'bawiki', 'bawikibooks', 'bbcwiki', 'bclwiki', 'bclwikiquote', 'bclwikisource', 'bclwiktionary', 'bdrwiki', 'bdwikimedia', 'be_x_oldwiki', 'betawikiversity', 'bewiki', 'bewikibooks', 'bewikimedia', 'bewikiquote', 'bewikisource', 'bewiktionary', 'bewwiki', 'bewwiktionary', 'bgwiki', 'bgwikibooks', 'bgwikiquote', 'bgwikisource', 'bgwiktionary', 'bhwiki', 'biwiki', 'bjnwiki', 'bjnwikiquote', 'bjnwiktionary', 'blkwiki', 'blkwiktionary', 'bmwiki', 'bnwiki', 'bnwikibooks', 'bnwikiquote', 'bnwikisource', 'bnwikivoyage', 'bnwiktionary', 'bowiki', 'bpywiki', 'brwiki', 'brwikimedia', 'brwikiquote', 'brwikisource', 'brwiktionary', 'bswiki', 'bswikibooks', 'bswikinews', 'bswikiquote', 'bswikisource', 'bswiktionary', 'btmwiki', 'btmwiktionary', 'bugwiki', 'bxrwiki', 'cawiki', 'cawikibooks', 'cawikimedia', 'cawikinews', 'cawikiquote', 'cawikisource', 'cawiktionary', 'cbk_zamwiki', 'cdowiki', 'cebwiki', 'cewiki', 'chrwiki', 'chrwiktionary', 'chwiki', 'chywiki', 'ckbwiki', 'ckbwiktionary', 'commonswiki', 'cowiki', 'cowikimedia', 'cowiktionary', 'crhwiki', 'csbwiki', 'csbwiktionary', 'cswiki', 'cswikibooks', 'cswikinews', 'cswikiquote', 'cswikisource', 'cswikiversity', 'cswikivoyage', 'cswiktionary', 'cuwiki', 'cvwiki', 'cvwikibooks', 'cywiki', 'cywikibooks', 'cywikiquote', 'cywikisource', 'cywiktionary', 'dagwiki', 'dawiki', 'dawikibooks', 'dawikiquote', 'dawikisource', 'dawiktionary', 'dewiki', 'dewikibooks', 'dewikinews', 'dewikiquote', 'dewikisource', 'dewikiversity', 'dewikivoyage', 'dewiktionary', 'dgawiki', 'dinwiki', 'diqwiki', 'diqwiktionary', 'dkwikimedia', 'dsbwiki', 'dtpwiki', 'dtywiki', 'dvwiki', 'dvwiktionary', 'dzwiki', 'eewiki', 'elwiki', 'elwikibooks', 'elwikinews', 'elwikiquote', 'elwikisource', 'elwikiversity', 'elwikivoyage', 'elwiktionary', 'emlwiki', 'enwiki', 'enwikibooks', 'enwikinews', 'enwikiquote', 'enwikisource', 'enwikiversity', 'enwikivoyage', 'enwiktionary', 'eowiki', 'eowikibooks', 'eowikinews', 'eowikiquote', 'eowikisource', 'eowikivoyage', 'eowiktionary', 'eswiki', 'eswikibooks', 'eswikinews', 'eswikiquote', 'eswikisource', 'eswikiversity', 'eswikivoyage', 'eswiktionary', 'etwiki', 'etwikibooks', 'eewikimedia', 'etwikiquote', 'etwikisource', 'etwiktionary', 'euwiki', 'euwikibooks', 'euwikiquote', 'euwikisource', 'euwiktionary', 'extwiki', 'fatwiki', 'fawiki', 'fawikibooks', 'fawikinews', 'fawikiquote', 'fawikisource', 'fawikivoyage', 'fawiktionary', 'ffwiki', 'fiu_vrowiki', 'fiwiki', 'fiwikibooks', 'fiwikimedia', 'fiwikinews', 'fiwikiquote', 'fiwikisource', 'fiwikiversity', 'fiwikivoyage', 'fiwiktionary', 'fjwiki', 'fjwiktionary', 'fonwiki', 'foundationwiki', 'fowiki', 'fowikisource', 'fowiktionary', 'frpwiki', 'frrwiki', 'frwiki', 'frwikibooks', 'frwikinews', 'frwikiquote', 'frwikisource', 'frwikiversity', 'frwikivoyage', 'frwiktionary', 'furwiki', 'fywiki', 'fywikibooks', 'fywiktionary', 'gagwiki', 'ganwiki', 'gawiki', 'gawiktionary', 'gcrwiki', 'gdwiki', 'gdwiktionary', 'glkwiki', 'glwiki', 'glwikibooks', 'glwikiquote', 'glwikisource', 'glwiktionary', 'gnwiki', 'gnwiktionary', 'gomwiki', 'gomwiktionary', 'gorwiki', 'gorwikiquote', 'gorwiktionary', 'gotwiki', 'gpewiki', 'gucwiki', 'gurwiki', 'guwiki', 'guwikiquote', 'guwikisource', 'guwiktionary', 'guwwiki', 'guwwikinews', 'guwwikiquote', 'guwwiktionary', 'gvwiki', 'gvwiktionary', 'hakwiki', 'hawiki', 'hawiktionary', 'hawwiki', 'hewiki', 'hewikibooks', 'hewikinews', 'hewikiquote', 'hewikisource', 'hewikivoyage', 'hewiktionary', 'hifwiki', 'hifwiktionary', 'hiwiki', 'hiwikibooks', 'hiwikiquote', 'hiwikisource', 'hiwikiversity', 'hiwikivoyage', 'hiwiktionary', 'hrwiki', 'hrwikibooks', 'hrwikiquote', 'hrwikisource', 'hrwiktionary', 'hsbwiki', 'hsbwiktionary', 'htwiki', 'huwiki', 'huwikibooks', 'huwikisource', 'huwiktionary', 'hywiki', 'hywikibooks', 'hywikiquote', 'hywikisource', 'hywiktionary', 'hywwiki', 'iawiki', 'iawikibooks', 'iawiktionary', 'ibawiki', 'idwiki', 'idwikibooks', 'idwikiquote', 'idwikisource', 'idwikivoyage', 'idwiktionary', 'iewiki', 'iewiktionary', 'iglwiki', 'igwiki', 'igwikiquote', 'igwiktionary', 'ikwiki', 'ilowiki', 'incubatorwiki', 'inhwiki', 'iowiki', 'iowiktionary', 'iswiki', 'iswikibooks', 'iswikiquote', 'iswikisource', 'iswiktionary', 'itwiki', 'itwikibooks', 'itwikinews', 'itwikiquote', 'itwikisource', 'itwikiversity', 'itwikivoyage', 'itwiktionary', 'iuwiki', 'iuwiktionary', 'jamwiki', 'jawiki', 'jawikibooks', 'jawikinews', 'jawikisource', 'jawikiversity', 'jawikivoyage', 'jawiktionary', 'jbowiki', 'jbowiktionary', 'jvwiki', 'jvwikisource', 'jvwiktionary', 'kaawiki', 'kaawiktionary', 'kabwiki', 'kaiwiki', 'kajwiki', 'kawiki', 'kawikibooks', 'kawikiquote', 'kawikisource', 'kawiktionary', 'kbdwiki', 'kbdwiktionary', 'kbpwiki', 'kcgwiki', 'kcgwiktionary', 'kgewiki', 'kgwiki', 'kiwiki', 'kkwiki', 'kkwikibooks', 'kkwiktionary', 'klwiktionary', 'kmwiki', 'kmwikibooks', 'kmwiktionary', 'kncwiki', 'knwiki', 'knwikiquote', 'knwikisource', 'knwiktionary', 'koiwiki', 'kowiki', 'kowikibooks', 'kowikinews', 'kowikiquote', 'kowikisource', 'kowikiversity', 'kowiktionary', 'krcwiki', 'kshwiki', 'kswiki', 'kswiktionary', 'kuswiki', 'kuwiki', 'kuwikibooks', 'kuwikiquote', 'kuwiktionary', 'kvwiki', 'kwwiki', 'kwwiktionary', 'kywiki', 'kywikiquote', 'kywiktionary', 'labswiki', 'ladwiki', 'lawiki', 'lawikibooks', 'lawikiquote', 'lawikisource', 'lawiktionary', 'lbewiki', 'lbwiki', 'lbwiktionary', 'lezwiki', 'lfnwiki', 'lgwiki', 'lijwiki', 'lijwikisource', 'liwiki', 'liwikibooks', 'liwikinews', 'liwikiquote', 'liwikisource', 'liwiktionary', 'lldwiki', 'lmowiki', 'lmowiktionary', 'lnwiki', 'lnwiktionary', 'lowiki', 'lowiktionary', 'ltgwiki', 'ltwiki', 'ltwikibooks', 'ltwikiquote', 'ltwikisource', 'ltwiktionary', 'lvwiki', 'lvwiktionary', 'madwiki', 'madwikisource', 'madwiktionary', 'maiwiki', 'map_bmswiki', 'mdfwiki', 'mediawikiwiki', 'metawiki', 'mgwiki', 'mgwikibooks', 'mgwiktionary', 'mhrwiki', 'minwiki', 'minwikibooks', 'minwikisource', 'minwiktionary', 'miwiki', 'miwiktionary', 'mkwiki', 'mkwikibooks', 'mkwikimedia', 'mkwikisource', 'mkwiktionary', 'mlwiki', 'mlwikibooks', 'mlwikiquote', 'mlwikisource', 'mlwiktionary', 'mniwiki', 'mniwiktionary', 'mnwiki', 'mnwiktionary', 'mnwwiki', 'mnwwiktionary', 'moswiki', 'mrjwiki', 'mrwiki', 'mrwikibooks', 'mrwikiquote', 'mrwikisource', 'mrwiktionary', 'mswiki', 'mswikibooks', 'mswikiquote', 'mswikisource', 'mswiktionary', 'mtwiki', 'mtwiktionary', 'mwlwiki', 'mxwikimedia', 'myvwiki', 'mywiki', 'mywikisource', 'mywiktionary', 'mznwiki', 'nahwiki', 'nahwiktionary', 'napwiki', 'napwikisource', 'nawiktionary', 'nds_nlwiki', 'ndswiki', 'ndswiktionary', 'newiki', 'newikibooks', 'newiktionary', 'newwiki', 'niawiki', 'niawiktionary', 'nlwiki', 'nlwikibooks', 'nlwikimedia', 'nlwikinews', 'nlwikiquote', 'nlwikisource', 'nlwikivoyage', 'nlwiktionary', 'nnwiki', 'nnwikiquote', 'nnwiktionary', 'novwiki', 'nowiki', 'nowikibooks', 'nowikimedia', 'nowikinews', 'nowikiquote', 'nowikisource', 'nowiktionary', 'nqowiki', 'nrmwiki', 'nrwiki', 'nsowiki', 'nupwiki', 'nvwiki', 'nycwikimedia', 'nywiki', 'ocwiki', 'ocwikibooks', 'ocwiktionary', 'olowiki', 'omwiki', 'omwiktionary', 'orwiki', 'orwikisource', 'orwiktionary', 'oswiki', 'outreachwiki', 'pagwiki', 'pamwiki', 'papwiki', 'pawiki', 'pawikibooks', 'pawikisource', 'pawiktionary', 'pcdwiki', 'pcmwiki', 'pcmwikiquote', 'pdcwiki', 'pflwiki', 'piwiki', 'plwiki', 'plwikibooks', 'plwikimedia', 'plwikinews', 'plwikiquote', 'plwikisource', 'plwikivoyage', 'plwiktionary', 'pmswiki', 'pmswikisource', 'pnbwiki', 'pnbwiktionary', 'pntwiki', 'pplwiki', 'pswiki', 'pswikivoyage', 'pswiktionary', 'ptwiki', 'ptwikibooks', 'ptwikimedia', 'ptwikinews', 'ptwikiquote', 'ptwikisource', 'ptwikiversity', 'ptwikivoyage', 'ptwiktionary', 'pwnwiki', 'quwiktionary', 'rkiwiki', 'rmwiki', 'rmywiki', 'rnwiki', 'roa_rupwiki', 'roa_rupwiktionary', 'roa_tarawiki', 'rowiki', 'rowikibooks', 'rowikinews', 'rowikiquote', 'rowiktionary', 'rskwiki', 'ruewiki', 'ruwiki', 'ruwikibooks', 'ruwikinews', 'ruwikiquote', 'ruwikisource', 'ruwikiversity', 'ruwikivoyage', 'ruwiktionary', 'rwwiki', 'rwwiktionary', 'sahwiki', 'sahwikiquote', 'sahwikisource', 'satwiki', 'satwiktionary', 'sawiki', 'sawikibooks', 'sawikiquote', 'sawikisource', 'sawiktionary', 'scnwiki', 'scnwiktionary', 'scowiki', 'scwiki', 'sdwiki', 'sdwiktionary', 'sewiki', 'sewikimedia', 'sgwiki', 'sgwiktionary', 'shiwiki', 'shnwiki', 'shnwikibooks', 'shnwikinews', 'shnwikivoyage', 'shnwiktionary', 'shwiki', 'shwiktionary', 'shywiktionary', 'simplewiki', 'simplewiktionary', 'siwiki', 'siwikibooks', 'siwiktionary', 'skwiki', 'skrwiki', 'skrwiktionary', 'slwiki', 'slwikibooks', 'slwikiquote', 'slwikisource', 'slwikiversity', 'slwiktionary', 'smnwiki', 'smwiki', 'smwiktionary', 'snwiki', 'sourceswiki', 'sowiki', 'sowiktionary', 'specieswiki', 'sqwiki', 'sqwikibooks', 'sqwikinews', 'sqwikiquote', 'sqwiktionary', 'srnwiki', 'srwiki', 'srwikibooks', 'srwikinews', 'srwikiquote', 'srwikisource', 'srwiktionary', 'sswiki', 'sswiktionary', 'stqwiki', 'stwiki', 'stwiktionary', 'suwiki', 'suwikiquote', 'suwikisource', 'suwiktionary', 'svwiki', 'svwikibooks', 'svwikinews', 'svwikiquote', 'svwikisource', 'svwikiversity', 'svwikivoyage', 'svwiktionary', 'swwiki', 'swwiktionary', 'sylwiki', 'szlwiki', 'szywiki', 'tawiki', 'tawikibooks', 'tawikinews', 'tawikiquote', 'tawikisource', 'tawiktionary', 'taywiki', 'tcywiki', 'tcywikisource', 'tcywiktionary', 'tddwiki', 'tewiki', 'tewikibooks', 'tewikiquote', 'tewikisource', 'tewiktionary', 'tgwiki', 'tgwikibooks', 'tgwiktionary', 'thwiki', 'thwikibooks', 'thwikimedia', 'thwikiquote', 'thwikisource', 'thwiktionary', 'tigwiki', 'tiwiki', 'tiwiktionary', 'tkwiki', 'tkwiktionary', 'tlwiki', 'tlwikibooks', 'tlwikiquote', 'tlwikisource', 'tlwiktionary', 'tlywiki', 'tnwiki', 'tnwiktionary', 'tokwiki', 'towiki', 'tpiwiki', 'tpiwiktionary', 'trvwiki', 'trwiki', 'trwikibooks', 'trwikimedia', 'trwikinews', 'trwikiquote', 'trwikisource', 'trwikivoyage', 'trwiktionary', 'tswiki', 'tswiktionary', 'ttwiki', 'ttwikibooks', 'ttwikiquote', 'ttwiktionary', 'tumwiki', 'twwiki', 'twwiktionary', 'tyvwiki', 'tywiki', 'uawikimedia', 'udmwiki', 'ugwiki', 'ugwiktionary', 'ukwiki', 'ukwikibooks', 'ukwikinews', 'ukwikiquote', 'ukwikisource', 'ukwikivoyage', 'ukwiktionary', 'urwiki', 'urwikibooks', 'urwikiquote', 'urwikisource', 'urwiktionary', 'uzwiki', 'uzwikiquote', 'uzwiktionary', 'vecwiki', 'vecwikisource', 'vecwiktionary', 'vepwiki', 'vewiki', 'viwiki', 'viwikibooks', 'viwikiquote', 'viwikisource', 'viwikivoyage', 'viwiktionary', 'vlswiki', 'vowiki', 'vowiktionary', 'warwiki', 'wawiki', 'wawikisource', 'wawiktionary', 'wikidatawiki', 'wikimaniawiki', 'wowiki', 'wowiktionary', 'wuuwiki', 'xalwiki', 'xhwiki', 'xmfwiki', 'yiwiki', 'yiwikisource', 'yiwiktionary', 'yowiki', 'zawiki', 'zeawiki', 'zghwiki', 'zghwiktionary', 'zh_classicalwiki', 'zh_min_nanwiki', 'zh_min_nanwikisource', 'zh_min_nanwiktionary', 'zh_yuewiki', 'zhwiki', 'zhwikibooks', 'zhwikinews', 'zhwikiquote', 'zhwikisource', 'zhwikiversity', 'zhwikivoyage', 'zhwiktionary', 'zuwiki', 'zuwiktionary', 'test2wiki', 'testwiki'];
// Time delay between API requests to prevent rate limiting (429 errors)
const DELAY_MS = 800;
// Helper function to pause execution for a specified number of milliseconds
const sleep = ms => new Promise(r => setTimeout(r, ms));
// Cache for global user group information to avoid redundant API calls
let userCache = {};
// Cache for historical global rights changes within the audit period
let historyCache = {};
// Helper function to handle API calls with up to 3 retries on 429 errors
async function robustCall(api, params) {
let retries = 0;
const maxRetries = 3; // Maximum number of retry attempts
// Continue attempting the API request as long as we haven't hit the maximum retry limit
while (retries < maxRetries) {
try {
// Attempt to execute the API GET request
return await api.get(params);
} catch (err) {
// If the error is "429 Too Many Requests" (rate limit)
if (err?.status === 429) {
retries++;
// Get wait time from 'Retry-After' header, or use a default backoff (30s, 60s, 90s)
const retryAfter = parseInt(err.xhr?.getResponseHeader('Retry-After')) || (30 * retries);
// Update UI status message with the remaining wait time
$('#status-msg').html(`<span style="color:orange; font-weight:bold;">Rate limit hit! Resting ${retryAfter}s... (Attempt ${retries}/${maxRetries})</span>`);
// Pause execution before the next retry
await sleep(retryAfter * 1000);
} else {
// Rethrow any error that is not a rate limit (e.g., 404, 403, 500)
throw err;
}
}
}
// Fail if max retries are exceeded
throw new Error("Max retries reached");
}
// Converts a database name (like 'enwiki') into a real web address.
function getDomain(db) {
// Static mapping for projects with non-standard naming conventions
const mapping = {
'commonswiki': 'commons.wikimedia.org', 'metawiki': 'meta.wikimedia.org', 'wikidatawiki': 'www.wikidata.org',
'mediawikiwiki': 'www.mediawiki.org', 'sourceswiki': 'wikisource.org', 'foundationwiki': 'foundation.wikimedia.org',
'incubatorwiki': 'incubator.wikimedia.org', 'outreachwiki': 'outreach.wikimedia.org',
'be_x_oldwiki': 'be-tarask.wikipedia.org', 'labswiki': 'wikitech.wikimedia.org',
'wikimaniawiki': 'wikimania.wikimedia.org', 'specieswiki': 'species.wikimedia.org',
'abstractwiki': 'abstract.wikipedia.org', 'betawikiversity': 'beta.wikiversity.org',
'mo_wiki': 'ro.wikipedia.org',
'testwiki': 'test.wikipedia.org', 'test2wiki': 'test2.wikipedia.org',
'wikifunctionswiki': 'www.wikifunctions.org',
'testcommonswiki': 'test-commons.wikimedia.org',
'testwikidatawiki': 'test.wikidata.org',
};
// Return early if an explicit mapping exists
if (mapping[db]) return mapping[db];
// Format language codes: replace underscores with hyphens (e.g., zh_yue -> zh-yue)
const name = db.replace(/_/g, '-');
// Handle sister project families by trimming suffixes and appending correct domains
if (name.endsWith('wikisource')) return name.slice(0, -10) + '.wikisource.org';
if (name.endsWith('wikiversity')) return name.slice(0, -11) + '.wikiversity.org';
if (name.endsWith('wiktionary')) return name.slice(0, -10) + '.wiktionary.org';
if (name.endsWith('wikivoyage')) return name.slice(0, -10) + '.wikivoyage.org';
if (name.endsWith('wikibooks')) return name.slice(0, -9) + '.wikibooks.org';
if (name.endsWith('wikiquote')) return name.slice(0, -9) + '.wikiquote.org';
if (name.endsWith('wikinews')) return name.slice(0, -8) + '.wikinews.org';
if (name.endsWith('wikimedia')) return name.slice(0, -9) + '.wikimedia.org';
// Default logic for Wikipedias: remove 'wiki' suffix and append domain
if (name.endsWith('wiki')) return name.slice(0, -4) + '.wikipedia.org';
// Generic fallback
return name + '.wikipedia.org';
}
function getInterwikiPrefix(db) {
// Mapping for special projects that use non-standard interwiki shortcuts
const specials = {
'commonswiki': 'commons', 'metawiki': 'm', 'wikidatawiki': 'd',
'mediawikiwiki': 'mw', 'specieswiki': 'wikispecies',
'foundationwiki': 'wikimedia', 'sourceswiki': 'wikisource'
};
// Return early if an explicit special prefix is defined
if (specials[db]) return specials[db];
// Format name for interwiki consistency (underscores to hyphens)
const name = db.replace(/_/g, '-');
// Logic for sister project families: trim suffixes and append the standard shorthand
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 'wikimedia:' + name.slice(0, -9);
// Standard Wikipedia logic: removes 'wiki' suffix and adds 'w:' prefix
if (name.endsWith('wiki')) return 'w:' + name.slice(0, -4);
// Default fallback for any other database patterns
return 'w:' + name;
}
// Ensure required MediaWiki API and ForeignApi modules are loaded before execution
mw.loader.using(['mediawiki.api', 'mediawiki.ForeignApi']).then(function () {
// Initialize API connection to Meta-Wiki for global user information and rights logs
const metaApi = new mw.ForeignApi('https://meta.wikimedia.org/w/api.php');
// Capture current date/time for report generation and timestamping
const now = new Date();
// State management variables:
let results = {}, // Stores aggregated CU action counts per wiki/user
emptyWikis = [], // List of projects with no recorded CU actions
failedWikis = [], // List of projects that encountered API errors
scannedWikis = [], // Tracking list of successfully processed projects
isRunning = false; // Flag to control the start/stop state of the audit
// UI state variables for filtering wikis and user roles
let currentFilterMode = 'all'; // Current wiki selection mode (all, exclude, include)
let currentUserFilter = 'all'; // Current user role filter (all, local, steward)
// Initializes and builds the User Interface (UI) elements on the page.
function setupUI() {
// Get the current year to ensure the date range is always up-to-date
const currentYear = now.getFullYear();
// Generate HTML options for the year selection (starting from 2005 to current)
let yearOpts = '';
for (let y = currentYear; y >= 2005; y--) yearOpts += `<option value="${y}">${y}</option>`;
// Prepare HTML options for month selection (1-12)
let monthOptsFrom = '';
let monthOptsTo = '';
for (let m = 1; m <= 12; m++) {
// Set default: 'From' dropdown defaults to January (1)
monthOptsFrom += `<option value="${m}" ${m === 1 ? 'selected' : ''}>${m}</option>`;
// Set default: 'To' dropdown defaults to December (12)
monthOptsTo += `<option value="${m}" ${m === 12 ? 'selected' : ''}>${m}</option>`;
}
// Set the page title to identify the script
$('#firstHeading').text('GlobalCheckUserStats.js');
// Clear the main content area and append the control panel HTML
$('#mw-content-text').empty().append(`
<div style="border:1px solid #a2a9b1; padding:15px; background:#f8f9fa;">
<h3>Stats Range</h3>
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 style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>Wiki Filter:</strong>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="wiki-mode" id="btn-all" checked> All wikis</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="wiki-mode" id="btn-except"> All wikis except</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><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; font-size:12px; line-height:18px;" title="Show all wiki DB names">?</span>
</div>
<div id="filter-input-container" style="display:none; margin-top:10px;">
<input id="wiki-filter" type="text" style="width:100%; max-width:600px;" placeholder="">
</div>
<div style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>User Filter:</strong>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-all" checked> All users</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-local"> Only Local CUs</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-steward"> Only Stewards</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; color:#202122;">
<strong>Available Wikis (alphabetical):</strong><br>${[...rawWikis].sort().join(', ')}
</div>
<div style="margin-top:20px;">
<button id="start" class="mw-ui-button mw-ui-progressive">Run GlobalCheckUserStats.js</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="${rawWikis.length}" style="width:100%"></progress></div>
<textarea id="out" style="width:100%; height:450px; margin-top:10px; display:none; font-family:monospace; font-size:12px;"></textarea>
</div>
`);
// Handle clicking 'All wikis': resets filter mode and hides the input field
$('#btn-all').click(() => { currentFilterMode = 'all'; $('#filter-input-container').hide(); });
// Handle clicking 'All wikis except': shows input field, sets mode to 'exclude', and focuses the box
$('#btn-except').click(() => { currentFilterMode = 'exclude'; $('#filter-input-container').show(); $('#wiki-filter').attr('placeholder', 'Exclude (dbname1, dbname2)...').focus(); });
// Handle clicking 'Only these wikis': shows input field, sets mode to 'include', and focuses the box
$('#btn-only').click(() => { currentFilterMode = 'include'; $('#filter-input-container').show(); $('#wiki-filter').attr('placeholder', 'Only (dbname1, dbname2)...').focus(); });
// User role filter buttons: define which user categories will appear in the final table
$('#u-all').click(() => { currentUserFilter = 'all'; });
$('#u-local').click(() => { currentUserFilter = 'local'; });
$('#u-steward').click(() => { currentUserFilter = 'steward'; });
// Toggle visibility of the 'Available Wikis' help list when the question mark icon is clicked
$('#wiki-help-trigger').click(() => $('#wiki-list-help').toggle());
// Main execution trigger: starts the audit using the selected date range from dropdowns
$('#start').click(() => runAudit($('#y-f').val(), $('#m-f').val(), $('#y-t').val(), $('#m-t').val()));
// Stop button: sets isRunning to false to halt the loop between wiki scans
$('#stop').click(() => isRunning = false);
}
// Fetches general statistics for a specific wiki, primarily to get the active user count.
async function fetchWikiMetrics(db) {
try {
const api = new mw.ForeignApi(`https://${getDomain(db)}/w/api.php`);
// Request site statistics via the 'siteinfo' meta module
const res = await robustCall(api, {
action: 'query', meta: 'siteinfo', siprop: 'statistics', formatversion: 2
});
// Extract the active users count; default to 0 if the data is missing
return { active: res.query.statistics.activeusers || 0 };
// Graceful fallback: return zero if the project is unreachable or API fails
} catch (e) { return { active: 0 }; }
}
// Checks if a user held global roles (Steward, Staff, Ombuds) during the audit period.
async function checkGlobalHistory(user, start, end) {
try {
// Fetch global rights logs from Meta-Wiki for the specific user
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), auditEnd = new Date(end);
let state = { steward: false, staff: false, ombuds: false };
// Look at the last log entry BEFORE the audit started to see the initial status
const priorLogs = logs.filter(l => new Date(l.timestamp) <= auditStart).sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
if (priorLogs.length > 0) {
const p = priorLogs[0].params || {};
const groupsInLog = p.newGroups || p[1] || [];
state.steward = groupsInLog.includes('steward');
state.staff = groupsInLog.includes('staff');
state.ombuds = groupsInLog.includes('ombuds');
}
// Keep track of any global roles gained during the audit period itself
let held = { wasSteward: state.steward, wasStaff: state.staff, wasOmbuds: state.ombuds };
logs.filter(l => { const ts = new Date(l.timestamp); return ts > auditStart && ts < auditEnd; }).forEach(e => {
const p = e.params || {};
const added = p.newGroups || p[1] || [];
if (added.includes('steward')) held.wasSteward = true;
if (added.includes('staff')) held.wasStaff = true;
if (added.includes('ombuds')) held.wasOmbuds = true;
});
return held;
} catch (e) {
// If anything fails, assume no global roles were held
return { wasSteward: false, wasStaff: false, wasOmbuds: false }; }
}
// Gathers all necessary data for a specific user on a specific wiki.
async function fetchUserData(user, db, start, end) {
// Convert start and end strings into Date objects for easier comparison
const auditStart = new Date(start), auditEnd = new Date(end);
// 1. FETCH CURRENT GLOBAL GROUPS (Using cache to save time)
if (!userCache[user]) {
try {
// Ask Meta-Wiki for the user's current global groups (steward, staff, etc.)
const gres = await robustCall(metaApi, { action: 'query', meta: 'globaluserinfo', guiprop: 'groups', guiuser: user, formatversion: 2 });
userCache[user] = gres.query.globaluserinfo.groups || [];
// Fallback to an empty list if the request fails
} catch (e) { userCache[user] = []; }
}
// Check if the user currently holds any key global roles
const gGroups = userCache[user];
const isGloballySteward = gGroups.includes('steward');
const isGloballyStaff = gGroups.includes('staff');
const isGloballyOmbuds = gGroups.includes('ombuds') || gGroups.includes('ombudsman');
// 2. FETCH GLOBAL HISTORY (Identify roles held during the period but lost since)
if (!historyCache[user]) historyCache[user] = await checkGlobalHistory(user, start, end);
const historyRes = historyCache[user];
// 3. CHECK CURRENT LOCAL CHECKUSER STATUS
let isCurrentLocal = false;
try {
// Connect to the local wiki to see if the user is currently a CheckUser
const localApi = new mw.ForeignApi(`https://${getDomain(db)}/w/api.php`);
const ures = await robustCall(localApi, { action: 'query', list: 'users', ususers: user, usprop: 'groups', formatversion: 2 });
const lGroups = (ures.query.users[0] && ures.query.users[0].groups) || [];
isCurrentLocal = lGroups.includes('checkuser');
} catch (e) {
// Ignore errors; defaults to false
}
// Define the target string for rights log searching
const target = 'User:' + user + '@' + db;
// Initialize variables for log analysis and role calculation
let logText = '',
addTime = null,
removeTime = null,
isSelfAssign = false,
maxDurationMins = -1,
longestTimeStr = "",
assignCount = 0,
hasLocalRightsInPeriod = false;
// 4. DEEP RIGHTS LOG SCAN (Pagination to bypass 500-limit)
let events = [];
let continueToken = null;
let finished = false;
try {
// Loop through all log pages until we reach the end of history
while (!finished) {
let params = {
action: 'query', list: 'logevents', letype: 'rights',
letitle: target, ledir: 'older', lelimit: 'max', formatversion: 2
};
if (continueToken) Object.assign(params, continueToken);
const res = await robustCall(metaApi, params);
const batch = res.query.logevents || [];
events = events.concat(batch);
// If the API says there is more data, keep going; otherwise, stop
if (res.continue) continueToken = res.continue;
else finished = true;
}
let logEntries = [];
let pendingRemoved = null;
let lastPairedDate = null; // Track last processed ADD to avoid duplicates - e.g., when a Steward self-assigns CU and then updates the duration/reason.
// Process each log event to track when CheckUser rights were granted or removed
for (let i = 0; i < events.length; i++) {
const e = events[i], p = e.params || {};
// Detect if CheckUser group was added or removed in this log entry
let cuAdded = (p.add || []).includes('checkuser') || ((p.newgroups || []).includes('checkuser') && !(p.oldgroups || []).includes('checkuser'));
const cuRemoved = (p.remove || []).includes('checkuser') || (!(p.newgroups || []).includes('checkuser') && (p.oldgroups || []).includes('checkuser'));
// If rights were just updated (e.g. expiry changed), count it as an ADD
const cuModified = (p.newgroups || []).includes('checkuser') && (p.oldgroups || []).includes('checkuser');
if (cuModified) cuAdded = true;
const eventDate = new Date(e.timestamp);
const exactTime = e.timestamp.replace('T', ' ').replace('Z', '');
if (cuAdded) addTime = eventDate;
if (cuRemoved) removeTime = eventDate;
// Check if the user had rights at any point during the selected audit period
if (addTime && addTime <= auditEnd && (!removeTime || removeTime >= auditStart)) hasLocalRightsInPeriod = true;
// Only record details if the event happened within our date range
if (eventDate >= auditStart && eventDate <= auditEnd && (cuAdded || cuRemoved)) {
let expiryDate = null;
// Check for temporary access expiry dates
if (p.newmetadata) {
const cuMeta = p.newmetadata.find(m => m.group === 'checkuser');
if (cuMeta && cuMeta.expiry && cuMeta.expiry !== 'infinity') expiryDate = new Date(cuMeta.expiry);
}
const isSelf = (e.user === user || !e.user) ? " (Self-assign)" : "";
if (e.user === user || !e.user) isSelfAssign = true;
if (cuAdded) {
// Match an ADD event with a previously found REMOVE event to calculate duration
if (pendingRemoved) {
const diffMins = Math.round(Math.abs(pendingRemoved.date - eventDate) / 60000);
const d = Math.floor(diffMins / 1440), h = Math.floor((diffMins % 1440) / 60), m = diffMins % 60;
const durStr = `${d > 0 ? d+'d ' : ''}${h > 0 ? h+'h ' : ''}${m}m`;
logEntries.unshift(`* ADDED: ${exactTime} | REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf} (Duration: ${durStr})`);
if (diffMins > maxDurationMins) { maxDurationMins = diffMins; longestTimeStr = durStr; }
assignCount++;
pendingRemoved = null;
lastPairedDate = eventDate;
}
// If the ADD has its own expiry metadata, use that for duration
else if (expiryDate) {
const diffMins = Math.round(Math.abs(expiryDate - eventDate) / 60000);
const d = Math.floor(diffMins / 1440), h = Math.floor((diffMins % 1440) / 60), m = diffMins % 60;
const durStr = `${d > 0 ? d+'d ' : ''}${h > 0 ? h+'h ' : ''}${m}m`;
const exactExpiryTime = expiryDate.toISOString().replace('T', ' ').substring(0, 19);
logEntries.unshift(`* ADDED: ${exactTime} | EXPIRED: ${exactExpiryTime} by ${e.user}${isSelf} (Duration: ${durStr})`);
if (diffMins > maxDurationMins) { maxDurationMins = diffMins; longestTimeStr = durStr; }
assignCount++;
lastPairedDate = eventDate;
}
// Otherwise, it's an active or indefinite assignment
else {
if (lastPairedDate && Math.abs(lastPairedDate - eventDate) < 86400000) {
// Ignore duplicate entry from the same session
} else {
logEntries.unshift(`* ADDED: ${exactTime} | REMOVED: (Active/Not removed) by ${e.user}${isSelf}`);
lastPairedDate = eventDate;
}
}
}
else if (cuRemoved) {
// If we find a REMOVE event, save it to pair with the next ADD event found
if (pendingRemoved) logEntries.unshift(`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf}`);
pendingRemoved = { time: exactTime, date: eventDate, user: e.user, isSelf: isSelf };
}
}
}
// Cleanup: handle any remaining REMOVE events that didn't have a matching ADD
if (pendingRemoved) logEntries.unshift(`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf}`);
if (logEntries.length) logText = '\n' + logEntries.join('\n');
// 5. ROLE CLASSIFICATION
let roleLabel = "";
// Check if user has any logged actions or held rights during the period
const hasActions = (results[db] && results[db][user] && results[db][user].total > 0);
let wasCU = hasLocalRightsInPeriod || hasActions;
// Determine the most relevant role for the user
if (isGloballyStaff || historyRes.wasStaff) {
// Priority 1: WMF Staff status
roleLabel = isGloballyStaff ? "Current Staff" : "Former Staff (in period)";
}
else if (isCurrentLocal) {
// Priority 2: Currently active local CheckUser
roleLabel = "Current Local CheckUser";
}
else if (longestTimeStr && isSelfAssign && (isGloballySteward || historyRes.wasSteward)) {
// Priority 3: Steward acting temporarily (Self-assign detection)
roleLabel = `Steward action (Self-assign: ${assignCount > 1 ? assignCount + 'x, longest ' : ''}${longestTimeStr})`;
}
else if (isGloballySteward || historyRes.wasSteward) {
// Priority 4: Standard Steward status
roleLabel = isGloballySteward ? "Current Steward" : "Former Steward (in period)";
}
else if (isGloballyOmbuds || historyRes.wasOmbuds) {
// Priority 5: Ombudsman commission status
roleLabel = isGloballyOmbuds ? "Current Ombudsman" : "Former Ombudsman (in period)";
}
else if (wasCU) {
// Priority 6: User who was a local CU but no longer is
roleLabel = "Former Local CheckUser (in period)";
}
else {
// Default if no specific role matches
roleLabel = "Unknown role";
}
// Return the calculated role and the formatted log entries
return { role: roleLabel, log: logText };
} catch (err) {
// Return an error state if anything crashes during user data processing
return { role: "Error fetching data", log: "" };
}
}
// The main engine that iterates through wikis and collects CheckUser logs.
async function runAudit(yf, mf, yt, mt) {
// Reset all data and state variables for a fresh run
isRunning = true;
userCache = {};
historyCache = {};
results = {};
emptyWikis = [];
failedWikis = [];
scannedWikis = [];
// Parse and prepare the wiki filter list from the UI input
const filterText = $('#wiki-filter').val().trim().toLowerCase();
const filterList = filterText ? filterText.split(',').map(s => s.trim()).filter(s => s !== "") : [];
let wikisToScan = rawWikis;
// Apply include/exclude logic to the master wiki list
if (currentFilterMode === 'include') wikisToScan = rawWikis.filter(w => filterList.includes(w));
else if (currentFilterMode === 'exclude') wikisToScan = rawWikis.filter(w => !filterList.includes(w));
if (wikisToScan.length === 0) { alert("No wikis selected!"); return; }
// Update UI buttons and progress bar to "running" state
$('#start').prop('disabled', true); $('#stop').prop('disabled', false); $('#out').hide();
$('#bar').attr('max', wikisToScan.length).val(0);
// Format the date strings into ISO format 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 columns for the table based on the selected month range
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++; }
}
// Loop through each selected wiki and fetch its logs
for (let i = 0; i < wikisToScan.length; i++) {
if (!isRunning) break; // Exit loop if the "Stop" button was clicked
const db = wikisToScan[i]; scannedWikis.push(db);
$('#status-msg').text(`Scanning ${db} (${i + 1}/${wikisToScan.length})...`);
let successLocal = false, continueToken = null;
// Inner loop for handling pagination of the logs on a single wiki
while (!successLocal && isRunning) {
try {
const api = new mw.ForeignApi(`https://${getDomain(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);
// Process each entry in the log batch
const ent = res.query?.checkuserlog?.entries || [];
if (ent.length) {
if (!results[db]) results[db] = {};
ent.forEach(e => {
const u = e.checkuser;
if (!results[db][u]) results[db][u] = { total: 0, months: {} };
const mKey = e.timestamp.slice(0, 7);
results[db][u].total++; results[db][u].months[mKey] = (results[db][u].months[mKey] || 0) + 1;
});
}
// Check if there are more log pages to fetch
if (res.continue && isRunning) continueToken = res.continue;
else { if (!results[db]) emptyWikis.push(db); successLocal = true; }
} catch (err) {
// Record failures (like 429 after retries or 404) and move to the next wiki
failedWikis.push(db);
successLocal = true;
}
}
// Update the progress bar and wait a bit to be kind to the API
$('#bar').val(i + 1); await sleep(DELAY_MS);
}
// Update UI and prepare the Wikitable header and metadata
$('#status-msg').text(`Generating report...`);
const today = new Date().toISOString().split('T')[0];
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' (UTC)';
// Start building the Wikitext report
let wt = `== Global CheckUser Stats (${mf}/${yf} - ${mt}/${yt}) ==\n''Report generated on: ${timestamp}''\n\n`;
let rightsLog = `\n== Rights Log (In Period) ==\n`;
// Create table structure with dynamic month columns
wt += `{| class="wikitable sortable" style="font-size:90%; text-align:right;"\n! Wiki / User !! Category !! '''Total''' !! per 1k active users ${monthCols.map(m => `!! ${m.label}`).join(' ')}\n`;
// Sort databases alphabetically and begin processing each project's results
const sortedDBs = Object.keys(results).sort();
for (const db of sortedDBs) {
// Get active user count; if API fails or is 0, display 'Unknown'
const metrics = await fetchWikiMetrics(db);
const fmtActive = metrics.active > 0 ? metrics.active.toLocaleString('en-US') : 'Unknown';
// Prepare interwiki links for the project and its CheckUser log
const iwPrefix = getInterwikiPrefix(db);
const projectLink = `[[${iwPrefix}:|${db}]]`;
const logLink = `[[${iwPrefix}:Special:CheckUserLog|(log)]]`;
wt += `|-\n! colspan="${4 + monthCols.length}" style="background:#eaecf0; text-align:center;" | ${projectLink} <small style="font-weight:normal; color:#54595d;">(Active Users as of ${today}: ${fmtActive})</small> — ${logLink}\n`;
// Initialize local wiki totals and storage for individual user rows
let wikiRows = [],
wT = 0,
wMS = {};
monthCols.forEach(col => wMS[col.key] = 0);
const projectUsers = Object.keys(results[db]).sort();
for (const user of projectUsers) {
// Get role and rights log details for each user
const m = await fetchUserData(user, db, START, END);
// Identify if user belongs to specific global or local groups
const isSteward = m.role.includes("Steward") || m.role.includes("Staff") || m.role.includes("Ombudsman");
const isLocalCU = m.role.includes("Local CheckUser");
// Filter out users if they don't match the selected User Filter (Local/Steward)
if (currentUserFilter === 'local' && !isLocalCU) continue;
if (currentUserFilter === 'steward' && !isSteward) continue;
const uD = results[db][user];
// Aggregate totals for the current wiki
wT += uD.total; monthCols.forEach(col => wMS[col.key] += (uD.months[col.key] || 0));
// Calculate actions per 1k active users; display 0.0 if project activity is unknown
let uP1kU = metrics.active > 0 ? ((uD.total / metrics.active) * 1000).toFixed(1) : "0.0";
// Build the Wikitext row for the individual user
let row = `|-\n| style="text-align:left;" | ${user}@${db} || <small>${m.role}</small> || '''${uD.total}''' || ${uP1kU}`;
monthCols.forEach(col => row += ` || ${uD.months[col.key] || 0}`);
// Store the formatted row and its corresponding rights log
wikiRows.push({ html: row + "\n", log: m.log ? `'''${user}@${db}''':${m.log}\n\n` : "" });
}
// Create the bold TOTAL row for the current wiki
let wP1kU = metrics.active > 0 ? ((wT / metrics.active) * 1000).toFixed(1) : "0.0";
let totalRow = `|- style="background:#f8f9fa; font-weight:bold;"\n| style="text-align:left;" | TOTAL ${db} || — || ${wT} || ${wP1kU}`;
monthCols.forEach(col => totalRow += ` || ${wMS[col.key]}`);
// Append the total row first, then all individual user rows
wt += totalRow + "\n";
wikiRows.forEach(r => { wt += r.html; if (r.log) rightsLog += r.log; });
}
// Append a list of wikis where no CheckUser actions were found
wt += `|}\n\n== Projects with 0 actions ==\n<div style="font-size:85%; color:#54595d;">${emptyWikis.sort().join(', ')}</div>\n`;
// Append a list of projects that failed to scan (e.g., due to API errors)
if (failedWikis.length > 0) {
wt += `\n== API Errors / Skipped Projects ==\n<div style="font-size:85%; color:#d33;">${failedWikis.sort().join(', ')}</div>\n`;
}
// Finalize the report with the Rights Log section
wt += rightsLog + `\n\n<references />\n`;
// Display the final output and reset UI buttons
$('#out').val(wt).show();
$('#status-msg').text(`Done.`); $('#start').prop('disabled', false); $('#stop').prop('disabled', true);
}
// Initialize the interface on page load
setupUI();
});
})();
lt0hmzr4bc81ibukce8601mhu9tn6m8
737758
737757
2026-04-12T16:35:59Z
MrJaroslavik
44012
e
737758
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]].
// - Full Reporting: Outputs sortable Wikitables and detailed rights change logs.
// - With help of Gemini 3
// -------------------------------------------------------
// -------------------------------------------------------
// Wrap everything so this script doesn't break other Wikipedia scripts
(function () {
'use strict';
// Stop immediately if we are NOT on the specific GlobalCheckUserStats page
if (mw.config.get('wgCanonicalSpecialPageName') !== 'Blankpage' || mw.config.get('wgTitle').indexOf('GlobalCheckUserStats') === -1) return;
// A fixed list of all valid Wikimedia projects to scan
const rawWikis = ['abstractwiki', 'abwiki', 'acewiki', 'adywiki', 'afwiki', 'afwikibooks', 'afwikiquote', 'afwiktionary', 'alswiki', 'altwiki', 'amiwiki', 'amwiki', 'amwiktionary', 'angwiki', 'angwiktionary', 'annwiki', 'anpwiki', 'anwiki', 'anwiktionary', 'arcwiki', 'arwiki', 'arwikibooks', 'arwikimedia', 'arwikinews', 'arwikiquote', 'arwikisource', 'arwikiversity', 'arwiktionary', 'arywiki', 'arzwiki', 'astwiki', 'astwiktionary', 'aswiki', 'aswikiquote', 'aswikisource', 'atjwiki', 'avkwiki', 'avwiki', 'awawiki', 'aywiki', 'aywiktionary', 'azbwiki', 'azwiki', 'azwikibooks', 'azwikiquote', 'azwikisource', 'azwiktionary', 'banwiki', 'banwikisource', 'barwiki', 'bat_smgwiki', 'bawiki', 'bawikibooks', 'bbcwiki', 'bclwiki', 'bclwikiquote', 'bclwikisource', 'bclwiktionary', 'bdrwiki', 'bdwikimedia', 'be_x_oldwiki', 'betawikiversity', 'bewiki', 'bewikibooks', 'bewikimedia', 'bewikiquote', 'bewikisource', 'bewiktionary', 'bewwiki', 'bewwiktionary', 'bgwiki', 'bgwikibooks', 'bgwikiquote', 'bgwikisource', 'bgwiktionary', 'bhwiki', 'biwiki', 'bjnwiki', 'bjnwikiquote', 'bjnwiktionary', 'blkwiki', 'blkwiktionary', 'bmwiki', 'bnwiki', 'bnwikibooks', 'bnwikiquote', 'bnwikisource', 'bnwikivoyage', 'bnwiktionary', 'bowiki', 'bpywiki', 'brwiki', 'brwikimedia', 'brwikiquote', 'brwikisource', 'brwiktionary', 'bswiki', 'bswikibooks', 'bswikinews', 'bswikiquote', 'bswikisource', 'bswiktionary', 'btmwiki', 'btmwiktionary', 'bugwiki', 'bxrwiki', 'cawiki', 'cawikibooks', 'cawikimedia', 'cawikinews', 'cawikiquote', 'cawikisource', 'cawiktionary', 'cbk_zamwiki', 'cdowiki', 'cebwiki', 'cewiki', 'chrwiki', 'chrwiktionary', 'chwiki', 'chywiki', 'ckbwiki', 'ckbwiktionary', 'commonswiki', 'cowiki', 'cowikimedia', 'cowiktionary', 'crhwiki', 'csbwiki', 'csbwiktionary', 'cswiki', 'cswikibooks', 'cswikinews', 'cswikiquote', 'cswikisource', 'cswikiversity', 'cswikivoyage', 'cswiktionary', 'cuwiki', 'cvwiki', 'cvwikibooks', 'cywiki', 'cywikibooks', 'cywikiquote', 'cywikisource', 'cywiktionary', 'dagwiki', 'dawiki', 'dawikibooks', 'dawikiquote', 'dawikisource', 'dawiktionary', 'dewiki', 'dewikibooks', 'dewikinews', 'dewikiquote', 'dewikisource', 'dewikiversity', 'dewikivoyage', 'dewiktionary', 'dgawiki', 'dinwiki', 'diqwiki', 'diqwiktionary', 'dkwikimedia', 'dsbwiki', 'dtpwiki', 'dtywiki', 'dvwiki', 'dvwiktionary', 'dzwiki', 'eewiki', 'elwiki', 'elwikibooks', 'elwikinews', 'elwikiquote', 'elwikisource', 'elwikiversity', 'elwikivoyage', 'elwiktionary', 'emlwiki', 'enwiki', 'enwikibooks', 'enwikinews', 'enwikiquote', 'enwikisource', 'enwikiversity', 'enwikivoyage', 'enwiktionary', 'eowiki', 'eowikibooks', 'eowikinews', 'eowikiquote', 'eowikisource', 'eowikivoyage', 'eowiktionary', 'eswiki', 'eswikibooks', 'eswikinews', 'eswikiquote', 'eswikisource', 'eswikiversity', 'eswikivoyage', 'eswiktionary', 'etwiki', 'etwikibooks', 'eewikimedia', 'etwikiquote', 'etwikisource', 'etwiktionary', 'euwiki', 'euwikibooks', 'euwikiquote', 'euwikisource', 'euwiktionary', 'extwiki', 'fatwiki', 'fawiki', 'fawikibooks', 'fawikinews', 'fawikiquote', 'fawikisource', 'fawikivoyage', 'fawiktionary', 'ffwiki', 'fiu_vrowiki', 'fiwiki', 'fiwikibooks', 'fiwikimedia', 'fiwikinews', 'fiwikiquote', 'fiwikisource', 'fiwikiversity', 'fiwikivoyage', 'fiwiktionary', 'fjwiki', 'fjwiktionary', 'fonwiki', 'foundationwiki', 'fowiki', 'fowikisource', 'fowiktionary', 'frpwiki', 'frrwiki', 'frwiki', 'frwikibooks', 'frwikinews', 'frwikiquote', 'frwikisource', 'frwikiversity', 'frwikivoyage', 'frwiktionary', 'furwiki', 'fywiki', 'fywikibooks', 'fywiktionary', 'gagwiki', 'ganwiki', 'gawiki', 'gawiktionary', 'gcrwiki', 'gdwiki', 'gdwiktionary', 'glkwiki', 'glwiki', 'glwikibooks', 'glwikiquote', 'glwikisource', 'glwiktionary', 'gnwiki', 'gnwiktionary', 'gomwiki', 'gomwiktionary', 'gorwiki', 'gorwikiquote', 'gorwiktionary', 'gotwiki', 'gpewiki', 'gucwiki', 'gurwiki', 'guwiki', 'guwikiquote', 'guwikisource', 'guwiktionary', 'guwwiki', 'guwwikinews', 'guwwikiquote', 'guwwiktionary', 'gvwiki', 'gvwiktionary', 'hakwiki', 'hawiki', 'hawiktionary', 'hawwiki', 'hewiki', 'hewikibooks', 'hewikinews', 'hewikiquote', 'hewikisource', 'hewikivoyage', 'hewiktionary', 'hifwiki', 'hifwiktionary', 'hiwiki', 'hiwikibooks', 'hiwikiquote', 'hiwikisource', 'hiwikiversity', 'hiwikivoyage', 'hiwiktionary', 'hrwiki', 'hrwikibooks', 'hrwikiquote', 'hrwikisource', 'hrwiktionary', 'hsbwiki', 'hsbwiktionary', 'htwiki', 'huwiki', 'huwikibooks', 'huwikisource', 'huwiktionary', 'hywiki', 'hywikibooks', 'hywikiquote', 'hywikisource', 'hywiktionary', 'hywwiki', 'iawiki', 'iawikibooks', 'iawiktionary', 'ibawiki', 'idwiki', 'idwikibooks', 'idwikiquote', 'idwikisource', 'idwikivoyage', 'idwiktionary', 'iewiki', 'iewiktionary', 'iglwiki', 'igwiki', 'igwikiquote', 'igwiktionary', 'ikwiki', 'ilowiki', 'incubatorwiki', 'inhwiki', 'iowiki', 'iowiktionary', 'iswiki', 'iswikibooks', 'iswikiquote', 'iswikisource', 'iswiktionary', 'itwiki', 'itwikibooks', 'itwikinews', 'itwikiquote', 'itwikisource', 'itwikiversity', 'itwikivoyage', 'itwiktionary', 'iuwiki', 'iuwiktionary', 'jamwiki', 'jawiki', 'jawikibooks', 'jawikinews', 'jawikisource', 'jawikiversity', 'jawikivoyage', 'jawiktionary', 'jbowiki', 'jbowiktionary', 'jvwiki', 'jvwikisource', 'jvwiktionary', 'kaawiki', 'kaawiktionary', 'kabwiki', 'kaiwiki', 'kajwiki', 'kawiki', 'kawikibooks', 'kawikiquote', 'kawikisource', 'kawiktionary', 'kbdwiki', 'kbdwiktionary', 'kbpwiki', 'kcgwiki', 'kcgwiktionary', 'kgewiki', 'kgwiki', 'kiwiki', 'kkwiki', 'kkwikibooks', 'kkwiktionary', 'klwiktionary', 'kmwiki', 'kmwikibooks', 'kmwiktionary', 'kncwiki', 'knwiki', 'knwikiquote', 'knwikisource', 'knwiktionary', 'koiwiki', 'kowiki', 'kowikibooks', 'kowikinews', 'kowikiquote', 'kowikisource', 'kowikiversity', 'kowiktionary', 'krcwiki', 'kshwiki', 'kswiki', 'kswiktionary', 'kuswiki', 'kuwiki', 'kuwikibooks', 'kuwikiquote', 'kuwiktionary', 'kvwiki', 'kwwiki', 'kwwiktionary', 'kywiki', 'kywikiquote', 'kywiktionary', 'labswiki', 'ladwiki', 'lawiki', 'lawikibooks', 'lawikiquote', 'lawikisource', 'lawiktionary', 'lbewiki', 'lbwiki', 'lbwiktionary', 'lezwiki', 'lfnwiki', 'lgwiki', 'lijwiki', 'lijwikisource', 'liwiki', 'liwikibooks', 'liwikinews', 'liwikiquote', 'liwikisource', 'liwiktionary', 'lldwiki', 'lmowiki', 'lmowiktionary', 'lnwiki', 'lnwiktionary', 'lowiki', 'lowiktionary', 'ltgwiki', 'ltwiki', 'ltwikibooks', 'ltwikiquote', 'ltwikisource', 'ltwiktionary', 'lvwiki', 'lvwiktionary', 'madwiki', 'madwikisource', 'madwiktionary', 'maiwiki', 'map_bmswiki', 'mdfwiki', 'mediawikiwiki', 'metawiki', 'mgwiki', 'mgwikibooks', 'mgwiktionary', 'mhrwiki', 'minwiki', 'minwikibooks', 'minwikisource', 'minwiktionary', 'miwiki', 'miwiktionary', 'mkwiki', 'mkwikibooks', 'mkwikimedia', 'mkwikisource', 'mkwiktionary', 'mlwiki', 'mlwikibooks', 'mlwikiquote', 'mlwikisource', 'mlwiktionary', 'mniwiki', 'mniwiktionary', 'mnwiki', 'mnwiktionary', 'mnwwiki', 'mnwwiktionary', 'moswiki', 'mrjwiki', 'mrwiki', 'mrwikibooks', 'mrwikiquote', 'mrwikisource', 'mrwiktionary', 'mswiki', 'mswikibooks', 'mswikiquote', 'mswikisource', 'mswiktionary', 'mtwiki', 'mtwiktionary', 'mwlwiki', 'mxwikimedia', 'myvwiki', 'mywiki', 'mywikisource', 'mywiktionary', 'mznwiki', 'nahwiki', 'nahwiktionary', 'napwiki', 'napwikisource', 'nawiktionary', 'nds_nlwiki', 'ndswiki', 'ndswiktionary', 'newiki', 'newikibooks', 'newiktionary', 'newwiki', 'niawiki', 'niawiktionary', 'nlwiki', 'nlwikibooks', 'nlwikimedia', 'nlwikinews', 'nlwikiquote', 'nlwikisource', 'nlwikivoyage', 'nlwiktionary', 'nnwiki', 'nnwikiquote', 'nnwiktionary', 'novwiki', 'nowiki', 'nowikibooks', 'nowikimedia', 'nowikinews', 'nowikiquote', 'nowikisource', 'nowiktionary', 'nqowiki', 'nrmwiki', 'nrwiki', 'nsowiki', 'nupwiki', 'nvwiki', 'nycwikimedia', 'nywiki', 'ocwiki', 'ocwikibooks', 'ocwiktionary', 'olowiki', 'omwiki', 'omwiktionary', 'orwiki', 'orwikisource', 'orwiktionary', 'oswiki', 'outreachwiki', 'pagwiki', 'pamwiki', 'papwiki', 'pawiki', 'pawikibooks', 'pawikisource', 'pawiktionary', 'pcdwiki', 'pcmwiki', 'pcmwikiquote', 'pdcwiki', 'pflwiki', 'piwiki', 'plwiki', 'plwikibooks', 'plwikimedia', 'plwikinews', 'plwikiquote', 'plwikisource', 'plwikivoyage', 'plwiktionary', 'pmswiki', 'pmswikisource', 'pnbwiki', 'pnbwiktionary', 'pntwiki', 'pplwiki', 'pswiki', 'pswikivoyage', 'pswiktionary', 'ptwiki', 'ptwikibooks', 'ptwikimedia', 'ptwikinews', 'ptwikiquote', 'ptwikisource', 'ptwikiversity', 'ptwikivoyage', 'ptwiktionary', 'pwnwiki', 'quwiktionary', 'rkiwiki', 'rmwiki', 'rmywiki', 'rnwiki', 'roa_rupwiki', 'roa_rupwiktionary', 'roa_tarawiki', 'rowiki', 'rowikibooks', 'rowikinews', 'rowikiquote', 'rowiktionary', 'rskwiki', 'ruewiki', 'ruwiki', 'ruwikibooks', 'ruwikinews', 'ruwikiquote', 'ruwikisource', 'ruwikiversity', 'ruwikivoyage', 'ruwiktionary', 'rwwiki', 'rwwiktionary', 'sahwiki', 'sahwikiquote', 'sahwikisource', 'satwiki', 'satwiktionary', 'sawiki', 'sawikibooks', 'sawikiquote', 'sawikisource', 'sawiktionary', 'scnwiki', 'scnwiktionary', 'scowiki', 'scwiki', 'sdwiki', 'sdwiktionary', 'sewiki', 'sewikimedia', 'sgwiki', 'sgwiktionary', 'shiwiki', 'shnwiki', 'shnwikibooks', 'shnwikinews', 'shnwikivoyage', 'shnwiktionary', 'shwiki', 'shwiktionary', 'shywiktionary', 'simplewiki', 'simplewiktionary', 'siwiki', 'siwikibooks', 'siwiktionary', 'skwiki', 'skrwiki', 'skrwiktionary', 'slwiki', 'slwikibooks', 'slwikiquote', 'slwikisource', 'slwikiversity', 'slwiktionary', 'smnwiki', 'smwiki', 'smwiktionary', 'snwiki', 'sourceswiki', 'sowiki', 'sowiktionary', 'specieswiki', 'sqwiki', 'sqwikibooks', 'sqwikinews', 'sqwikiquote', 'sqwiktionary', 'srnwiki', 'srwiki', 'srwikibooks', 'srwikinews', 'srwikiquote', 'srwikisource', 'srwiktionary', 'sswiki', 'sswiktionary', 'stqwiki', 'stwiki', 'stwiktionary', 'suwiki', 'suwikiquote', 'suwikisource', 'suwiktionary', 'svwiki', 'svwikibooks', 'svwikinews', 'svwikiquote', 'svwikisource', 'svwikiversity', 'svwikivoyage', 'svwiktionary', 'swwiki', 'swwiktionary', 'sylwiki', 'szlwiki', 'szywiki', 'tawiki', 'tawikibooks', 'tawikinews', 'tawikiquote', 'tawikisource', 'tawiktionary', 'taywiki', 'tcywiki', 'tcywikisource', 'tcywiktionary', 'tddwiki', 'tewiki', 'tewikibooks', 'tewikiquote', 'tewikisource', 'tewiktionary', 'tgwiki', 'tgwikibooks', 'tgwiktionary', 'thwiki', 'thwikibooks', 'thwikimedia', 'thwikiquote', 'thwikisource', 'thwiktionary', 'tigwiki', 'tiwiki', 'tiwiktionary', 'tkwiki', 'tkwiktionary', 'tlwiki', 'tlwikibooks', 'tlwikiquote', 'tlwikisource', 'tlwiktionary', 'tlywiki', 'tnwiki', 'tnwiktionary', 'tokwiki', 'towiki', 'tpiwiki', 'tpiwiktionary', 'trvwiki', 'trwiki', 'trwikibooks', 'trwikimedia', 'trwikinews', 'trwikiquote', 'trwikisource', 'trwikivoyage', 'trwiktionary', 'tswiki', 'tswiktionary', 'ttwiki', 'ttwikibooks', 'ttwikiquote', 'ttwiktionary', 'tumwiki', 'twwiki', 'twwiktionary', 'tyvwiki', 'tywiki', 'uawikimedia', 'udmwiki', 'ugwiki', 'ugwiktionary', 'ukwiki', 'ukwikibooks', 'ukwikinews', 'ukwikiquote', 'ukwikisource', 'ukwikivoyage', 'ukwiktionary', 'urwiki', 'urwikibooks', 'urwikiquote', 'urwikisource', 'urwiktionary', 'uzwiki', 'uzwikiquote', 'uzwiktionary', 'vecwiki', 'vecwikisource', 'vecwiktionary', 'vepwiki', 'vewiki', 'viwiki', 'viwikibooks', 'viwikiquote', 'viwikisource', 'viwikivoyage', 'viwiktionary', 'vlswiki', 'vowiki', 'vowiktionary', 'warwiki', 'wawiki', 'wawikisource', 'wawiktionary', 'wikidatawiki', 'wikimaniawiki', 'wowiki', 'wowiktionary', 'wuuwiki', 'xalwiki', 'xhwiki', 'xmfwiki', 'yiwiki', 'yiwikisource', 'yiwiktionary', 'yowiki', 'zawiki', 'zeawiki', 'zghwiki', 'zghwiktionary', 'zh_classicalwiki', 'zh_min_nanwiki', 'zh_min_nanwikisource', 'zh_min_nanwiktionary', 'zh_yuewiki', 'zhwiki', 'zhwikibooks', 'zhwikinews', 'zhwikiquote', 'zhwikisource', 'zhwikiversity', 'zhwikivoyage', 'zhwiktionary', 'zuwiki', 'zuwiktionary', 'test2wiki', 'testwiki'];
// Time delay between API requests to prevent rate limiting (429 errors)
const DELAY_MS = 800;
// Helper function to pause execution for a specified number of milliseconds
const sleep = ms => new Promise(r => setTimeout(r, ms));
// Cache for global user group information to avoid redundant API calls
let userCache = {};
// Cache for historical global rights changes within the audit period
let historyCache = {};
// Helper function to handle API calls with up to 3 retries on 429 errors
async function robustCall(api, params) {
let retries = 0;
const maxRetries = 3; // Maximum number of retry attempts
// Continue attempting the API request as long as we haven't hit the maximum retry limit
while (retries < maxRetries) {
try {
// Attempt to execute the API GET request
return await api.get(params);
} catch (err) {
// If the error is "429 Too Many Requests" (rate limit)
if (err?.status === 429) {
retries++;
// Get wait time from 'Retry-After' header, or use a default backoff (30s, 60s, 90s)
const retryAfter = parseInt(err.xhr?.getResponseHeader('Retry-After')) || (30 * retries);
// Update UI status message with the remaining wait time
$('#status-msg').html(`<span style="color:orange; font-weight:bold;">Rate limit hit! Resting ${retryAfter}s... (Attempt ${retries}/${maxRetries})</span>`);
// Pause execution before the next retry
await sleep(retryAfter * 1000);
} else {
// Rethrow any error that is not a rate limit (e.g., 404, 403, 500)
throw err;
}
}
}
// Fail if max retries are exceeded
throw new Error("Max retries reached");
}
// Converts a database name (like 'enwiki') into a real web address.
function getDomain(db) {
// Static mapping for projects with non-standard naming conventions
const mapping = {
'commonswiki': 'commons.wikimedia.org', 'metawiki': 'meta.wikimedia.org', 'wikidatawiki': 'www.wikidata.org',
'mediawikiwiki': 'www.mediawiki.org', 'sourceswiki': 'wikisource.org', 'foundationwiki': 'foundation.wikimedia.org',
'incubatorwiki': 'incubator.wikimedia.org', 'outreachwiki': 'outreach.wikimedia.org',
'be_x_oldwiki': 'be-tarask.wikipedia.org', 'labswiki': 'wikitech.wikimedia.org',
'wikimaniawiki': 'wikimania.wikimedia.org', 'specieswiki': 'species.wikimedia.org',
'abstractwiki': 'abstract.wikipedia.org', 'betawikiversity': 'beta.wikiversity.org',
'mo_wiki': 'ro.wikipedia.org',
'testwiki': 'test.wikipedia.org', 'test2wiki': 'test2.wikipedia.org',
'wikifunctionswiki': 'www.wikifunctions.org',
'testcommonswiki': 'test-commons.wikimedia.org',
'testwikidatawiki': 'test.wikidata.org',
};
// Return early if an explicit mapping exists
if (mapping[db]) return mapping[db];
// Format language codes: replace underscores with hyphens (e.g., zh_yue -> zh-yue)
const name = db.replace(/_/g, '-');
// Handle sister project families by trimming suffixes and appending correct domains
if (name.endsWith('wikisource')) return name.slice(0, -10) + '.wikisource.org';
if (name.endsWith('wikiversity')) return name.slice(0, -11) + '.wikiversity.org';
if (name.endsWith('wiktionary')) return name.slice(0, -10) + '.wiktionary.org';
if (name.endsWith('wikivoyage')) return name.slice(0, -10) + '.wikivoyage.org';
if (name.endsWith('wikibooks')) return name.slice(0, -9) + '.wikibooks.org';
if (name.endsWith('wikiquote')) return name.slice(0, -9) + '.wikiquote.org';
if (name.endsWith('wikinews')) return name.slice(0, -8) + '.wikinews.org';
if (name.endsWith('wikimedia')) return name.slice(0, -9) + '.wikimedia.org';
// Default logic for Wikipedias: remove 'wiki' suffix and append domain
if (name.endsWith('wiki')) return name.slice(0, -4) + '.wikipedia.org';
// Generic fallback
return name + '.wikipedia.org';
}
function getInterwikiPrefix(db) {
// Mapping for special projects that use non-standard interwiki shortcuts
const specials = {
'commonswiki': 'commons', 'metawiki': 'm', 'wikidatawiki': 'd',
'mediawikiwiki': 'mw', 'specieswiki': 'wikispecies',
'foundationwiki': 'wikimedia', 'sourceswiki': 'wikisource'
};
// Return early if an explicit special prefix is defined
if (specials[db]) return specials[db];
// Format name for interwiki consistency (underscores to hyphens)
const name = db.replace(/_/g, '-');
// Logic for sister project families: trim suffixes and append the standard shorthand
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 'wikimedia:' + name.slice(0, -9);
// Standard Wikipedia logic: removes 'wiki' suffix and adds 'w:' prefix
if (name.endsWith('wiki')) return 'w:' + name.slice(0, -4);
// Default fallback for any other database patterns
return 'w:' + name;
}
// Ensure required MediaWiki API and ForeignApi modules are loaded before execution
mw.loader.using(['mediawiki.api', 'mediawiki.ForeignApi']).then(function () {
// Initialize API connection to Meta-Wiki for global user information and rights logs
const metaApi = new mw.ForeignApi('https://meta.wikimedia.org/w/api.php');
// Capture current date/time for report generation and timestamping
const now = new Date();
// State management variables:
let results = {}, // Stores aggregated CU action counts per wiki/user
emptyWikis = [], // List of projects with no recorded CU actions
failedWikis = [], // List of projects that encountered API errors
scannedWikis = [], // Tracking list of successfully processed projects
isRunning = false; // Flag to control the start/stop state of the audit
// UI state variables for filtering wikis and user roles
let currentFilterMode = 'all'; // Current wiki selection mode (all, exclude, include)
let currentUserFilter = 'all'; // Current user role filter (all, local, steward)
// Initializes and builds the User Interface (UI) elements on the page.
function setupUI() {
// Get the current year to ensure the date range is always up-to-date
const currentYear = now.getFullYear();
// Generate HTML options for the year selection (starting from 2005 to current)
let yearOpts = '';
for (let y = currentYear; y >= 2005; y--) yearOpts += `<option value="${y}">${y}</option>`;
// Prepare HTML options for month selection (1-12)
let monthOptsFrom = '';
let monthOptsTo = '';
for (let m = 1; m <= 12; m++) {
// Set default: 'From' dropdown defaults to January (1)
monthOptsFrom += `<option value="${m}" ${m === 1 ? 'selected' : ''}>${m}</option>`;
// Set default: 'To' dropdown defaults to December (12)
monthOptsTo += `<option value="${m}" ${m === 12 ? 'selected' : ''}>${m}</option>`;
}
// Set the page title to identify the script
$('#firstHeading').text('GlobalCheckUserStats.js');
// Clear the main content area and append the control panel HTML
$('#mw-content-text').empty().append(`
<div style="border:1px solid #a2a9b1; padding:15px; background:#f8f9fa;">
<h3>Stats Range</h3>
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 style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>Wiki Filter:</strong>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="wiki-mode" id="btn-all" checked> All wikis</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="wiki-mode" id="btn-except"> All wikis except</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><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; font-size:12px; line-height:18px;" title="Show all wiki DB names">?</span>
</div>
<div id="filter-input-container" style="display:none; margin-top:10px;">
<input id="wiki-filter" type="text" style="width:100%; max-width:600px;" placeholder="">
</div>
<div style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>User Filter:</strong>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-all" checked> All users</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-local"> Only Local CUs</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-steward"> Only Stewards</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; color:#202122;">
<strong>Available Wikis (alphabetical):</strong><br>${[...rawWikis].sort().join(', ')}
</div>
<div style="margin-top:20px;">
<button id="start" class="mw-ui-button mw-ui-progressive">Run GlobalCheckUserStats.js</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="${rawWikis.length}" style="width:100%"></progress></div>
<textarea id="out" style="width:100%; height:450px; margin-top:10px; display:none; font-family:monospace; font-size:12px;"></textarea>
</div>
`);
// Handle clicking 'All wikis': resets filter mode and hides the input field
$('#btn-all').click(() => { currentFilterMode = 'all'; $('#filter-input-container').hide(); });
// Handle clicking 'All wikis except': shows input field, sets mode to 'exclude', and focuses the box
$('#btn-except').click(() => { currentFilterMode = 'exclude'; $('#filter-input-container').show(); $('#wiki-filter').attr('placeholder', 'Exclude (dbname1, dbname2)...').focus(); });
// Handle clicking 'Only these wikis': shows input field, sets mode to 'include', and focuses the box
$('#btn-only').click(() => { currentFilterMode = 'include'; $('#filter-input-container').show(); $('#wiki-filter').attr('placeholder', 'Only (dbname1, dbname2)...').focus(); });
// User role filter buttons: define which user categories will appear in the final table
$('#u-all').click(() => { currentUserFilter = 'all'; });
$('#u-local').click(() => { currentUserFilter = 'local'; });
$('#u-steward').click(() => { currentUserFilter = 'steward'; });
// Toggle visibility of the 'Available Wikis' help list when the question mark icon is clicked
$('#wiki-help-trigger').click(() => $('#wiki-list-help').toggle());
// Main execution trigger: starts the audit using the selected date range from dropdowns
$('#start').click(() => runAudit($('#y-f').val(), $('#m-f').val(), $('#y-t').val(), $('#m-t').val()));
// Stop button: sets isRunning to false to halt the loop between wiki scans
$('#stop').click(() => isRunning = false);
}
// Fetches general statistics for a specific wiki, primarily to get the active user count.
async function fetchWikiMetrics(db) {
try {
const api = new mw.ForeignApi(`https://${getDomain(db)}/w/api.php`);
// Request site statistics via the 'siteinfo' meta module
const res = await robustCall(api, {
action: 'query', meta: 'siteinfo', siprop: 'statistics', formatversion: 2
});
// Extract the active users count; default to 0 if the data is missing
return { active: res.query.statistics.activeusers || 0 };
// Graceful fallback: return zero if the project is unreachable or API fails
} catch (e) { return { active: 0 }; }
}
// Checks if a user held global roles (Steward, Staff, Ombuds) during the audit period.
async function checkGlobalHistory(user, start, end) {
try {
// Fetch global rights logs from Meta-Wiki for the specific user
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), auditEnd = new Date(end);
let state = { steward: false, staff: false, ombuds: false };
// Look at the last log entry BEFORE the audit started to see the initial status
const priorLogs = logs.filter(l => new Date(l.timestamp) <= auditStart).sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
if (priorLogs.length > 0) {
const p = priorLogs[0].params || {};
const groupsInLog = p.newGroups || p[1] || [];
state.steward = groupsInLog.includes('steward');
state.staff = groupsInLog.includes('staff');
state.ombuds = groupsInLog.includes('ombuds');
}
// Keep track of any global roles gained during the audit period itself
let held = { wasSteward: state.steward, wasStaff: state.staff, wasOmbuds: state.ombuds };
logs.filter(l => { const ts = new Date(l.timestamp); return ts > auditStart && ts < auditEnd; }).forEach(e => {
const p = e.params || {};
const added = p.newGroups || p[1] || [];
if (added.includes('steward')) held.wasSteward = true;
if (added.includes('staff')) held.wasStaff = true;
if (added.includes('ombuds')) held.wasOmbuds = true;
});
return held;
} catch (e) {
// If anything fails, assume no global roles were held
return { wasSteward: false, wasStaff: false, wasOmbuds: false }; }
}
// Gathers all necessary data for a specific user on a specific wiki.
async function fetchUserData(user, db, start, end) {
// Convert start and end strings into Date objects for easier comparison
const auditStart = new Date(start), auditEnd = new Date(end);
// 1. FETCH CURRENT GLOBAL GROUPS (Using cache to save time)
if (!userCache[user]) {
try {
// Ask Meta-Wiki for the user's current global groups (steward, staff, etc.)
const gres = await robustCall(metaApi, { action: 'query', meta: 'globaluserinfo', guiprop: 'groups', guiuser: user, formatversion: 2 });
userCache[user] = gres.query.globaluserinfo.groups || [];
// Fallback to an empty list if the request fails
} catch (e) { userCache[user] = []; }
}
// Check if the user currently holds any key global roles
const gGroups = userCache[user];
const isGloballySteward = gGroups.includes('steward');
const isGloballyStaff = gGroups.includes('staff');
const isGloballyOmbuds = gGroups.includes('ombuds') || gGroups.includes('ombudsman');
// 2. FETCH GLOBAL HISTORY (Identify roles held during the period but lost since)
if (!historyCache[user]) historyCache[user] = await checkGlobalHistory(user, start, end);
const historyRes = historyCache[user];
// 3. CHECK CURRENT LOCAL CHECKUSER STATUS
let isCurrentLocal = false;
try {
// Connect to the local wiki to see if the user is currently a CheckUser
const localApi = new mw.ForeignApi(`https://${getDomain(db)}/w/api.php`);
const ures = await robustCall(localApi, { action: 'query', list: 'users', ususers: user, usprop: 'groups', formatversion: 2 });
const lGroups = (ures.query.users[0] && ures.query.users[0].groups) || [];
isCurrentLocal = lGroups.includes('checkuser');
} catch (e) {
// Ignore errors; defaults to false
}
// Define the target string for rights log searching
const target = 'User:' + user + '@' + db;
// Initialize variables for log analysis and role calculation
let logText = '',
addTime = null,
removeTime = null,
isSelfAssign = false,
maxDurationMins = -1,
longestTimeStr = "",
assignCount = 0,
hasLocalRightsInPeriod = false;
// 4. DEEP RIGHTS LOG SCAN (Pagination to bypass 500-limit)
let events = [];
let continueToken = null;
let finished = false;
try {
// Loop through all log pages until we reach the end of history
while (!finished) {
let params = {
action: 'query', list: 'logevents', letype: 'rights',
letitle: target, ledir: 'older', lelimit: 'max', formatversion: 2
};
if (continueToken) Object.assign(params, continueToken);
const res = await robustCall(metaApi, params);
const batch = res.query.logevents || [];
events = events.concat(batch);
// If the API says there is more data, keep going; otherwise, stop
if (res.continue) continueToken = res.continue;
else finished = true;
}
let logEntries = [];
let pendingRemoved = null;
let lastPairedDate = null; // Track last processed ADD to avoid duplicates - e.g., when a Steward self-assigns CU and then updates the duration/reason.
// Process each log event to track when CheckUser rights were granted or removed
for (let i = 0; i < events.length; i++) {
const e = events[i], p = e.params || {};
// Detect if CheckUser group was added or removed in this log entry
let cuAdded = (p.add || []).includes('checkuser') || ((p.newgroups || []).includes('checkuser') && !(p.oldgroups || []).includes('checkuser'));
const cuRemoved = (p.remove || []).includes('checkuser') || (!(p.newgroups || []).includes('checkuser') && (p.oldgroups || []).includes('checkuser'));
// If rights were just updated (e.g. expiry changed), count it as an ADD
const cuModified = (p.newgroups || []).includes('checkuser') && (p.oldgroups || []).includes('checkuser');
if (cuModified) cuAdded = true;
const eventDate = new Date(e.timestamp);
const exactTime = e.timestamp.replace('T', ' ').replace('Z', '');
if (cuAdded) addTime = eventDate;
if (cuRemoved) removeTime = eventDate;
// Check if the user had rights at any point during the selected audit period
if (addTime && addTime <= auditEnd && (!removeTime || removeTime >= auditStart)) hasLocalRightsInPeriod = true;
// Check if the event is within our selected date range
const isInPeriod = (eventDate >= auditStart && eventDate <= auditEnd);
// Logic: Process if inside period OR if we are looking for a pair to a removal found earlier
if ((isInPeriod && (cuAdded || cuRemoved)) || (pendingRemoved && cuAdded)) {
let expiryDate = null;
// Check for temporary access expiry dates
if (p.newmetadata) {
const cuMeta = p.newmetadata.find(m => m.group === 'checkuser');
if (cuMeta && cuMeta.expiry && cuMeta.expiry !== 'infinity') expiryDate = new Date(cuMeta.expiry);
}
const isSelf = (e.user === user || !e.user) ? " (Self-assign)" : "";
if (e.user === user || !e.user) isSelfAssign = true;
if (cuAdded) {
// Match an ADD event with a previously found REMOVE event to calculate duration
if (pendingRemoved) {
const diffMins = Math.round(Math.abs(pendingRemoved.date - eventDate) / 60000);
const d = Math.floor(diffMins / 1440), h = Math.floor((diffMins % 1440) / 60), m = diffMins % 60;
const durStr = `${d > 0 ? d+'d ' : ''}${h > 0 ? h+'h ' : ''}${m}m`;
logEntries.unshift(`* ADDED: ${exactTime} | REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf} (Duration: ${durStr})`);
if (diffMins > maxDurationMins) { maxDurationMins = diffMins; longestTimeStr = durStr; }
assignCount++;
pendingRemoved = null;
lastPairedDate = eventDate;
}
// If the ADD has its own expiry metadata, use that for duration
else if (expiryDate) {
const diffMins = Math.round(Math.abs(expiryDate - eventDate) / 60000);
const d = Math.floor(diffMins / 1440), h = Math.floor((diffMins % 1440) / 60), m = diffMins % 60;
const durStr = `${d > 0 ? d+'d ' : ''}${h > 0 ? h+'h ' : ''}${m}m`;
const exactExpiryTime = expiryDate.toISOString().replace('T', ' ').substring(0, 19);
logEntries.unshift(`* ADDED: ${exactTime} | EXPIRED: ${exactExpiryTime} by ${e.user}${isSelf} (Duration: ${durStr})`);
if (diffMins > maxDurationMins) { maxDurationMins = diffMins; longestTimeStr = durStr; }
assignCount++;
lastPairedDate = eventDate;
}
// Otherwise, it's an active or indefinite assignment
else {
if (lastPairedDate && Math.abs(lastPairedDate - eventDate) < 86400000) {
// Ignore duplicate entry from the same session
} else {
logEntries.unshift(`* ADDED: ${exactTime} | REMOVED: (Active/Not removed) by ${e.user}${isSelf}`);
lastPairedDate = eventDate;
}
}
}
else if (cuRemoved) {
// If we find a REMOVE event, save it to pair with the next ADD event found
if (pendingRemoved) logEntries.unshift(`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf}`);
pendingRemoved = { time: exactTime, date: eventDate, user: e.user, isSelf: isSelf };
}
}
}
// Cleanup: handle any remaining REMOVE events that didn't have a matching ADD
if (pendingRemoved) logEntries.unshift(`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf}`);
if (logEntries.length) logText = '\n' + logEntries.join('\n');
// 5. ROLE CLASSIFICATION
let roleLabel = "";
// Check if user has any logged actions or held rights during the period
const hasActions = (results[db] && results[db][user] && results[db][user].total > 0);
let wasCU = hasLocalRightsInPeriod || hasActions;
// Determine the most relevant role for the user
if (isGloballyStaff || historyRes.wasStaff) {
// Priority 1: WMF Staff status
roleLabel = isGloballyStaff ? "Current Staff" : "Former Staff (in period)";
}
else if (isCurrentLocal) {
// Priority 2: Currently active local CheckUser
roleLabel = "Current Local CheckUser";
}
else if (longestTimeStr && isSelfAssign && (isGloballySteward || historyRes.wasSteward)) {
// Priority 3: Steward acting temporarily (Self-assign detection)
roleLabel = `Steward action (Self-assign: ${assignCount > 1 ? assignCount + 'x, longest ' : ''}${longestTimeStr})`;
}
else if (isGloballySteward || historyRes.wasSteward) {
// Priority 4: Standard Steward status
roleLabel = isGloballySteward ? "Current Steward" : "Former Steward (in period)";
}
else if (isGloballyOmbuds || historyRes.wasOmbuds) {
// Priority 5: Ombudsman commission status
roleLabel = isGloballyOmbuds ? "Current Ombudsman" : "Former Ombudsman (in period)";
}
else if (wasCU) {
// Priority 6: User who was a local CU but no longer is
roleLabel = "Former Local CheckUser (in period)";
}
else {
// Default if no specific role matches
roleLabel = "Unknown role";
}
// Return the calculated role and the formatted log entries
return { role: roleLabel, log: logText };
} catch (err) {
// Return an error state if anything crashes during user data processing
return { role: "Error fetching data", log: "" };
}
}
// The main engine that iterates through wikis and collects CheckUser logs.
async function runAudit(yf, mf, yt, mt) {
// Reset all data and state variables for a fresh run
isRunning = true;
userCache = {};
historyCache = {};
results = {};
emptyWikis = [];
failedWikis = [];
scannedWikis = [];
// Parse and prepare the wiki filter list from the UI input
const filterText = $('#wiki-filter').val().trim().toLowerCase();
const filterList = filterText ? filterText.split(',').map(s => s.trim()).filter(s => s !== "") : [];
let wikisToScan = rawWikis;
// Apply include/exclude logic to the master wiki list
if (currentFilterMode === 'include') wikisToScan = rawWikis.filter(w => filterList.includes(w));
else if (currentFilterMode === 'exclude') wikisToScan = rawWikis.filter(w => !filterList.includes(w));
if (wikisToScan.length === 0) { alert("No wikis selected!"); return; }
// Update UI buttons and progress bar to "running" state
$('#start').prop('disabled', true); $('#stop').prop('disabled', false); $('#out').hide();
$('#bar').attr('max', wikisToScan.length).val(0);
// Format the date strings into ISO format 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 columns for the table based on the selected month range
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++; }
}
// Loop through each selected wiki and fetch its logs
for (let i = 0; i < wikisToScan.length; i++) {
if (!isRunning) break; // Exit loop if the "Stop" button was clicked
const db = wikisToScan[i]; scannedWikis.push(db);
$('#status-msg').text(`Scanning ${db} (${i + 1}/${wikisToScan.length})...`);
let successLocal = false, continueToken = null;
// Inner loop for handling pagination of the logs on a single wiki
while (!successLocal && isRunning) {
try {
const api = new mw.ForeignApi(`https://${getDomain(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);
// Process each entry in the log batch
const ent = res.query?.checkuserlog?.entries || [];
if (ent.length) {
if (!results[db]) results[db] = {};
ent.forEach(e => {
const u = e.checkuser;
if (!results[db][u]) results[db][u] = { total: 0, months: {} };
const mKey = e.timestamp.slice(0, 7);
results[db][u].total++; results[db][u].months[mKey] = (results[db][u].months[mKey] || 0) + 1;
});
}
// Check if there are more log pages to fetch
if (res.continue && isRunning) continueToken = res.continue;
else { if (!results[db]) emptyWikis.push(db); successLocal = true; }
} catch (err) {
// Record failures (like 429 after retries or 404) and move to the next wiki
failedWikis.push(db);
successLocal = true;
}
}
// Update the progress bar and wait a bit to be kind to the API
$('#bar').val(i + 1); await sleep(DELAY_MS);
}
// Update UI and prepare the Wikitable header and metadata
$('#status-msg').text(`Generating report...`);
const today = new Date().toISOString().split('T')[0];
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' (UTC)';
// Start building the Wikitext report
let wt = `== Global CheckUser Stats (${mf}/${yf} - ${mt}/${yt}) ==\n''Report generated on: ${timestamp}''\n\n`;
let rightsLog = `\n== Rights Log (In Period) ==\n`;
// Create table structure with dynamic month columns
wt += `{| class="wikitable sortable" style="font-size:90%; text-align:right;"\n! Wiki / User !! Category !! '''Total''' !! per 1k active users ${monthCols.map(m => `!! ${m.label}`).join(' ')}\n`;
// Sort databases alphabetically and begin processing each project's results
const sortedDBs = Object.keys(results).sort();
for (const db of sortedDBs) {
// Get active user count; if API fails or is 0, display 'Unknown'
const metrics = await fetchWikiMetrics(db);
const fmtActive = metrics.active > 0 ? metrics.active.toLocaleString('en-US') : 'Unknown';
// Prepare interwiki links for the project and its CheckUser log
const iwPrefix = getInterwikiPrefix(db);
const projectLink = `[[${iwPrefix}:|${db}]]`;
const logLink = `[[${iwPrefix}:Special:CheckUserLog|(log)]]`;
wt += `|-\n! colspan="${4 + monthCols.length}" style="background:#eaecf0; text-align:center;" | ${projectLink} <small style="font-weight:normal; color:#54595d;">(Active Users as of ${today}: ${fmtActive})</small> — ${logLink}\n`;
// Initialize local wiki totals and storage for individual user rows
let wikiRows = [],
wT = 0,
wMS = {};
monthCols.forEach(col => wMS[col.key] = 0);
const projectUsers = Object.keys(results[db]).sort();
for (const user of projectUsers) {
// Get role and rights log details for each user
const m = await fetchUserData(user, db, START, END);
// Identify if user belongs to specific global or local groups
const isSteward = m.role.includes("Steward") || m.role.includes("Staff") || m.role.includes("Ombudsman");
const isLocalCU = m.role.includes("Local CheckUser");
// Filter out users if they don't match the selected User Filter (Local/Steward)
if (currentUserFilter === 'local' && !isLocalCU) continue;
if (currentUserFilter === 'steward' && !isSteward) continue;
const uD = results[db][user];
// Aggregate totals for the current wiki
wT += uD.total; monthCols.forEach(col => wMS[col.key] += (uD.months[col.key] || 0));
// Calculate actions per 1k active users; display 0.0 if project activity is unknown
let uP1kU = metrics.active > 0 ? ((uD.total / metrics.active) * 1000).toFixed(1) : "0.0";
// Build the Wikitext row for the individual user
let row = `|-\n| style="text-align:left;" | ${user}@${db} || <small>${m.role}</small> || '''${uD.total}''' || ${uP1kU}`;
monthCols.forEach(col => row += ` || ${uD.months[col.key] || 0}`);
// Store the formatted row and its corresponding rights log
wikiRows.push({ html: row + "\n", log: m.log ? `'''${user}@${db}''':${m.log}\n\n` : "" });
}
// Create the bold TOTAL row for the current wiki
let wP1kU = metrics.active > 0 ? ((wT / metrics.active) * 1000).toFixed(1) : "0.0";
let totalRow = `|- style="background:#f8f9fa; font-weight:bold;"\n| style="text-align:left;" | TOTAL ${db} || — || ${wT} || ${wP1kU}`;
monthCols.forEach(col => totalRow += ` || ${wMS[col.key]}`);
// Append the total row first, then all individual user rows
wt += totalRow + "\n";
wikiRows.forEach(r => { wt += r.html; if (r.log) rightsLog += r.log; });
}
// Append a list of wikis where no CheckUser actions were found
wt += `|}\n\n== Projects with 0 actions ==\n<div style="font-size:85%; color:#54595d;">${emptyWikis.sort().join(', ')}</div>\n`;
// Append a list of projects that failed to scan (e.g., due to API errors)
if (failedWikis.length > 0) {
wt += `\n== API Errors / Skipped Projects ==\n<div style="font-size:85%; color:#d33;">${failedWikis.sort().join(', ')}</div>\n`;
}
// Finalize the report with the Rights Log section
wt += rightsLog + `\n\n<references />\n`;
// Display the final output and reset UI buttons
$('#out').val(wt).show();
$('#status-msg').text(`Done.`); $('#start').prop('disabled', false); $('#stop').prop('disabled', true);
}
// Initialize the interface on page load
setupUI();
});
})();
dbgvia2rjp8egoz7prhrs1wnsgjb8dy
737759
737758
2026-04-12T16:51:37Z
MrJaroslavik
44012
e
737759
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]].
// - Full Reporting: Outputs sortable Wikitables and detailed rights change logs.
// - With help of Gemini 3
// -------------------------------------------------------
// -------------------------------------------------------
// Wrap everything so this script doesn't break other Wikipedia scripts
(function () {
'use strict';
// Stop immediately if we are NOT on the specific GlobalCheckUserStats page
if (mw.config.get('wgCanonicalSpecialPageName') !== 'Blankpage' || mw.config.get('wgTitle').indexOf('GlobalCheckUserStats') === -1) return;
// A fixed list of all valid Wikimedia projects to scan
const rawWikis = ['abstractwiki', 'abwiki', 'acewiki', 'adywiki', 'afwiki', 'afwikibooks', 'afwikiquote', 'afwiktionary', 'alswiki', 'altwiki', 'amiwiki', 'amwiki', 'amwiktionary', 'angwiki', 'angwiktionary', 'annwiki', 'anpwiki', 'anwiki', 'anwiktionary', 'arcwiki', 'arwiki', 'arwikibooks', 'arwikimedia', 'arwikinews', 'arwikiquote', 'arwikisource', 'arwikiversity', 'arwiktionary', 'arywiki', 'arzwiki', 'astwiki', 'astwiktionary', 'aswiki', 'aswikiquote', 'aswikisource', 'atjwiki', 'avkwiki', 'avwiki', 'awawiki', 'aywiki', 'aywiktionary', 'azbwiki', 'azwiki', 'azwikibooks', 'azwikiquote', 'azwikisource', 'azwiktionary', 'banwiki', 'banwikisource', 'barwiki', 'bat_smgwiki', 'bawiki', 'bawikibooks', 'bbcwiki', 'bclwiki', 'bclwikiquote', 'bclwikisource', 'bclwiktionary', 'bdrwiki', 'bdwikimedia', 'be_x_oldwiki', 'betawikiversity', 'bewiki', 'bewikibooks', 'bewikimedia', 'bewikiquote', 'bewikisource', 'bewiktionary', 'bewwiki', 'bewwiktionary', 'bgwiki', 'bgwikibooks', 'bgwikiquote', 'bgwikisource', 'bgwiktionary', 'bhwiki', 'biwiki', 'bjnwiki', 'bjnwikiquote', 'bjnwiktionary', 'blkwiki', 'blkwiktionary', 'bmwiki', 'bnwiki', 'bnwikibooks', 'bnwikiquote', 'bnwikisource', 'bnwikivoyage', 'bnwiktionary', 'bowiki', 'bpywiki', 'brwiki', 'brwikimedia', 'brwikiquote', 'brwikisource', 'brwiktionary', 'bswiki', 'bswikibooks', 'bswikinews', 'bswikiquote', 'bswikisource', 'bswiktionary', 'btmwiki', 'btmwiktionary', 'bugwiki', 'bxrwiki', 'cawiki', 'cawikibooks', 'cawikimedia', 'cawikinews', 'cawikiquote', 'cawikisource', 'cawiktionary', 'cbk_zamwiki', 'cdowiki', 'cebwiki', 'cewiki', 'chrwiki', 'chrwiktionary', 'chwiki', 'chywiki', 'ckbwiki', 'ckbwiktionary', 'commonswiki', 'cowiki', 'cowikimedia', 'cowiktionary', 'crhwiki', 'csbwiki', 'csbwiktionary', 'cswiki', 'cswikibooks', 'cswikinews', 'cswikiquote', 'cswikisource', 'cswikiversity', 'cswikivoyage', 'cswiktionary', 'cuwiki', 'cvwiki', 'cvwikibooks', 'cywiki', 'cywikibooks', 'cywikiquote', 'cywikisource', 'cywiktionary', 'dagwiki', 'dawiki', 'dawikibooks', 'dawikiquote', 'dawikisource', 'dawiktionary', 'dewiki', 'dewikibooks', 'dewikinews', 'dewikiquote', 'dewikisource', 'dewikiversity', 'dewikivoyage', 'dewiktionary', 'dgawiki', 'dinwiki', 'diqwiki', 'diqwiktionary', 'dkwikimedia', 'dsbwiki', 'dtpwiki', 'dtywiki', 'dvwiki', 'dvwiktionary', 'dzwiki', 'eewiki', 'elwiki', 'elwikibooks', 'elwikinews', 'elwikiquote', 'elwikisource', 'elwikiversity', 'elwikivoyage', 'elwiktionary', 'emlwiki', 'enwiki', 'enwikibooks', 'enwikinews', 'enwikiquote', 'enwikisource', 'enwikiversity', 'enwikivoyage', 'enwiktionary', 'eowiki', 'eowikibooks', 'eowikinews', 'eowikiquote', 'eowikisource', 'eowikivoyage', 'eowiktionary', 'eswiki', 'eswikibooks', 'eswikinews', 'eswikiquote', 'eswikisource', 'eswikiversity', 'eswikivoyage', 'eswiktionary', 'etwiki', 'etwikibooks', 'eewikimedia', 'etwikiquote', 'etwikisource', 'etwiktionary', 'euwiki', 'euwikibooks', 'euwikiquote', 'euwikisource', 'euwiktionary', 'extwiki', 'fatwiki', 'fawiki', 'fawikibooks', 'fawikinews', 'fawikiquote', 'fawikisource', 'fawikivoyage', 'fawiktionary', 'ffwiki', 'fiu_vrowiki', 'fiwiki', 'fiwikibooks', 'fiwikimedia', 'fiwikinews', 'fiwikiquote', 'fiwikisource', 'fiwikiversity', 'fiwikivoyage', 'fiwiktionary', 'fjwiki', 'fjwiktionary', 'fonwiki', 'foundationwiki', 'fowiki', 'fowikisource', 'fowiktionary', 'frpwiki', 'frrwiki', 'frwiki', 'frwikibooks', 'frwikinews', 'frwikiquote', 'frwikisource', 'frwikiversity', 'frwikivoyage', 'frwiktionary', 'furwiki', 'fywiki', 'fywikibooks', 'fywiktionary', 'gagwiki', 'ganwiki', 'gawiki', 'gawiktionary', 'gcrwiki', 'gdwiki', 'gdwiktionary', 'glkwiki', 'glwiki', 'glwikibooks', 'glwikiquote', 'glwikisource', 'glwiktionary', 'gnwiki', 'gnwiktionary', 'gomwiki', 'gomwiktionary', 'gorwiki', 'gorwikiquote', 'gorwiktionary', 'gotwiki', 'gpewiki', 'gucwiki', 'gurwiki', 'guwiki', 'guwikiquote', 'guwikisource', 'guwiktionary', 'guwwiki', 'guwwikinews', 'guwwikiquote', 'guwwiktionary', 'gvwiki', 'gvwiktionary', 'hakwiki', 'hawiki', 'hawiktionary', 'hawwiki', 'hewiki', 'hewikibooks', 'hewikinews', 'hewikiquote', 'hewikisource', 'hewikivoyage', 'hewiktionary', 'hifwiki', 'hifwiktionary', 'hiwiki', 'hiwikibooks', 'hiwikiquote', 'hiwikisource', 'hiwikiversity', 'hiwikivoyage', 'hiwiktionary', 'hrwiki', 'hrwikibooks', 'hrwikiquote', 'hrwikisource', 'hrwiktionary', 'hsbwiki', 'hsbwiktionary', 'htwiki', 'huwiki', 'huwikibooks', 'huwikisource', 'huwiktionary', 'hywiki', 'hywikibooks', 'hywikiquote', 'hywikisource', 'hywiktionary', 'hywwiki', 'iawiki', 'iawikibooks', 'iawiktionary', 'ibawiki', 'idwiki', 'idwikibooks', 'idwikiquote', 'idwikisource', 'idwikivoyage', 'idwiktionary', 'iewiki', 'iewiktionary', 'iglwiki', 'igwiki', 'igwikiquote', 'igwiktionary', 'ikwiki', 'ilowiki', 'incubatorwiki', 'inhwiki', 'iowiki', 'iowiktionary', 'iswiki', 'iswikibooks', 'iswikiquote', 'iswikisource', 'iswiktionary', 'itwiki', 'itwikibooks', 'itwikinews', 'itwikiquote', 'itwikisource', 'itwikiversity', 'itwikivoyage', 'itwiktionary', 'iuwiki', 'iuwiktionary', 'jamwiki', 'jawiki', 'jawikibooks', 'jawikinews', 'jawikisource', 'jawikiversity', 'jawikivoyage', 'jawiktionary', 'jbowiki', 'jbowiktionary', 'jvwiki', 'jvwikisource', 'jvwiktionary', 'kaawiki', 'kaawiktionary', 'kabwiki', 'kaiwiki', 'kajwiki', 'kawiki', 'kawikibooks', 'kawikiquote', 'kawikisource', 'kawiktionary', 'kbdwiki', 'kbdwiktionary', 'kbpwiki', 'kcgwiki', 'kcgwiktionary', 'kgewiki', 'kgwiki', 'kiwiki', 'kkwiki', 'kkwikibooks', 'kkwiktionary', 'klwiktionary', 'kmwiki', 'kmwikibooks', 'kmwiktionary', 'kncwiki', 'knwiki', 'knwikiquote', 'knwikisource', 'knwiktionary', 'koiwiki', 'kowiki', 'kowikibooks', 'kowikinews', 'kowikiquote', 'kowikisource', 'kowikiversity', 'kowiktionary', 'krcwiki', 'kshwiki', 'kswiki', 'kswiktionary', 'kuswiki', 'kuwiki', 'kuwikibooks', 'kuwikiquote', 'kuwiktionary', 'kvwiki', 'kwwiki', 'kwwiktionary', 'kywiki', 'kywikiquote', 'kywiktionary', 'labswiki', 'ladwiki', 'lawiki', 'lawikibooks', 'lawikiquote', 'lawikisource', 'lawiktionary', 'lbewiki', 'lbwiki', 'lbwiktionary', 'lezwiki', 'lfnwiki', 'lgwiki', 'lijwiki', 'lijwikisource', 'liwiki', 'liwikibooks', 'liwikinews', 'liwikiquote', 'liwikisource', 'liwiktionary', 'lldwiki', 'lmowiki', 'lmowiktionary', 'lnwiki', 'lnwiktionary', 'lowiki', 'lowiktionary', 'ltgwiki', 'ltwiki', 'ltwikibooks', 'ltwikiquote', 'ltwikisource', 'ltwiktionary', 'lvwiki', 'lvwiktionary', 'madwiki', 'madwikisource', 'madwiktionary', 'maiwiki', 'map_bmswiki', 'mdfwiki', 'mediawikiwiki', 'metawiki', 'mgwiki', 'mgwikibooks', 'mgwiktionary', 'mhrwiki', 'minwiki', 'minwikibooks', 'minwikisource', 'minwiktionary', 'miwiki', 'miwiktionary', 'mkwiki', 'mkwikibooks', 'mkwikimedia', 'mkwikisource', 'mkwiktionary', 'mlwiki', 'mlwikibooks', 'mlwikiquote', 'mlwikisource', 'mlwiktionary', 'mniwiki', 'mniwiktionary', 'mnwiki', 'mnwiktionary', 'mnwwiki', 'mnwwiktionary', 'moswiki', 'mrjwiki', 'mrwiki', 'mrwikibooks', 'mrwikiquote', 'mrwikisource', 'mrwiktionary', 'mswiki', 'mswikibooks', 'mswikiquote', 'mswikisource', 'mswiktionary', 'mtwiki', 'mtwiktionary', 'mwlwiki', 'mxwikimedia', 'myvwiki', 'mywiki', 'mywikisource', 'mywiktionary', 'mznwiki', 'nahwiki', 'nahwiktionary', 'napwiki', 'napwikisource', 'nawiktionary', 'nds_nlwiki', 'ndswiki', 'ndswiktionary', 'newiki', 'newikibooks', 'newiktionary', 'newwiki', 'niawiki', 'niawiktionary', 'nlwiki', 'nlwikibooks', 'nlwikimedia', 'nlwikinews', 'nlwikiquote', 'nlwikisource', 'nlwikivoyage', 'nlwiktionary', 'nnwiki', 'nnwikiquote', 'nnwiktionary', 'novwiki', 'nowiki', 'nowikibooks', 'nowikimedia', 'nowikinews', 'nowikiquote', 'nowikisource', 'nowiktionary', 'nqowiki', 'nrmwiki', 'nrwiki', 'nsowiki', 'nupwiki', 'nvwiki', 'nycwikimedia', 'nywiki', 'ocwiki', 'ocwikibooks', 'ocwiktionary', 'olowiki', 'omwiki', 'omwiktionary', 'orwiki', 'orwikisource', 'orwiktionary', 'oswiki', 'outreachwiki', 'pagwiki', 'pamwiki', 'papwiki', 'pawiki', 'pawikibooks', 'pawikisource', 'pawiktionary', 'pcdwiki', 'pcmwiki', 'pcmwikiquote', 'pdcwiki', 'pflwiki', 'piwiki', 'plwiki', 'plwikibooks', 'plwikimedia', 'plwikinews', 'plwikiquote', 'plwikisource', 'plwikivoyage', 'plwiktionary', 'pmswiki', 'pmswikisource', 'pnbwiki', 'pnbwiktionary', 'pntwiki', 'pplwiki', 'pswiki', 'pswikivoyage', 'pswiktionary', 'ptwiki', 'ptwikibooks', 'ptwikimedia', 'ptwikinews', 'ptwikiquote', 'ptwikisource', 'ptwikiversity', 'ptwikivoyage', 'ptwiktionary', 'pwnwiki', 'quwiktionary', 'rkiwiki', 'rmwiki', 'rmywiki', 'rnwiki', 'roa_rupwiki', 'roa_rupwiktionary', 'roa_tarawiki', 'rowiki', 'rowikibooks', 'rowikinews', 'rowikiquote', 'rowiktionary', 'rskwiki', 'ruewiki', 'ruwiki', 'ruwikibooks', 'ruwikinews', 'ruwikiquote', 'ruwikisource', 'ruwikiversity', 'ruwikivoyage', 'ruwiktionary', 'rwwiki', 'rwwiktionary', 'sahwiki', 'sahwikiquote', 'sahwikisource', 'satwiki', 'satwiktionary', 'sawiki', 'sawikibooks', 'sawikiquote', 'sawikisource', 'sawiktionary', 'scnwiki', 'scnwiktionary', 'scowiki', 'scwiki', 'sdwiki', 'sdwiktionary', 'sewiki', 'sewikimedia', 'sgwiki', 'sgwiktionary', 'shiwiki', 'shnwiki', 'shnwikibooks', 'shnwikinews', 'shnwikivoyage', 'shnwiktionary', 'shwiki', 'shwiktionary', 'shywiktionary', 'simplewiki', 'simplewiktionary', 'siwiki', 'siwikibooks', 'siwiktionary', 'skwiki', 'skrwiki', 'skrwiktionary', 'slwiki', 'slwikibooks', 'slwikiquote', 'slwikisource', 'slwikiversity', 'slwiktionary', 'smnwiki', 'smwiki', 'smwiktionary', 'snwiki', 'sourceswiki', 'sowiki', 'sowiktionary', 'specieswiki', 'sqwiki', 'sqwikibooks', 'sqwikinews', 'sqwikiquote', 'sqwiktionary', 'srnwiki', 'srwiki', 'srwikibooks', 'srwikinews', 'srwikiquote', 'srwikisource', 'srwiktionary', 'sswiki', 'sswiktionary', 'stqwiki', 'stwiki', 'stwiktionary', 'suwiki', 'suwikiquote', 'suwikisource', 'suwiktionary', 'svwiki', 'svwikibooks', 'svwikinews', 'svwikiquote', 'svwikisource', 'svwikiversity', 'svwikivoyage', 'svwiktionary', 'swwiki', 'swwiktionary', 'sylwiki', 'szlwiki', 'szywiki', 'tawiki', 'tawikibooks', 'tawikinews', 'tawikiquote', 'tawikisource', 'tawiktionary', 'taywiki', 'tcywiki', 'tcywikisource', 'tcywiktionary', 'tddwiki', 'tewiki', 'tewikibooks', 'tewikiquote', 'tewikisource', 'tewiktionary', 'tgwiki', 'tgwikibooks', 'tgwiktionary', 'thwiki', 'thwikibooks', 'thwikimedia', 'thwikiquote', 'thwikisource', 'thwiktionary', 'tigwiki', 'tiwiki', 'tiwiktionary', 'tkwiki', 'tkwiktionary', 'tlwiki', 'tlwikibooks', 'tlwikiquote', 'tlwikisource', 'tlwiktionary', 'tlywiki', 'tnwiki', 'tnwiktionary', 'tokwiki', 'towiki', 'tpiwiki', 'tpiwiktionary', 'trvwiki', 'trwiki', 'trwikibooks', 'trwikimedia', 'trwikinews', 'trwikiquote', 'trwikisource', 'trwikivoyage', 'trwiktionary', 'tswiki', 'tswiktionary', 'ttwiki', 'ttwikibooks', 'ttwikiquote', 'ttwiktionary', 'tumwiki', 'twwiki', 'twwiktionary', 'tyvwiki', 'tywiki', 'uawikimedia', 'udmwiki', 'ugwiki', 'ugwiktionary', 'ukwiki', 'ukwikibooks', 'ukwikinews', 'ukwikiquote', 'ukwikisource', 'ukwikivoyage', 'ukwiktionary', 'urwiki', 'urwikibooks', 'urwikiquote', 'urwikisource', 'urwiktionary', 'uzwiki', 'uzwikiquote', 'uzwiktionary', 'vecwiki', 'vecwikisource', 'vecwiktionary', 'vepwiki', 'vewiki', 'viwiki', 'viwikibooks', 'viwikiquote', 'viwikisource', 'viwikivoyage', 'viwiktionary', 'vlswiki', 'vowiki', 'vowiktionary', 'warwiki', 'wawiki', 'wawikisource', 'wawiktionary', 'wikidatawiki', 'wikimaniawiki', 'wowiki', 'wowiktionary', 'wuuwiki', 'xalwiki', 'xhwiki', 'xmfwiki', 'yiwiki', 'yiwikisource', 'yiwiktionary', 'yowiki', 'zawiki', 'zeawiki', 'zghwiki', 'zghwiktionary', 'zh_classicalwiki', 'zh_min_nanwiki', 'zh_min_nanwikisource', 'zh_min_nanwiktionary', 'zh_yuewiki', 'zhwiki', 'zhwikibooks', 'zhwikinews', 'zhwikiquote', 'zhwikisource', 'zhwikiversity', 'zhwikivoyage', 'zhwiktionary', 'zuwiki', 'zuwiktionary', 'test2wiki', 'testwiki'];
// Time delay between API requests to prevent rate limiting (429 errors)
const DELAY_MS = 800;
// Helper function to pause execution for a specified number of milliseconds
const sleep = ms => new Promise(r => setTimeout(r, ms));
// Cache for global user group information to avoid redundant API calls
let userCache = {};
// Cache for historical global rights changes within the audit period
let historyCache = {};
// Helper function to handle API calls with up to 3 retries on 429 errors
async function robustCall(api, params) {
let retries = 0;
const maxRetries = 3; // Maximum number of retry attempts
// Continue attempting the API request as long as we haven't hit the maximum retry limit
while (retries < maxRetries) {
try {
// Attempt to execute the API GET request
return await api.get(params);
} catch (err) {
// If the error is "429 Too Many Requests" (rate limit)
if (err?.status === 429) {
retries++;
// Get wait time from 'Retry-After' header, or use a default backoff (30s, 60s, 90s)
const retryAfter = parseInt(err.xhr?.getResponseHeader('Retry-After')) || (30 * retries);
// Update UI status message with the remaining wait time
$('#status-msg').html(`<span style="color:orange; font-weight:bold;">Rate limit hit! Resting ${retryAfter}s... (Attempt ${retries}/${maxRetries})</span>`);
// Pause execution before the next retry
await sleep(retryAfter * 1000);
} else {
// Rethrow any error that is not a rate limit (e.g., 404, 403, 500)
throw err;
}
}
}
// Fail if max retries are exceeded
throw new Error("Max retries reached");
}
// Converts a database name (like 'enwiki') into a real web address.
function getDomain(db) {
// Static mapping for projects with non-standard naming conventions
const mapping = {
'commonswiki': 'commons.wikimedia.org', 'metawiki': 'meta.wikimedia.org', 'wikidatawiki': 'www.wikidata.org',
'mediawikiwiki': 'www.mediawiki.org', 'sourceswiki': 'wikisource.org', 'foundationwiki': 'foundation.wikimedia.org',
'incubatorwiki': 'incubator.wikimedia.org', 'outreachwiki': 'outreach.wikimedia.org',
'be_x_oldwiki': 'be-tarask.wikipedia.org', 'labswiki': 'wikitech.wikimedia.org',
'wikimaniawiki': 'wikimania.wikimedia.org', 'specieswiki': 'species.wikimedia.org',
'abstractwiki': 'abstract.wikipedia.org', 'betawikiversity': 'beta.wikiversity.org',
'mo_wiki': 'ro.wikipedia.org',
'testwiki': 'test.wikipedia.org', 'test2wiki': 'test2.wikipedia.org',
'wikifunctionswiki': 'www.wikifunctions.org',
'testcommonswiki': 'test-commons.wikimedia.org',
'testwikidatawiki': 'test.wikidata.org',
};
// Return early if an explicit mapping exists
if (mapping[db]) return mapping[db];
// Format language codes: replace underscores with hyphens (e.g., zh_yue -> zh-yue)
const name = db.replace(/_/g, '-');
// Handle sister project families by trimming suffixes and appending correct domains
if (name.endsWith('wikisource')) return name.slice(0, -10) + '.wikisource.org';
if (name.endsWith('wikiversity')) return name.slice(0, -11) + '.wikiversity.org';
if (name.endsWith('wiktionary')) return name.slice(0, -10) + '.wiktionary.org';
if (name.endsWith('wikivoyage')) return name.slice(0, -10) + '.wikivoyage.org';
if (name.endsWith('wikibooks')) return name.slice(0, -9) + '.wikibooks.org';
if (name.endsWith('wikiquote')) return name.slice(0, -9) + '.wikiquote.org';
if (name.endsWith('wikinews')) return name.slice(0, -8) + '.wikinews.org';
if (name.endsWith('wikimedia')) return name.slice(0, -9) + '.wikimedia.org';
// Default logic for Wikipedias: remove 'wiki' suffix and append domain
if (name.endsWith('wiki')) return name.slice(0, -4) + '.wikipedia.org';
// Generic fallback
return name + '.wikipedia.org';
}
function getInterwikiPrefix(db) {
// Mapping for special projects that use non-standard interwiki shortcuts
const specials = {
'commonswiki': 'commons', 'metawiki': 'm', 'wikidatawiki': 'd',
'mediawikiwiki': 'mw', 'specieswiki': 'wikispecies',
'foundationwiki': 'wikimedia', 'sourceswiki': 'wikisource'
};
// Return early if an explicit special prefix is defined
if (specials[db]) return specials[db];
// Format name for interwiki consistency (underscores to hyphens)
const name = db.replace(/_/g, '-');
// Logic for sister project families: trim suffixes and append the standard shorthand
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 'wikimedia:' + name.slice(0, -9);
// Standard Wikipedia logic: removes 'wiki' suffix and adds 'w:' prefix
if (name.endsWith('wiki')) return 'w:' + name.slice(0, -4);
// Default fallback for any other database patterns
return 'w:' + name;
}
// Ensure required MediaWiki API and ForeignApi modules are loaded before execution
mw.loader.using(['mediawiki.api', 'mediawiki.ForeignApi']).then(function () {
// Initialize API connection to Meta-Wiki for global user information and rights logs
const metaApi = new mw.ForeignApi('https://meta.wikimedia.org/w/api.php');
// Capture current date/time for report generation and timestamping
const now = new Date();
// State management variables:
let results = {}, // Stores aggregated CU action counts per wiki/user
emptyWikis = [], // List of projects with no recorded CU actions
failedWikis = [], // List of projects that encountered API errors
scannedWikis = [], // Tracking list of successfully processed projects
isRunning = false; // Flag to control the start/stop state of the audit
// UI state variables for filtering wikis and user roles
let currentFilterMode = 'all'; // Current wiki selection mode (all, exclude, include)
let currentUserFilter = 'all'; // Current user role filter (all, local, steward)
// Initializes and builds the User Interface (UI) elements on the page.
function setupUI() {
// Get the current year to ensure the date range is always up-to-date
const currentYear = now.getFullYear();
// Generate HTML options for the year selection (starting from 2005 to current)
let yearOpts = '';
for (let y = currentYear; y >= 2005; y--) yearOpts += `<option value="${y}">${y}</option>`;
// Prepare HTML options for month selection (1-12)
let monthOptsFrom = '';
let monthOptsTo = '';
for (let m = 1; m <= 12; m++) {
// Set default: 'From' dropdown defaults to January (1)
monthOptsFrom += `<option value="${m}" ${m === 1 ? 'selected' : ''}>${m}</option>`;
// Set default: 'To' dropdown defaults to December (12)
monthOptsTo += `<option value="${m}" ${m === 12 ? 'selected' : ''}>${m}</option>`;
}
// Set the page title to identify the script
$('#firstHeading').text('GlobalCheckUserStats.js');
// Clear the main content area and append the control panel HTML
$('#mw-content-text').empty().append(`
<div style="border:1px solid #a2a9b1; padding:15px; background:#f8f9fa;">
<h3>Stats Range</h3>
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 style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>Wiki Filter:</strong>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="wiki-mode" id="btn-all" checked> All wikis</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="wiki-mode" id="btn-except"> All wikis except</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><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; font-size:12px; line-height:18px;" title="Show all wiki DB names">?</span>
</div>
<div id="filter-input-container" style="display:none; margin-top:10px;">
<input id="wiki-filter" type="text" style="width:100%; max-width:600px;" placeholder="">
</div>
<div style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>User Filter:</strong>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-all" checked> All users</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-local"> Only Local CUs</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-steward"> Only Stewards</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; color:#202122;">
<strong>Available Wikis (alphabetical):</strong><br>${[...rawWikis].sort().join(', ')}
</div>
<div style="margin-top:20px;">
<button id="start" class="mw-ui-button mw-ui-progressive">Run GlobalCheckUserStats.js</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="${rawWikis.length}" style="width:100%"></progress></div>
<textarea id="out" style="width:100%; height:450px; margin-top:10px; display:none; font-family:monospace; font-size:12px;"></textarea>
</div>
`);
// Handle clicking 'All wikis': resets filter mode and hides the input field
$('#btn-all').click(() => { currentFilterMode = 'all'; $('#filter-input-container').hide(); });
// Handle clicking 'All wikis except': shows input field, sets mode to 'exclude', and focuses the box
$('#btn-except').click(() => { currentFilterMode = 'exclude'; $('#filter-input-container').show(); $('#wiki-filter').attr('placeholder', 'Exclude (dbname1, dbname2)...').focus(); });
// Handle clicking 'Only these wikis': shows input field, sets mode to 'include', and focuses the box
$('#btn-only').click(() => { currentFilterMode = 'include'; $('#filter-input-container').show(); $('#wiki-filter').attr('placeholder', 'Only (dbname1, dbname2)...').focus(); });
// User role filter buttons: define which user categories will appear in the final table
$('#u-all').click(() => { currentUserFilter = 'all'; });
$('#u-local').click(() => { currentUserFilter = 'local'; });
$('#u-steward').click(() => { currentUserFilter = 'steward'; });
// Toggle visibility of the 'Available Wikis' help list when the question mark icon is clicked
$('#wiki-help-trigger').click(() => $('#wiki-list-help').toggle());
// Main execution trigger: starts the audit using the selected date range from dropdowns
$('#start').click(() => runAudit($('#y-f').val(), $('#m-f').val(), $('#y-t').val(), $('#m-t').val()));
// Stop button: sets isRunning to false to halt the loop between wiki scans
$('#stop').click(() => isRunning = false);
}
// Fetches general statistics for a specific wiki, primarily to get the active user count.
async function fetchWikiMetrics(db) {
try {
const api = new mw.ForeignApi(`https://${getDomain(db)}/w/api.php`);
// Request site statistics via the 'siteinfo' meta module
const res = await robustCall(api, {
action: 'query', meta: 'siteinfo', siprop: 'statistics', formatversion: 2
});
// Extract the active users count; default to 0 if the data is missing
return { active: res.query.statistics.activeusers || 0 };
// Graceful fallback: return zero if the project is unreachable or API fails
} catch (e) { return { active: 0 }; }
}
// Checks if a user held global roles (Steward, Staff, Ombuds) during the audit period.
async function checkGlobalHistory(user, start, end) {
try {
// Fetch global rights logs from Meta-Wiki for the specific user
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), auditEnd = new Date(end);
let state = { steward: false, staff: false, ombuds: false };
// Look at the last log entry BEFORE the audit started to see the initial status
const priorLogs = logs.filter(l => new Date(l.timestamp) <= auditStart).sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
if (priorLogs.length > 0) {
const p = priorLogs[0].params || {};
const groupsInLog = p.newGroups || p[1] || [];
state.steward = groupsInLog.includes('steward');
state.staff = groupsInLog.includes('staff');
state.ombuds = groupsInLog.includes('ombuds');
}
// Keep track of any global roles gained during the audit period itself
let held = { wasSteward: state.steward, wasStaff: state.staff, wasOmbuds: state.ombuds };
logs.filter(l => { const ts = new Date(l.timestamp); return ts > auditStart && ts < auditEnd; }).forEach(e => {
const p = e.params || {};
const added = p.newGroups || p[1] || [];
if (added.includes('steward')) held.wasSteward = true;
if (added.includes('staff')) held.wasStaff = true;
if (added.includes('ombuds')) held.wasOmbuds = true;
});
return held;
} catch (e) {
// If anything fails, assume no global roles were held
return { wasSteward: false, wasStaff: false, wasOmbuds: false }; }
}
// Gathers all necessary data for a specific user on a specific wiki.
async function fetchUserData(user, db, start, end) {
// Convert start and end strings into Date objects for easier comparison
const auditStart = new Date(start), auditEnd = new Date(end);
// 1. FETCH CURRENT GLOBAL GROUPS (Using cache to save time)
if (!userCache[user]) {
try {
// Ask Meta-Wiki for the user's current global groups (steward, staff, etc.)
const gres = await robustCall(metaApi, { action: 'query', meta: 'globaluserinfo', guiprop: 'groups', guiuser: user, formatversion: 2 });
userCache[user] = gres.query.globaluserinfo.groups || [];
// Fallback to an empty list if the request fails
} catch (e) { userCache[user] = []; }
}
// Check if the user currently holds any key global roles
const gGroups = userCache[user];
const isGloballySteward = gGroups.includes('steward');
const isGloballyStaff = gGroups.includes('staff');
const isGloballyOmbuds = gGroups.includes('ombuds') || gGroups.includes('ombudsman');
// 2. FETCH GLOBAL HISTORY (Identify roles held during the period but lost since)
if (!historyCache[user]) historyCache[user] = await checkGlobalHistory(user, start, end);
const historyRes = historyCache[user];
// 3. CHECK CURRENT LOCAL CHECKUSER STATUS
let isCurrentLocal = false;
try {
// Connect to the local wiki to see if the user is currently a CheckUser
const localApi = new mw.ForeignApi(`https://${getDomain(db)}/w/api.php`);
const ures = await robustCall(localApi, { action: 'query', list: 'users', ususers: user, usprop: 'groups', formatversion: 2 });
const lGroups = (ures.query.users[0] && ures.query.users[0].groups) || [];
isCurrentLocal = lGroups.includes('checkuser');
} catch (e) {
// Ignore errors; defaults to false
}
// Define the target string for rights log searching
const target = 'User:' + user + '@' + db;
// Initialize variables for log analysis and role calculation
let logText = '',
addTime = null,
removeTime = null,
isSelfAssign = false,
maxDurationMins = -1,
longestTimeStr = "",
assignCount = 0,
hasLocalRightsInPeriod = false;
// 4. DEEP RIGHTS LOG SCAN (Pagination to bypass 500-limit)
let events = [];
let continueToken = null;
let finished = false;
try {
while (!finished) {
let params = {
action: 'query', list: 'logevents', letype: 'rights',
letitle: target, ledir: 'older', lelimit: 'max', formatversion: 2
};
if (continueToken) Object.assign(params, continueToken);
const res = await robustCall(metaApi, params);
const batch = res.query.logevents || [];
events = events.concat(batch);
if (res.continue) continueToken = res.continue;
else finished = true;
}
let logEntries = [];
let pendingRemoved = null;
let lastPairedDate = null;
for (let i = 0; i < events.length; i++) {
const e = events[i], p = e.params || {};
let cuAdded = (p.add || []).includes('checkuser') || ((p.newgroups || []).includes('checkuser') && !(p.oldgroups || []).includes('checkuser'));
const cuRemoved = (p.remove || []).includes('checkuser') || (!(p.newgroups || []).includes('checkuser') && (p.oldgroups || []).includes('checkuser'));
const cuModified = (p.newgroups || []).includes('checkuser') && (p.oldgroups || []).includes('checkuser');
if (cuModified) cuAdded = true;
const eventDate = new Date(e.timestamp);
const exactTime = e.timestamp.replace('T', ' ').replace('Z', '');
if (cuAdded) addTime = eventDate;
if (cuRemoved) removeTime = eventDate;
if (addTime && addTime <= auditEnd && (!removeTime || removeTime >= auditStart)) hasLocalRightsInPeriod = true;
const isInPeriod = (eventDate >= auditStart && eventDate <= auditEnd);
// Process if inside period OR looking for the start of a removal pair
if ((isInPeriod && (cuAdded || cuRemoved)) || (pendingRemoved && cuAdded)) {
let expiryDate = null;
if (p.newmetadata) {
const cuMeta = p.newmetadata.find(m => m.group === 'checkuser');
if (cuMeta && cuMeta.expiry && cuMeta.expiry !== 'infinity') expiryDate = new Date(cuMeta.expiry);
}
// Label if the performer was the user themselves (Self-removed)
const isSelf = (e.user === user || !e.user) ? " (Self-removed)" : "";
if (e.user === user || !e.user) isSelfAssign = true;
if (cuAdded) {
if (pendingRemoved) {
const diffMins = Math.round(Math.abs(pendingRemoved.date - eventDate) / 60000);
const d = Math.floor(diffMins / 1440), h = Math.floor((diffMins % 1440) / 60), m = diffMins % 60;
const durStr = `${d > 0 ? d+'d ' : ''}${h > 0 ? h+'h ' : ''}${m}m`;
// NEW: Shows performer for both ADD and REMOVE
logEntries.unshift(`* ADDED: ${exactTime} by ${e.user} | REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf} (Duration: ${durStr})`);
if (diffMins > maxDurationMins) { maxDurationMins = diffMins; longestTimeStr = durStr; }
assignCount++;
pendingRemoved = null;
lastPairedDate = eventDate;
} else if (expiryDate) {
const diffMins = Math.round(Math.abs(expiryDate - eventDate) / 60000);
const d = Math.floor(diffMins / 1440), h = Math.floor((diffMins % 1440) / 60), m = diffMins % 60;
const durStr = `${d > 0 ? d+'d ' : ''}${h > 0 ? h+'h ' : ''}${m}m`;
const exactExpiryTime = expiryDate.toISOString().replace('T', ' ').substring(0, 19);
// NEW: Shows who added the rights before expiration
logEntries.unshift(`* ADDED: ${exactTime} by ${e.user} | EXPIRED: ${exactExpiryTime} (Duration: ${durStr})`);
if (diffMins > maxDurationMins) { maxDurationMins = diffMins; longestTimeStr = durStr; }
assignCount++;
lastPairedDate = eventDate;
} else {
if (lastPairedDate && Math.abs(lastPairedDate - eventDate) < 86400000) {
// Skip duplicates
} else {
// NEW: Shows who added the currently active rights
logEntries.unshift(`* ADDED: ${exactTime} by ${e.user} | REMOVED: (Active/Not removed)`);
lastPairedDate = eventDate;
}
}
} else if (cuRemoved) {
if (pendingRemoved) logEntries.unshift(`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf}`);
pendingRemoved = { time: exactTime, date: eventDate, user: e.user, isSelf: isSelf };
}
}
}
if (pendingRemoved) logEntries.unshift(`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf}`);
if (logEntries.length) logText = '\n' + logEntries.join('\n');
// 5. ROLE CLASSIFICATION
let roleLabel = "";
// Check if user has any logged actions or held rights during the period
const hasActions = (results[db] && results[db][user] && results[db][user].total > 0);
let wasCU = hasLocalRightsInPeriod || hasActions;
// Determine the most relevant role for the user
if (isGloballyStaff || historyRes.wasStaff) {
// Priority 1: WMF Staff status
roleLabel = isGloballyStaff ? "Current Staff" : "Former Staff (in period)";
}
else if (isCurrentLocal) {
// Priority 2: Currently active local CheckUser
roleLabel = "Current Local CheckUser";
}
else if (longestTimeStr && isSelfAssign && (isGloballySteward || historyRes.wasSteward)) {
// Priority 3: Steward acting temporarily (Self-assign detection)
roleLabel = `Steward action (Self-assign: ${assignCount > 1 ? assignCount + 'x, longest ' : ''}${longestTimeStr})`;
}
else if (isGloballySteward || historyRes.wasSteward) {
// Priority 4: Standard Steward status
roleLabel = isGloballySteward ? "Current Steward" : "Former Steward (in period)";
}
else if (isGloballyOmbuds || historyRes.wasOmbuds) {
// Priority 5: Ombudsman commission status
roleLabel = isGloballyOmbuds ? "Current Ombudsman" : "Former Ombudsman (in period)";
}
else if (wasCU) {
// Priority 6: User who was a local CU but no longer is
roleLabel = "Former Local CheckUser (in period)";
}
else {
// Default if no specific role matches
roleLabel = "Unknown role";
}
// Return the calculated role and the formatted log entries
return { role: roleLabel, log: logText };
} catch (err) {
// Return an error state if anything crashes during user data processing
return { role: "Error fetching data", log: "" };
}
}
// The main engine that iterates through wikis and collects CheckUser logs.
async function runAudit(yf, mf, yt, mt) {
// Reset all data and state variables for a fresh run
isRunning = true;
userCache = {};
historyCache = {};
results = {};
emptyWikis = [];
failedWikis = [];
scannedWikis = [];
// Parse and prepare the wiki filter list from the UI input
const filterText = $('#wiki-filter').val().trim().toLowerCase();
const filterList = filterText ? filterText.split(',').map(s => s.trim()).filter(s => s !== "") : [];
let wikisToScan = rawWikis;
// Apply include/exclude logic to the master wiki list
if (currentFilterMode === 'include') wikisToScan = rawWikis.filter(w => filterList.includes(w));
else if (currentFilterMode === 'exclude') wikisToScan = rawWikis.filter(w => !filterList.includes(w));
if (wikisToScan.length === 0) { alert("No wikis selected!"); return; }
// Update UI buttons and progress bar to "running" state
$('#start').prop('disabled', true); $('#stop').prop('disabled', false); $('#out').hide();
$('#bar').attr('max', wikisToScan.length).val(0);
// Format the date strings into ISO format 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 columns for the table based on the selected month range
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++; }
}
// Loop through each selected wiki and fetch its logs
for (let i = 0; i < wikisToScan.length; i++) {
if (!isRunning) break; // Exit loop if the "Stop" button was clicked
const db = wikisToScan[i]; scannedWikis.push(db);
$('#status-msg').text(`Scanning ${db} (${i + 1}/${wikisToScan.length})...`);
let successLocal = false, continueToken = null;
// Inner loop for handling pagination of the logs on a single wiki
while (!successLocal && isRunning) {
try {
const api = new mw.ForeignApi(`https://${getDomain(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);
// Process each entry in the log batch
const ent = res.query?.checkuserlog?.entries || [];
if (ent.length) {
if (!results[db]) results[db] = {};
ent.forEach(e => {
const u = e.checkuser;
if (!results[db][u]) results[db][u] = { total: 0, months: {} };
const mKey = e.timestamp.slice(0, 7);
results[db][u].total++; results[db][u].months[mKey] = (results[db][u].months[mKey] || 0) + 1;
});
}
// Check if there are more log pages to fetch
if (res.continue && isRunning) continueToken = res.continue;
else { if (!results[db]) emptyWikis.push(db); successLocal = true; }
} catch (err) {
// Record failures (like 429 after retries or 404) and move to the next wiki
failedWikis.push(db);
successLocal = true;
}
}
// Update the progress bar and wait a bit to be kind to the API
$('#bar').val(i + 1); await sleep(DELAY_MS);
}
// Update UI and prepare the Wikitable header and metadata
$('#status-msg').text(`Generating report...`);
const today = new Date().toISOString().split('T')[0];
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' (UTC)';
// Start building the Wikitext report
let wt = `== Global CheckUser Stats (${mf}/${yf} - ${mt}/${yt}) ==\n''Report generated on: ${timestamp}''\n\n`;
let rightsLog = `\n== Rights Log (In Period) ==\n`;
// Create table structure with dynamic month columns
wt += `{| class="wikitable sortable" style="font-size:90%; text-align:right;"\n! Wiki / User !! Category !! '''Total''' !! per 1k active users ${monthCols.map(m => `!! ${m.label}`).join(' ')}\n`;
// Sort databases alphabetically and begin processing each project's results
const sortedDBs = Object.keys(results).sort();
for (const db of sortedDBs) {
// Get active user count; if API fails or is 0, display 'Unknown'
const metrics = await fetchWikiMetrics(db);
const fmtActive = metrics.active > 0 ? metrics.active.toLocaleString('en-US') : 'Unknown';
// Prepare interwiki links for the project and its CheckUser log
const iwPrefix = getInterwikiPrefix(db);
const projectLink = `[[${iwPrefix}:|${db}]]`;
const logLink = `[[${iwPrefix}:Special:CheckUserLog|(log)]]`;
wt += `|-\n! colspan="${4 + monthCols.length}" style="background:#eaecf0; text-align:center;" | ${projectLink} <small style="font-weight:normal; color:#54595d;">(Active Users as of ${today}: ${fmtActive})</small> — ${logLink}\n`;
// Initialize local wiki totals and storage for individual user rows
let wikiRows = [],
wT = 0,
wMS = {};
monthCols.forEach(col => wMS[col.key] = 0);
// Get users from CU logs AND users who had rights changes (if we tracked them)
// For now, let's at least make sure we don't skip users with 0 actions but active rights
let projectUsers = Object.keys(results[db] || {});
projectUsers.sort();
for (const user of projectUsers) {
// Get role and rights log details for each user
const m = await fetchUserData(user, db, START, END);
// Identify if user belongs to specific global or local groups
const isSteward = m.role.includes("Steward") || m.role.includes("Staff") || m.role.includes("Ombudsman");
const isLocalCU = m.role.includes("Local CheckUser");
// Filter out users if they don't match the selected User Filter (Local/Steward)
if (currentUserFilter === 'local' && !isLocalCU) continue;
if (currentUserFilter === 'steward' && !isSteward) continue;
const uD = results[db][user];
// Aggregate totals for the current wiki
wT += uD.total; monthCols.forEach(col => wMS[col.key] += (uD.months[col.key] || 0));
// Calculate actions per 1k active users; display 0.0 if project activity is unknown
let uP1kU = metrics.active > 0 ? ((uD.total / metrics.active) * 1000).toFixed(1) : "0.0";
// Build the Wikitext row for the individual user
let row = `|-\n| style="text-align:left;" | ${user}@${db} || <small>${m.role}</small> || '''${uD.total}''' || ${uP1kU}`;
monthCols.forEach(col => row += ` || ${uD.months[col.key] || 0}`);
// Store the formatted row and its corresponding rights log
wikiRows.push({ html: row + "\n", log: m.log ? `'''${user}@${db}''':${m.log}\n\n` : "" });
}
// Create the bold TOTAL row for the current wiki
let wP1kU = metrics.active > 0 ? ((wT / metrics.active) * 1000).toFixed(1) : "0.0";
let totalRow = `|- style="background:#f8f9fa; font-weight:bold;"\n| style="text-align:left;" | TOTAL ${db} || — || ${wT} || ${wP1kU}`;
monthCols.forEach(col => totalRow += ` || ${wMS[col.key]}`);
// Append the total row first, then all individual user rows
wt += totalRow + "\n";
wikiRows.forEach(r => { wt += r.html; if (r.log) rightsLog += r.log; });
}
// Append a list of wikis where no CheckUser actions were found
wt += `|}\n\n== Projects with 0 actions ==\n<div style="font-size:85%; color:#54595d;">${emptyWikis.sort().join(', ')}</div>\n`;
// Append a list of projects that failed to scan (e.g., due to API errors)
if (failedWikis.length > 0) {
wt += `\n== API Errors / Skipped Projects ==\n<div style="font-size:85%; color:#d33;">${failedWikis.sort().join(', ')}</div>\n`;
}
// Finalize the report with the Rights Log section
wt += rightsLog + `\n\n<references />\n`;
// Display the final output and reset UI buttons
$('#out').val(wt).show();
$('#status-msg').text(`Done.`); $('#start').prop('disabled', false); $('#stop').prop('disabled', true);
}
// Initialize the interface on page load
setupUI();
});
})();
mxzr4na7z6wae3oxapd3dfvk2rqv8iy
737761
737759
2026-04-12T17:05:56Z
MrJaroslavik
44012
e
737761
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]].
// - Full Reporting: Outputs sortable Wikitables and detailed rights change logs.
// - With help of Gemini 3
// -------------------------------------------------------
// Wrap everything so this script doesn't break other Wikipedia scripts
(function () {
'use strict';
// Stop immediately if we are NOT on the specific GlobalCheckUserStats page
if (
mw.config.get('wgCanonicalSpecialPageName') !== 'Blankpage' ||
mw.config.get('wgTitle').indexOf('GlobalCheckUserStats') === -1
) return;
// A fixed list of all valid Wikimedia projects to scan
const rawWikis = ['abstractwiki', 'abwiki', 'acewiki', 'adywiki', 'afwiki', 'afwikibooks', 'afwikiquote', 'afwiktionary', 'alswiki', 'altwiki', 'amiwiki', 'amwiki', 'amwiktionary', 'angwiki', 'angwiktionary', 'annwiki', 'anpwiki', 'anwiki', 'anwiktionary', 'arcwiki', 'arwiki', 'arwikibooks', 'arwikimedia', 'arwikinews', 'arwikiquote', 'arwikisource', 'arwikiversity', 'arwiktionary', 'arywiki', 'arzwiki', 'astwiki', 'astwiktionary', 'aswiki', 'aswikiquote', 'aswikisource', 'atjwiki', 'avkwiki', 'avwiki', 'awawiki', 'aywiki', 'aywiktionary', 'azbwiki', 'azwiki', 'azwikibooks', 'azwikiquote', 'azwikisource', 'azwiktionary', 'banwiki', 'banwikisource', 'barwiki', 'bat_smgwiki', 'bawiki', 'bawikibooks', 'bbcwiki', 'bclwiki', 'bclwikiquote', 'bclwikisource', 'bclwiktionary', 'bdrwiki', 'bdwikimedia', 'be_x_oldwiki', 'betawikiversity', 'bewiki', 'bewikibooks', 'bewikimedia', 'bewikiquote', 'bewikisource', 'bewiktionary', 'bewwiki', 'bewwiktionary', 'bgwiki', 'bgwikibooks', 'bgwikiquote', 'bgwikisource', 'bgwiktionary', 'bhwiki', 'biwiki', 'bjnwiki', 'bjnwikiquote', 'bjnwiktionary', 'blkwiki', 'blkwiktionary', 'bmwiki', 'bnwiki', 'bnwikibooks', 'bnwikiquote', 'bnwikisource', 'bnwikivoyage', 'bnwiktionary', 'bowiki', 'bpywiki', 'brwiki', 'brwikimedia', 'brwikiquote', 'brwikisource', 'brwiktionary', 'bswiki', 'bswikibooks', 'bswikinews', 'bswikiquote', 'bswikisource', 'bswiktionary', 'btmwiki', 'btmwiktionary', 'bugwiki', 'bxrwiki', 'cawiki', 'cawikibooks', 'cawikimedia', 'cawikinews', 'cawikiquote', 'cawikisource', 'cawiktionary', 'cbk_zamwiki', 'cdowiki', 'cebwiki', 'cewiki', 'chrwiki', 'chrwiktionary', 'chwiki', 'chywiki', 'ckbwiki', 'ckbwiktionary', 'commonswiki', 'cowiki', 'cowikimedia', 'cowiktionary', 'crhwiki', 'csbwiki', 'csbwiktionary', 'cswiki', 'cswikibooks', 'cswikinews', 'cswikiquote', 'cswikisource', 'cswikiversity', 'cswikivoyage', 'cswiktionary', 'cuwiki', 'cvwiki', 'cvwikibooks', 'cywiki', 'cywikibooks', 'cywikiquote', 'cywikisource', 'cywiktionary', 'dagwiki', 'dawiki', 'dawikibooks', 'dawikiquote', 'dawikisource', 'dawiktionary', 'dewiki', 'dewikibooks', 'dewikinews', 'dewikiquote', 'dewikisource', 'dewikiversity', 'dewikivoyage', 'dewiktionary', 'dgawiki', 'dinwiki', 'diqwiki', 'diqwiktionary', 'dkwikimedia', 'dsbwiki', 'dtpwiki', 'dtywiki', 'dvwiki', 'dvwiktionary', 'dzwiki', 'eewiki', 'elwiki', 'elwikibooks', 'elwikinews', 'elwikiquote', 'elwikisource', 'elwikiversity', 'elwikivoyage', 'elwiktionary', 'emlwiki', 'enwiki', 'enwikibooks', 'enwikinews', 'enwikiquote', 'enwikisource', 'enwikiversity', 'enwikivoyage', 'enwiktionary', 'eowiki', 'eowikibooks', 'eowikinews', 'eowikiquote', 'eowikisource', 'eowikivoyage', 'eowiktionary', 'eswiki', 'eswikibooks', 'eswikinews', 'eswikiquote', 'eswikisource', 'eswikiversity', 'eswikivoyage', 'eswiktionary', 'etwiki', 'etwikibooks', 'eewikimedia', 'etwikiquote', 'etwikisource', 'etwiktionary', 'euwiki', 'euwikibooks', 'euwikiquote', 'euwikisource', 'euwiktionary', 'extwiki', 'fatwiki', 'fawiki', 'fawikibooks', 'fawikinews', 'fawikiquote', 'fawikisource', 'fawikivoyage', 'fawiktionary', 'ffwiki', 'fiu_vrowiki', 'fiwiki', 'fiwikibooks', 'fiwikimedia', 'fiwikinews', 'fiwikiquote', 'fiwikisource', 'fiwikiversity', 'fiwikivoyage', 'fiwiktionary', 'fjwiki', 'fjwiktionary', 'fonwiki', 'foundationwiki', 'fowiki', 'fowikisource', 'fowiktionary', 'frpwiki', 'frrwiki', 'frwiki', 'frwikibooks', 'frwikinews', 'frwikiquote', 'frwikisource', 'frwikiversity', 'frwikivoyage', 'frwiktionary', 'furwiki', 'fywiki', 'fywikibooks', 'fywiktionary', 'gagwiki', 'ganwiki', 'gawiki', 'gawiktionary', 'gcrwiki', 'gdwiki', 'gdwiktionary', 'glkwiki', 'glwiki', 'glwikibooks', 'glwikiquote', 'glwikisource', 'glwiktionary', 'gnwiki', 'gnwiktionary', 'gomwiki', 'gomwiktionary', 'gorwiki', 'gorwikiquote', 'gorwiktionary', 'gotwiki', 'gpewiki', 'gucwiki', 'gurwiki', 'guwiki', 'guwikiquote', 'guwikisource', 'guwiktionary', 'guwwiki', 'guwwikinews', 'guwwikiquote', 'guwwiktionary', 'gvwiki', 'gvwiktionary', 'hakwiki', 'hawiki', 'hawiktionary', 'hawwiki', 'hewiki', 'hewikibooks', 'hewikinews', 'hewikiquote', 'hewikisource', 'hewikivoyage', 'hewiktionary', 'hifwiki', 'hifwiktionary', 'hiwiki', 'hiwikibooks', 'hiwikiquote', 'hiwikisource', 'hiwikiversity', 'hiwikivoyage', 'hiwiktionary', 'hrwiki', 'hrwikibooks', 'hrwikiquote', 'hrwikisource', 'hrwiktionary', 'hsbwiki', 'hsbwiktionary', 'htwiki', 'huwiki', 'huwikibooks', 'huwikisource', 'huwiktionary', 'hywiki', 'hywikibooks', 'hywikiquote', 'hywikisource', 'hywiktionary', 'hywwiki', 'iawiki', 'iawikibooks', 'iawiktionary', 'ibawiki', 'idwiki', 'idwikibooks', 'idwikiquote', 'idwikisource', 'idwikivoyage', 'idwiktionary', 'iewiki', 'iewiktionary', 'iglwiki', 'igwiki', 'igwikiquote', 'igwiktionary', 'ikwiki', 'ilowiki', 'incubatorwiki', 'inhwiki', 'iowiki', 'iowiktionary', 'iswiki', 'iswikibooks', 'iswikiquote', 'iswikisource', 'iswiktionary', 'itwiki', 'itwikibooks', 'itwikinews', 'itwikiquote', 'itwikisource', 'itwikiversity', 'itwikivoyage', 'itwiktionary', 'iuwiki', 'iuwiktionary', 'jamwiki', 'jawiki', 'jawikibooks', 'jawikinews', 'jawikisource', 'jawikiversity', 'jawikivoyage', 'jawiktionary', 'jbowiki', 'jbowiktionary', 'jvwiki', 'jvwikisource', 'jvwiktionary', 'kaawiki', 'kaawiktionary', 'kabwiki', 'kaiwiki', 'kajwiki', 'kawiki', 'kawikibooks', 'kawikiquote', 'kawikisource', 'kawiktionary', 'kbdwiki', 'kbdwiktionary', 'kbpwiki', 'kcgwiki', 'kcgwiktionary', 'kgewiki', 'kgwiki', 'kiwiki', 'kkwiki', 'kkwikibooks', 'kkwiktionary', 'klwiktionary', 'kmwiki', 'kmwikibooks', 'kmwiktionary', 'kncwiki', 'knwiki', 'knwikiquote', 'knwikisource', 'knwiktionary', 'koiwiki', 'kowiki', 'kowikibooks', 'kowikinews', 'kowikiquote', 'kowikisource', 'kowikiversity', 'kowiktionary', 'krcwiki', 'kshwiki', 'kswiki', 'kswiktionary', 'kuswiki', 'kuwiki', 'kuwikibooks', 'kuwikiquote', 'kuwiktionary', 'kvwiki', 'kwwiki', 'kwwiktionary', 'kywiki', 'kywikiquote', 'kywiktionary', 'labswiki', 'ladwiki', 'lawiki', 'lawikibooks', 'lawikiquote', 'lawikisource', 'lawiktionary', 'lbewiki', 'lbwiki', 'lbwiktionary', 'lezwiki', 'lfnwiki', 'lgwiki', 'lijwiki', 'lijwikisource', 'liwiki', 'liwikibooks', 'liwikinews', 'liwikiquote', 'liwikisource', 'liwiktionary', 'lldwiki', 'lmowiki', 'lmowiktionary', 'lnwiki', 'lnwiktionary', 'lowiki', 'lowiktionary', 'ltgwiki', 'ltwiki', 'ltwikibooks', 'ltwikiquote', 'ltwikisource', 'ltwiktionary', 'lvwiki', 'lvwiktionary', 'madwiki', 'madwikisource', 'madwiktionary', 'maiwiki', 'map_bmswiki', 'mdfwiki', 'mediawikiwiki', 'metawiki', 'mgwiki', 'mgwikibooks', 'mgwiktionary', 'mhrwiki', 'minwiki', 'minwikibooks', 'minwikisource', 'minwiktionary', 'miwiki', 'miwiktionary', 'mkwiki', 'mkwikibooks', 'mkwikimedia', 'mkwikisource', 'mkwiktionary', 'mlwiki', 'mlwikibooks', 'mlwikiquote', 'mlwikisource', 'mlwiktionary', 'mniwiki', 'mniwiktionary', 'mnwiki', 'mnwiktionary', 'mnwwiki', 'mnwwiktionary', 'moswiki', 'mrjwiki', 'mrwiki', 'mrwikibooks', 'mrwikiquote', 'mrwikisource', 'mrwiktionary', 'mswiki', 'mswikibooks', 'mswikiquote', 'mswikisource', 'mswiktionary', 'mtwiki', 'mtwiktionary', 'mwlwiki', 'mxwikimedia', 'myvwiki', 'mywiki', 'mywikisource', 'mywiktionary', 'mznwiki', 'nahwiki', 'nahwiktionary', 'napwiki', 'napwikisource', 'nawiktionary', 'nds_nlwiki', 'ndswiki', 'ndswiktionary', 'newiki', 'newikibooks', 'newiktionary', 'newwiki', 'niawiki', 'niawiktionary', 'nlwiki', 'nlwikibooks', 'nlwikimedia', 'nlwikinews', 'nlwikiquote', 'nlwikisource', 'nlwikivoyage', 'nlwiktionary', 'nnwiki', 'nnwikiquote', 'nnwiktionary', 'novwiki', 'nowiki', 'nowikibooks', 'nowikimedia', 'nowikinews', 'nowikiquote', 'nowikisource', 'nowiktionary', 'nqowiki', 'nrmwiki', 'nrwiki', 'nsowiki', 'nupwiki', 'nvwiki', 'nycwikimedia', 'nywiki', 'ocwiki', 'ocwikibooks', 'ocwiktionary', 'olowiki', 'omwiki', 'omwiktionary', 'orwiki', 'orwikisource', 'orwiktionary', 'oswiki', 'outreachwiki', 'pagwiki', 'pamwiki', 'papwiki', 'pawiki', 'pawikibooks', 'pawikisource', 'pawiktionary', 'pcdwiki', 'pcmwiki', 'pcmwikiquote', 'pdcwiki', 'pflwiki', 'piwiki', 'plwiki', 'plwikibooks', 'plwikimedia', 'plwikinews', 'plwikiquote', 'plwikisource', 'plwikivoyage', 'plwiktionary', 'pmswiki', 'pmswikisource', 'pnbwiki', 'pnbwiktionary', 'pntwiki', 'pplwiki', 'pswiki', 'pswikivoyage', 'pswiktionary', 'ptwiki', 'ptwikibooks', 'ptwikimedia', 'ptwikinews', 'ptwikiquote', 'ptwikisource', 'ptwikiversity', 'ptwikivoyage', 'ptwiktionary', 'pwnwiki', 'quwiktionary', 'rkiwiki', 'rmwiki', 'rmywiki', 'rnwiki', 'roa_rupwiki', 'roa_rupwiktionary', 'roa_tarawiki', 'rowiki', 'rowikibooks', 'rowikinews', 'rowikiquote', 'rowiktionary', 'rskwiki', 'ruewiki', 'ruwiki', 'ruwikibooks', 'ruwikinews', 'ruwikiquote', 'ruwikisource', 'ruwikiversity', 'ruwikivoyage', 'ruwiktionary', 'rwwiki', 'rwwiktionary', 'sahwiki', 'sahwikiquote', 'sahwikisource', 'satwiki', 'satwiktionary', 'sawiki', 'sawikibooks', 'sawikiquote', 'sawikisource', 'sawiktionary', 'scnwiki', 'scnwiktionary', 'scowiki', 'scwiki', 'sdwiki', 'sdwiktionary', 'sewiki', 'sewikimedia', 'sgwiki', 'sgwiktionary', 'shiwiki', 'shnwiki', 'shnwikibooks', 'shnwikinews', 'shnwikivoyage', 'shnwiktionary', 'shwiki', 'shwiktionary', 'shywiktionary', 'simplewiki', 'simplewiktionary', 'siwiki', 'siwikibooks', 'siwiktionary', 'skwiki', 'skrwiki', 'skrwiktionary', 'slwiki', 'slwikibooks', 'slwikiquote', 'slwikisource', 'slwikiversity', 'slwiktionary', 'smnwiki', 'smwiki', 'smwiktionary', 'snwiki', 'sourceswiki', 'sowiki', 'sowiktionary', 'specieswiki', 'sqwiki', 'sqwikibooks', 'sqwikinews', 'sqwikiquote', 'sqwiktionary', 'srnwiki', 'srwiki', 'srwikibooks', 'srwikinews', 'srwikiquote', 'srwikisource', 'srwiktionary', 'sswiki', 'sswiktionary', 'stqwiki', 'stwiki', 'stwiktionary', 'suwiki', 'suwikiquote', 'suwikisource', 'suwiktionary', 'svwiki', 'svwikibooks', 'svwikinews', 'svwikiquote', 'svwikisource', 'svwikiversity', 'svwikivoyage', 'svwiktionary', 'swwiki', 'swwiktionary', 'sylwiki', 'szlwiki', 'szywiki', 'tawiki', 'tawikibooks', 'tawikinews', 'tawikiquote', 'tawikisource', 'tawiktionary', 'taywiki', 'tcywiki', 'tcywikisource', 'tcywiktionary', 'tddwiki', 'tewiki', 'tewikibooks', 'tewikiquote', 'tewikisource', 'tewiktionary', 'tgwiki', 'tgwikibooks', 'tgwiktionary', 'thwiki', 'thwikibooks', 'thwikimedia', 'thwikiquote', 'thwikisource', 'thwiktionary', 'tigwiki', 'tiwiki', 'tiwiktionary', 'tkwiki', 'tkwiktionary', 'tlwiki', 'tlwikibooks', 'tlwikiquote', 'tlwikisource', 'tlwiktionary', 'tlywiki', 'tnwiki', 'tnwiktionary', 'tokwiki', 'towiki', 'tpiwiki', 'tpiwiktionary', 'trvwiki', 'trwiki', 'trwikibooks', 'trwikimedia', 'trwikinews', 'trwikiquote', 'trwikisource', 'trwikivoyage', 'trwiktionary', 'tswiki', 'tswiktionary', 'ttwiki', 'ttwikibooks', 'ttwikiquote', 'ttwiktionary', 'tumwiki', 'twwiki', 'twwiktionary', 'tyvwiki', 'tywiki', 'uawikimedia', 'udmwiki', 'ugwiki', 'ugwiktionary', 'ukwiki', 'ukwikibooks', 'ukwikinews', 'ukwikiquote', 'ukwikisource', 'ukwikivoyage', 'ukwiktionary', 'urwiki', 'urwikibooks', 'urwikiquote', 'urwikisource', 'urwiktionary', 'uzwiki', 'uzwikiquote', 'uzwiktionary', 'vecwiki', 'vecwikisource', 'vecwiktionary', 'vepwiki', 'vewiki', 'viwiki', 'viwikibooks', 'viwikiquote', 'viwikisource', 'viwikivoyage', 'viwiktionary', 'vlswiki', 'vowiki', 'vowiktionary', 'warwiki', 'wawiki', 'wawikisource', 'wawiktionary', 'wikidatawiki', 'wikimaniawiki', 'wowiki', 'wowiktionary', 'wuuwiki', 'xalwiki', 'xhwiki', 'xmfwiki', 'yiwiki', 'yiwikisource', 'yiwiktionary', 'yowiki', 'zawiki', 'zeawiki', 'zghwiki', 'zghwiktionary', 'zh_classicalwiki', 'zh_min_nanwiki', 'zh_min_nanwikisource', 'zh_min_nanwiktionary', 'zh_yuewiki', 'zhwiki', 'zhwikibooks', 'zhwikinews', 'zhwikiquote', 'zhwikisource', 'zhwikiversity', 'zhwikivoyage', 'zhwiktionary', 'zuwiki', 'zuwiktionary', 'test2wiki', 'testwiki'];
// Time delay between API requests to prevent rate limiting (429 errors)
const DELAY_MS = 800;
// Helper function to pause execution for a specified number of milliseconds
const sleep = ms => new Promise(r => setTimeout(r, ms));
// Cache for global user group information to avoid redundant API calls
let userCache = {};
// Cache for historical global rights changes within the audit period
let historyCache = {};
// Helper function to handle API calls with up to 3 retries on 429 errors
async function robustCall(api, params) {
let retries = 0;
const maxRetries = 3;
// Continue attempting the API request as long as we haven't hit the maximum retry limit
while (retries < maxRetries) {
try {
// Attempt to execute the API GET request
return await api.get(params);
} catch (err) {
// If the error is "429 Too Many Requests" (rate limit)
if (err?.status === 429) {
retries++;
// Get wait time from 'Retry-After' header, or use a default backoff (30s, 60s, 90s)
const retryAfter = parseInt(err.xhr?.getResponseHeader('Retry-After')) || (30 * retries);
// Update UI status message with the remaining wait time
$('#status-msg').html(
`<span style="color:orange; font-weight:bold;">Rate limit hit! Resting ${retryAfter}s... (Attempt ${retries}/${maxRetries})</span>`
);
// Pause execution before the next retry
await sleep(retryAfter * 1000);
} else {
// Rethrow any error that is not a rate limit (e.g., 404, 403, 500)
throw err;
}
}
}
// Fail if max retries are exceeded
throw new Error("Max retries reached");
}
// Converts a database name (like 'enwiki') into a real web address.
function getDomain(db) {
// Static mapping for projects with non-standard naming conventions
const mapping = {
'commonswiki': 'commons.wikimedia.org',
'metawiki': 'meta.wikimedia.org',
'wikidatawiki': 'www.wikidata.org',
'mediawikiwiki': 'www.mediawiki.org',
'sourceswiki': 'wikisource.org',
'foundationwiki': 'foundation.wikimedia.org',
'incubatorwiki': 'incubator.wikimedia.org',
'outreachwiki': 'outreach.wikimedia.org',
'be_x_oldwiki': 'be-tarask.wikipedia.org',
'labswiki': 'wikitech.wikimedia.org',
'wikimaniawiki': 'wikimania.wikimedia.org',
'specieswiki': 'species.wikimedia.org',
'abstractwiki': 'abstract.wikipedia.org',
'betawikiversity': 'beta.wikiversity.org',
'mo_wiki': 'ro.wikipedia.org',
'testwiki': 'test.wikipedia.org',
'test2wiki': 'test2.wikipedia.org',
'wikifunctionswiki': 'www.wikifunctions.org',
'testcommonswiki': 'test-commons.wikimedia.org',
'testwikidatawiki': 'test.wikidata.org',
};
// Return early if an explicit mapping exists
if (mapping[db]) return mapping[db];
// Format language codes: replace underscores with hyphens (e.g., zh_yue -> zh-yue)
const name = db.replace(/_/g, '-');
// Handle sister project families by trimming suffixes and appending correct domains
if (name.endsWith('wikisource')) return name.slice(0, -10) + '.wikisource.org';
if (name.endsWith('wikiversity')) return name.slice(0, -11) + '.wikiversity.org';
if (name.endsWith('wiktionary')) return name.slice(0, -10) + '.wiktionary.org';
if (name.endsWith('wikivoyage')) return name.slice(0, -10) + '.wikivoyage.org';
if (name.endsWith('wikibooks')) return name.slice(0, -9) + '.wikibooks.org';
if (name.endsWith('wikiquote')) return name.slice(0, -9) + '.wikiquote.org';
if (name.endsWith('wikinews')) return name.slice(0, -8) + '.wikinews.org';
if (name.endsWith('wikimedia')) return name.slice(0, -9) + '.wikimedia.org';
// Default logic for Wikipedias: remove 'wiki' suffix and append domain
if (name.endsWith('wiki')) return name.slice(0, -4) + '.wikipedia.org';
// Generic fallback
return name + '.wikipedia.org';
}
function getInterwikiPrefix(db) {
// Mapping for special projects that use non-standard interwiki shortcuts
const specials = {
'commonswiki': 'commons',
'metawiki': 'm',
'wikidatawiki': 'd',
'mediawikiwiki': 'mw',
'specieswiki': 'wikispecies',
'foundationwiki': 'wikimedia',
'sourceswiki': 'wikisource',
};
// Return early if an explicit special prefix is defined
if (specials[db]) return specials[db];
// Format name for interwiki consistency (underscores to hyphens)
const name = db.replace(/_/g, '-');
// Logic for sister project families: trim suffixes and append the standard shorthand
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 'wikimedia:' + name.slice(0, -9);
// Standard Wikipedia logic: removes 'wiki' suffix and adds 'w:' prefix
if (name.endsWith('wiki')) return 'w:' + name.slice(0, -4);
// Default fallback for any other database patterns
return 'w:' + name;
}
// Ensure required MediaWiki API and ForeignApi modules are loaded before execution
mw.loader.using(['mediawiki.api', 'mediawiki.ForeignApi']).then(function () {
// Initialize API connection to Meta-Wiki for global user information and rights logs
const metaApi = new mw.ForeignApi('https://meta.wikimedia.org/w/api.php');
// Capture current date/time for report generation and timestamping
const now = new Date();
// State management variables:
let results = {}, // Stores aggregated CU action counts per wiki/user
emptyWikis = [], // List of projects with no recorded CU actions
failedWikis = [], // List of projects that encountered API errors
scannedWikis = [], // Tracking list of successfully processed projects
isRunning = false; // Flag to control the start/stop state of the audit
// UI state variables for filtering wikis and user roles
let currentFilterMode = 'all';
let currentUserFilter = 'all';
// Initializes and builds the User Interface (UI) elements on the page.
function setupUI() {
// Get the current year to ensure the date range is always up-to-date
const currentYear = now.getFullYear();
// Generate HTML options for the year selection (starting from 2005 to current)
let yearOpts = '';
for (let y = currentYear; y >= 2005; y--) {
yearOpts += `<option value="${y}">${y}</option>`;
}
// Prepare HTML options for month selection (1-12)
let monthOptsFrom = '';
let monthOptsTo = '';
for (let m = 1; m <= 12; m++) {
// Set default: 'From' dropdown defaults to January (1)
monthOptsFrom += `<option value="${m}" ${m === 1 ? 'selected' : ''}>${m}</option>`;
// Set default: 'To' dropdown defaults to December (12)
monthOptsTo += `<option value="${m}" ${m === 12 ? 'selected' : ''}>${m}</option>`;
}
// Set the page title to identify the script
$('#firstHeading').text('GlobalCheckUserStats.js');
// Clear the main content area and append the control panel HTML
$('#mw-content-text').empty().append(`
<div style="border:1px solid #a2a9b1; padding:15px; background:#f8f9fa;">
<h3>Stats Range</h3>
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 style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>Wiki Filter:</strong>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="wiki-mode" id="btn-all" checked> All wikis</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="wiki-mode" id="btn-except"> All wikis except</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><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; font-size:12px; line-height:18px;" title="Show all wiki DB names">?</span>
</div>
<div id="filter-input-container" style="display:none; margin-top:10px;">
<input id="wiki-filter" type="text" style="width:100%; max-width:600px;" placeholder="">
</div>
<div style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>User Filter:</strong>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-all" checked> All users</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-local"> Only Local CUs</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-steward"> Only Stewards</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; color:#202122;">
<strong>Available Wikis (alphabetical):</strong><br>${[...rawWikis].sort().join(', ')}
</div>
<div style="margin-top:20px;">
<button id="start" class="mw-ui-button mw-ui-progressive">Run GlobalCheckUserStats.js</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="${rawWikis.length}" style="width:100%"></progress></div>
<textarea id="out" style="width:100%; height:450px; margin-top:10px; display:none; font-family:monospace; font-size:12px;"></textarea>
</div>
`);
// Handle clicking 'All wikis': resets filter mode and hides the input field
$('#btn-all').click(() => {
currentFilterMode = 'all';
$('#filter-input-container').hide();
});
// Handle clicking 'All wikis except': shows input field, sets mode to 'exclude', and focuses the box
$('#btn-except').click(() => {
currentFilterMode = 'exclude';
$('#filter-input-container').show();
$('#wiki-filter').attr('placeholder', 'Exclude (dbname1, dbname2)...').focus();
});
// Handle clicking 'Only these wikis': shows input field, sets mode to 'include', and focuses the box
$('#btn-only').click(() => {
currentFilterMode = 'include';
$('#filter-input-container').show();
$('#wiki-filter').attr('placeholder', 'Only (dbname1, dbname2)...').focus();
});
// User role filter buttons: define which user categories will appear in the final table
$('#u-all').click(() => { currentUserFilter = 'all'; });
$('#u-local').click(() => { currentUserFilter = 'local'; });
$('#u-steward').click(() => { currentUserFilter = 'steward'; });
// Toggle visibility of the 'Available Wikis' help list when the question mark icon is clicked
$('#wiki-help-trigger').click(() => $('#wiki-list-help').toggle());
// Main execution trigger: starts the audit using the selected date range from dropdowns
$('#start').click(() => runAudit($('#y-f').val(), $('#m-f').val(), $('#y-t').val(), $('#m-t').val()));
// Stop button: sets isRunning to false to halt the loop between wiki scans
$('#stop').click(() => isRunning = false);
}
// Fetches general statistics for a specific wiki, primarily to get the active user count.
async function fetchWikiMetrics(db) {
try {
const api = new mw.ForeignApi(`https://${getDomain(db)}/w/api.php`);
// Request site statistics via the 'siteinfo' meta module
const res = await robustCall(api, {
action: 'query', meta: 'siteinfo', siprop: 'statistics', formatversion: 2
});
// Extract the active users count; default to 0 if the data is missing
return { active: res.query.statistics.activeusers || 0 };
} catch (e) {
// Graceful fallback: return zero if the project is unreachable or API fails
return { active: 0 };
}
}
// Checks if a user held global roles (Steward, Staff, Ombuds) during the audit period.
async function checkGlobalHistory(user, start, end) {
try {
// Fetch global rights logs from Meta-Wiki for the specific user
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 state = { steward: false, staff: false, ombuds: false };
// Look at the last log entry BEFORE the audit started to see the initial status
const priorLogs = logs
.filter(l => new Date(l.timestamp) <= auditStart)
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
if (priorLogs.length > 0) {
const p = priorLogs[0].params || {};
const groupsInLog = p.newGroups || p[1] || [];
state.steward = groupsInLog.includes('steward');
state.staff = groupsInLog.includes('staff');
state.ombuds = groupsInLog.includes('ombuds');
}
// Keep track of any global roles gained during the audit period itself
let held = { wasSteward: state.steward, wasStaff: state.staff, wasOmbuds: state.ombuds };
logs
.filter(l => {
const ts = new Date(l.timestamp);
return ts > auditStart && ts < auditEnd;
})
.forEach(e => {
const p = e.params || {};
const added = p.newGroups || p[1] || [];
if (added.includes('steward')) held.wasSteward = true;
if (added.includes('staff')) held.wasStaff = true;
if (added.includes('ombuds')) held.wasOmbuds = true;
});
return held;
} catch (e) {
// If anything fails, assume no global roles were held
return { wasSteward: false, wasStaff: false, wasOmbuds: false };
}
}
// Gathers all necessary data for a specific user on a specific wiki.
async function fetchUserData(user, db, start, end) {
// Convert start and end strings into Date objects for easier comparison
const auditStart = new Date(start);
const auditEnd = new Date(end);
// 1. FETCH CURRENT GLOBAL GROUPS (Using cache to save time)
if (!userCache[user]) {
try {
// Ask Meta-Wiki for the user's current global groups (steward, staff, etc.)
const gres = await robustCall(metaApi, {
action: 'query', meta: 'globaluserinfo', guiprop: 'groups',
guiuser: user, formatversion: 2
});
userCache[user] = gres.query.globaluserinfo.groups || [];
} catch (e) {
// Fallback to an empty list if the request fails
userCache[user] = [];
}
}
// Check if the user currently holds any key global roles
const gGroups = userCache[user];
const isGloballySteward = gGroups.includes('steward');
const isGloballyStaff = gGroups.includes('staff');
const isGloballyOmbuds = gGroups.includes('ombuds') || gGroups.includes('ombudsman');
// 2. FETCH GLOBAL HISTORY (Identify roles held during the period but lost since)
if (!historyCache[user]) {
historyCache[user] = await checkGlobalHistory(user, start, end);
}
const historyRes = historyCache[user];
// 3. CHECK CURRENT LOCAL CHECKUSER STATUS
let isCurrentLocal = false;
try {
// Connect to the local wiki to see if the user is currently a CheckUser
const localApi = new mw.ForeignApi(`https://${getDomain(db)}/w/api.php`);
const ures = await robustCall(localApi, {
action: 'query', list: 'users', ususers: user,
usprop: 'groups', formatversion: 2
});
const lGroups = (ures.query.users[0] && ures.query.users[0].groups) || [];
isCurrentLocal = lGroups.includes('checkuser');
} catch (e) {
// Ignore errors; defaults to false
}
// Define the target string for rights log searching
const target = 'User:' + user + '@' + db;
// Initialize variables for log analysis and role calculation
let logText = '',
addTime = null,
removeTime = null,
isSelfAssign = false,
maxDurationMins = -1,
longestTimeStr = "",
assignCount = 0,
hasLocalRightsInPeriod = false;
// 4. DEEP RIGHTS LOG SCAN (Pagination to bypass 500-limit)
let events = [];
let continueToken = null;
let finished = false;
try {
while (!finished) {
let params = {
action: 'query', list: 'logevents', letype: 'rights',
letitle: target, ledir: 'older', lelimit: 'max', formatversion: 2
};
if (continueToken) Object.assign(params, continueToken);
const res = await robustCall(metaApi, params);
const batch = res.query.logevents || [];
events = events.concat(batch);
if (res.continue) {
continueToken = res.continue;
} else {
finished = true;
}
}
let logEntries = [];
let pendingRemoved = null;
let lastPairedDate = null;
for (let i = 0; i < events.length; i++) {
const e = events[i];
const p = e.params || {};
let cuAdded = (p.add || []).includes('checkuser') ||
((p.newgroups || []).includes('checkuser') && !(p.oldgroups || []).includes('checkuser'));
const cuRemoved = (p.remove || []).includes('checkuser') ||
(!(p.newgroups || []).includes('checkuser') && (p.oldgroups || []).includes('checkuser'));
const cuModified = (p.newgroups || []).includes('checkuser') &&
(p.oldgroups || []).includes('checkuser');
if (cuModified) cuAdded = true;
const eventDate = new Date(e.timestamp);
const exactTime = e.timestamp.replace('T', ' ').replace('Z', '');
if (cuAdded) addTime = eventDate;
if (cuRemoved) removeTime = eventDate;
if (addTime && addTime <= auditEnd && (!removeTime || removeTime >= auditStart)) {
hasLocalRightsInPeriod = true;
}
const isInPeriod = (eventDate >= auditStart && eventDate <= auditEnd);
// Process if inside period OR looking for the start of a removal pair
if ((isInPeriod && (cuAdded || cuRemoved)) || (pendingRemoved && cuAdded)) {
let expiryDate = null;
if (p.newmetadata) {
const cuMeta = p.newmetadata.find(m => m.group === 'checkuser');
if (cuMeta && cuMeta.expiry && cuMeta.expiry !== 'infinity') {
expiryDate = new Date(cuMeta.expiry);
}
}
// Label if the performer was the user themselves (Self-removed)
const isSelf = (e.user === user || !e.user) ? " (Self-removed)" : "";
if (e.user === user || !e.user) isSelfAssign = true;
if (cuAdded) {
if (pendingRemoved) {
const diffMins = Math.round(Math.abs(pendingRemoved.date - eventDate) / 60000);
const d = Math.floor(diffMins / 1440);
const h = Math.floor((diffMins % 1440) / 60);
const m = diffMins % 60;
const durStr = `${d > 0 ? d + 'd ' : ''}${h > 0 ? h + 'h ' : ''}${m}m`;
logEntries.unshift(
`* ADDED: ${exactTime} by ${e.user} | REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf} (Duration: ${durStr})`
);
if (diffMins > maxDurationMins) {
maxDurationMins = diffMins;
longestTimeStr = durStr;
}
assignCount++;
pendingRemoved = null;
lastPairedDate = eventDate;
} else if (expiryDate) {
const diffMins = Math.round(Math.abs(expiryDate - eventDate) / 60000);
const d = Math.floor(diffMins / 1440);
const h = Math.floor((diffMins % 1440) / 60);
const m = diffMins % 60;
const durStr = `${d > 0 ? d + 'd ' : ''}${h > 0 ? h + 'h ' : ''}${m}m`;
const exactExpiryTime = expiryDate.toISOString().replace('T', ' ').substring(0, 19);
logEntries.unshift(
`* ADDED: ${exactTime} by ${e.user} | EXPIRED: ${exactExpiryTime} (Duration: ${durStr})`
);
if (diffMins > maxDurationMins) {
maxDurationMins = diffMins;
longestTimeStr = durStr;
}
assignCount++;
lastPairedDate = eventDate;
} else {
if (lastPairedDate && Math.abs(lastPairedDate - eventDate) < 86400000) {
// Skip duplicates
} else {
logEntries.unshift(
`* ADDED: ${exactTime} by ${e.user} | REMOVED: (Active/Not removed)`
);
lastPairedDate = eventDate;
}
}
} else if (cuRemoved) {
if (pendingRemoved) {
logEntries.unshift(
`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf}`
);
}
pendingRemoved = { time: exactTime, date: eventDate, user: e.user, isSelf: isSelf };
}
}
}
if (pendingRemoved) {
logEntries.unshift(
`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf}`
);
}
if (logEntries.length) logText = '\n' + logEntries.join('\n');
// 5. ROLE CLASSIFICATION
let roleLabel = "";
// Check if user has any logged actions or held rights during the period
const hasActions = (results[db] && results[db][user] && results[db][user].total > 0);
let wasCU = hasLocalRightsInPeriod || hasActions;
// Determine the most relevant role for the user
if (isGloballyStaff || historyRes.wasStaff) {
// Priority 1: WMF Staff status
roleLabel = isGloballyStaff ? "Current Staff" : "Former Staff (in period)";
} else if (isCurrentLocal) {
// Priority 2: Currently active local CheckUser
roleLabel = "Current Local CheckUser";
} else if (longestTimeStr && isSelfAssign && (isGloballySteward || historyRes.wasSteward)) {
// Priority 3: Steward acting temporarily (Self-assign detection)
roleLabel = `Steward action (Self-assign: ${assignCount > 1 ? assignCount + 'x, longest ' : ''}${longestTimeStr})`;
} else if (isGloballySteward || historyRes.wasSteward) {
// Priority 4: Standard Steward status
roleLabel = isGloballySteward ? "Current Steward" : "Former Steward (in period)";
} else if (isGloballyOmbuds || historyRes.wasOmbuds) {
// Priority 5: Ombudsman commission status
roleLabel = isGloballyOmbuds ? "Current Ombudsman" : "Former Ombudsman (in period)";
} else if (wasCU) {
// Priority 6: User who was a local CU but no longer is
roleLabel = "Former Local CheckUser (in period)";
} else {
// Default if no specific role matches
roleLabel = "Unknown role";
}
// Return the calculated role and the formatted log entries
return { role: roleLabel, log: logText };
} catch (err) {
// Return an error state if anything crashes during user data processing
return { role: "Error fetching data", log: "" };
}
}
// The main engine that iterates through wikis and collects CheckUser logs.
async function runAudit(yf, mf, yt, mt) {
// Reset all data and state variables for a fresh run
isRunning = true;
userCache = {};
historyCache = {};
results = {};
emptyWikis = [];
failedWikis = [];
scannedWikis = [];
// Parse and prepare the wiki filter list from the UI input
const filterText = $('#wiki-filter').val().trim().toLowerCase();
const filterList = filterText
? filterText.split(',').map(s => s.trim()).filter(s => s !== "")
: [];
// Apply include/exclude logic to the master wiki list
let wikisToScan = rawWikis;
if (currentFilterMode === 'include') {
wikisToScan = rawWikis.filter(w => filterList.includes(w));
} else if (currentFilterMode === 'exclude') {
wikisToScan = rawWikis.filter(w => !filterList.includes(w));
}
if (wikisToScan.length === 0) {
alert("No wikis selected!");
return;
}
// Update UI buttons and progress bar to "running" state
$('#start').prop('disabled', true);
$('#stop').prop('disabled', false);
$('#out').hide();
$('#bar').attr('max', wikisToScan.length).val(0);
// Format the date strings into ISO format 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 columns for the table based on the selected month range
const monthCols = [];
let currY = parseInt(yf), currM = parseInt(mf);
let 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++; }
}
// Loop through each selected wiki and fetch its logs
for (let i = 0; i < wikisToScan.length; i++) {
if (!isRunning) break; // Exit loop if the "Stop" button was clicked
const db = wikisToScan[i];
scannedWikis.push(db);
$('#status-msg').text(`Scanning ${db} (${i + 1}/${wikisToScan.length})...`);
let successLocal = false;
let continueToken = null;
// Inner loop for handling pagination of the logs on a single wiki
while (!successLocal && isRunning) {
try {
const api = new mw.ForeignApi(`https://${getDomain(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);
// Process each entry in the log batch
const ent = res.query?.checkuserlog?.entries || [];
if (ent.length) {
if (!results[db]) results[db] = {};
ent.forEach(e => {
const u = e.checkuser;
if (!results[db][u]) results[db][u] = { total: 0, months: {} };
const mKey = e.timestamp.slice(0, 7);
results[db][u].total++;
results[db][u].months[mKey] = (results[db][u].months[mKey] || 0) + 1;
});
}
// Check if there are more log pages to fetch
if (res.continue && isRunning) {
continueToken = res.continue;
} else {
if (!results[db]) emptyWikis.push(db);
successLocal = true;
}
} catch (err) {
// Record failures (like 429 after retries or 404) and move to the next wiki
failedWikis.push(db);
successLocal = true;
}
}
// Update the progress bar and wait a bit to be kind to the API
$('#bar').val(i + 1);
await sleep(DELAY_MS);
}
// Update UI and prepare the Wikitable header and metadata
$('#status-msg').text(`Generating report...`);
const today = new Date().toISOString().split('T')[0];
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' (UTC)';
// Start building the Wikitext report
let wt = `== Global CheckUser Stats (${mf}/${yf} - ${mt}/${yt}) ==\n''Report generated on: ${timestamp}''\n\n`;
let rightsLog = `\n== Rights Log (In Period) ==\n`;
// Create table structure with dynamic month columns
wt += `{| class="wikitable sortable" style="font-size:90%; text-align:right;"\n`;
wt += `! Wiki / User !! Category !! '''Total''' !! per 1k active users ${monthCols.map(m => `!! ${m.label}`).join(' ')}\n`;
// Sort databases alphabetically and begin processing each project's results
const sortedDBs = Object.keys(results).sort();
for (const db of sortedDBs) {
// Get active user count; if API fails or is 0, display 'Unknown'
const metrics = await fetchWikiMetrics(db);
const fmtActive = metrics.active > 0 ? metrics.active.toLocaleString('en-US') : 'Unknown';
// Prepare interwiki links for the project and its CheckUser log
const iwPrefix = getInterwikiPrefix(db);
const projectLink = `[[${iwPrefix}:|${db}]]`;
const logLink = `[[${iwPrefix}:Special:CheckUserLog|(log)]]`;
wt += `|-\n! colspan="${4 + monthCols.length}" style="background:#eaecf0; text-align:center;" | `;
wt += `${projectLink} <small style="font-weight:normal; color:#54595d;">(Active Users as of ${today}: ${fmtActive})</small> — ${logLink}\n`;
// Initialize local wiki totals and storage for individual user rows
let wikiRows = [];
let wT = 0;
let wMS = {};
monthCols.forEach(col => wMS[col.key] = 0);
// Get users from CU logs AND users who had rights changes (if we tracked them)
// For now, let's at least make sure we don't skip users with 0 actions but active rights
let projectUsers = Object.keys(results[db] || {});
projectUsers.sort();
for (const user of projectUsers) {
// Get role and rights log details for each user
const m = await fetchUserData(user, db, START, END);
// Identify if user belongs to specific global or local groups
const isSteward = m.role.includes("Steward") || m.role.includes("Staff") || m.role.includes("Ombudsman");
const isLocalCU = m.role.includes("Local CheckUser");
// Filter out users if they don't match the selected User Filter (Local/Steward)
if (currentUserFilter === 'local' && !isLocalCU) continue;
if (currentUserFilter === 'steward' && !isSteward) continue;
const uD = results[db][user];
// Aggregate totals for the current wiki
wT += uD.total;
monthCols.forEach(col => wMS[col.key] += (uD.months[col.key] || 0));
// Calculate actions per 1k active users; display 0.0 if project activity is unknown
let uP1kU = metrics.active > 0 ? ((uD.total / metrics.active) * 1000).toFixed(1) : "0.0";
// Build the Wikitext row for the individual user
let row = `|-\n| style="text-align:left;" | ${user}@${db} || <small>${m.role}</small> || '''${uD.total}''' || ${uP1kU}`;
monthCols.forEach(col => row += ` || ${uD.months[col.key] || 0}`);
// Store the formatted row and its corresponding rights log
wikiRows.push({
html: row + "\n",
log: m.log ? `'''${user}@${db}''':${m.log}\n\n` : ""
});
}
// Create the bold TOTAL row for the current wiki
let wP1kU = metrics.active > 0 ? ((wT / metrics.active) * 1000).toFixed(1) : "0.0";
let totalRow = `|- style="background:#f8f9fa; font-weight:bold;"\n| style="text-align:left;" | TOTAL ${db} || — || ${wT} || ${wP1kU}`;
monthCols.forEach(col => totalRow += ` || ${wMS[col.key]}`);
// Append the total row first, then all individual user rows
wt += totalRow + "\n";
wikiRows.forEach(r => {
wt += r.html;
if (r.log) rightsLog += r.log;
});
}
// Append a list of wikis where no CheckUser actions were found
wt += `|}\n\n== Projects with 0 actions ==\n`;
wt += `<div style="font-size:85%; color:#54595d;">${emptyWikis.sort().join(', ')}</div>\n`;
// Append a list of projects that failed to scan (e.g., due to API errors)
if (failedWikis.length > 0) {
wt += `\n== API Errors / Skipped Projects ==\n`;
wt += `<div style="font-size:85%; color:#d33;">${failedWikis.sort().join(', ')}</div>\n`;
}
// Finalize the report with the Rights Log section
wt += rightsLog + `\n\n<references />\n`;
// Display the final output and reset UI buttons
$('#out').val(wt).show();
$('#status-msg').text(`Done.`);
$('#start').prop('disabled', false);
$('#stop').prop('disabled', true);
}
// Initialize the interface on page load
setupUI();
});
})();
dr9tw0p9fdrpqc2i9vhm1zgu5ovdymb
737762
737761
2026-04-12T17:20:54Z
MrJaroslavik
44012
e
737762
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]].
// - Full Reporting: Outputs sortable Wikitables and detailed rights change logs.
// - With help of Gemini 3
// -------------------------------------------------------
// Wrap everything so this script doesn't break other Wikipedia scripts
(function () {
'use strict';
// Stop immediately if we are NOT on the specific GlobalCheckUserStats page
if (
mw.config.get('wgCanonicalSpecialPageName') !== 'Blankpage' ||
mw.config.get('wgTitle').indexOf('GlobalCheckUserStats') === -1
) return;
// A fixed list of all valid Wikimedia projects to scan
const rawWikis = ['abstractwiki', 'abwiki', 'acewiki', 'adywiki', 'afwiki', 'afwikibooks', 'afwikiquote', 'afwiktionary', 'alswiki', 'altwiki', 'amiwiki', 'amwiki', 'amwiktionary', 'angwiki', 'angwiktionary', 'annwiki', 'anpwiki', 'anwiki', 'anwiktionary', 'arcwiki', 'arwiki', 'arwikibooks', 'arwikimedia', 'arwikinews', 'arwikiquote', 'arwikisource', 'arwikiversity', 'arwiktionary', 'arywiki', 'arzwiki', 'astwiki', 'astwiktionary', 'aswiki', 'aswikiquote', 'aswikisource', 'atjwiki', 'avkwiki', 'avwiki', 'awawiki', 'aywiki', 'aywiktionary', 'azbwiki', 'azwiki', 'azwikibooks', 'azwikiquote', 'azwikisource', 'azwiktionary', 'banwiki', 'banwikisource', 'barwiki', 'bat_smgwiki', 'bawiki', 'bawikibooks', 'bbcwiki', 'bclwiki', 'bclwikiquote', 'bclwikisource', 'bclwiktionary', 'bdrwiki', 'bdwikimedia', 'be_x_oldwiki', 'betawikiversity', 'bewiki', 'bewikibooks', 'bewikimedia', 'bewikiquote', 'bewikisource', 'bewiktionary', 'bewwiki', 'bewwiktionary', 'bgwiki', 'bgwikibooks', 'bgwikiquote', 'bgwikisource', 'bgwiktionary', 'bhwiki', 'biwiki', 'bjnwiki', 'bjnwikiquote', 'bjnwiktionary', 'blkwiki', 'blkwiktionary', 'bmwiki', 'bnwiki', 'bnwikibooks', 'bnwikiquote', 'bnwikisource', 'bnwikivoyage', 'bnwiktionary', 'bowiki', 'bpywiki', 'brwiki', 'brwikimedia', 'brwikiquote', 'brwikisource', 'brwiktionary', 'bswiki', 'bswikibooks', 'bswikinews', 'bswikiquote', 'bswikisource', 'bswiktionary', 'btmwiki', 'btmwiktionary', 'bugwiki', 'bxrwiki', 'cawiki', 'cawikibooks', 'cawikimedia', 'cawikinews', 'cawikiquote', 'cawikisource', 'cawiktionary', 'cbk_zamwiki', 'cdowiki', 'cebwiki', 'cewiki', 'chrwiki', 'chrwiktionary', 'chwiki', 'chywiki', 'ckbwiki', 'ckbwiktionary', 'commonswiki', 'cowiki', 'cowikimedia', 'cowiktionary', 'crhwiki', 'csbwiki', 'csbwiktionary', 'cswiki', 'cswikibooks', 'cswikinews', 'cswikiquote', 'cswikisource', 'cswikiversity', 'cswikivoyage', 'cswiktionary', 'cuwiki', 'cvwiki', 'cvwikibooks', 'cywiki', 'cywikibooks', 'cywikiquote', 'cywikisource', 'cywiktionary', 'dagwiki', 'dawiki', 'dawikibooks', 'dawikiquote', 'dawikisource', 'dawiktionary', 'dewiki', 'dewikibooks', 'dewikinews', 'dewikiquote', 'dewikisource', 'dewikiversity', 'dewikivoyage', 'dewiktionary', 'dgawiki', 'dinwiki', 'diqwiki', 'diqwiktionary', 'dkwikimedia', 'dsbwiki', 'dtpwiki', 'dtywiki', 'dvwiki', 'dvwiktionary', 'dzwiki', 'eewiki', 'elwiki', 'elwikibooks', 'elwikinews', 'elwikiquote', 'elwikisource', 'elwikiversity', 'elwikivoyage', 'elwiktionary', 'emlwiki', 'enwiki', 'enwikibooks', 'enwikinews', 'enwikiquote', 'enwikisource', 'enwikiversity', 'enwikivoyage', 'enwiktionary', 'eowiki', 'eowikibooks', 'eowikinews', 'eowikiquote', 'eowikisource', 'eowikivoyage', 'eowiktionary', 'eswiki', 'eswikibooks', 'eswikinews', 'eswikiquote', 'eswikisource', 'eswikiversity', 'eswikivoyage', 'eswiktionary', 'etwiki', 'etwikibooks', 'eewikimedia', 'etwikiquote', 'etwikisource', 'etwiktionary', 'euwiki', 'euwikibooks', 'euwikiquote', 'euwikisource', 'euwiktionary', 'extwiki', 'fatwiki', 'fawiki', 'fawikibooks', 'fawikinews', 'fawikiquote', 'fawikisource', 'fawikivoyage', 'fawiktionary', 'ffwiki', 'fiu_vrowiki', 'fiwiki', 'fiwikibooks', 'fiwikimedia', 'fiwikinews', 'fiwikiquote', 'fiwikisource', 'fiwikiversity', 'fiwikivoyage', 'fiwiktionary', 'fjwiki', 'fjwiktionary', 'fonwiki', 'foundationwiki', 'fowiki', 'fowikisource', 'fowiktionary', 'frpwiki', 'frrwiki', 'frwiki', 'frwikibooks', 'frwikinews', 'frwikiquote', 'frwikisource', 'frwikiversity', 'frwikivoyage', 'frwiktionary', 'furwiki', 'fywiki', 'fywikibooks', 'fywiktionary', 'gagwiki', 'ganwiki', 'gawiki', 'gawiktionary', 'gcrwiki', 'gdwiki', 'gdwiktionary', 'glkwiki', 'glwiki', 'glwikibooks', 'glwikiquote', 'glwikisource', 'glwiktionary', 'gnwiki', 'gnwiktionary', 'gomwiki', 'gomwiktionary', 'gorwiki', 'gorwikiquote', 'gorwiktionary', 'gotwiki', 'gpewiki', 'gucwiki', 'gurwiki', 'guwiki', 'guwikiquote', 'guwikisource', 'guwiktionary', 'guwwiki', 'guwwikinews', 'guwwikiquote', 'guwwiktionary', 'gvwiki', 'gvwiktionary', 'hakwiki', 'hawiki', 'hawiktionary', 'hawwiki', 'hewiki', 'hewikibooks', 'hewikinews', 'hewikiquote', 'hewikisource', 'hewikivoyage', 'hewiktionary', 'hifwiki', 'hifwiktionary', 'hiwiki', 'hiwikibooks', 'hiwikiquote', 'hiwikisource', 'hiwikiversity', 'hiwikivoyage', 'hiwiktionary', 'hrwiki', 'hrwikibooks', 'hrwikiquote', 'hrwikisource', 'hrwiktionary', 'hsbwiki', 'hsbwiktionary', 'htwiki', 'huwiki', 'huwikibooks', 'huwikisource', 'huwiktionary', 'hywiki', 'hywikibooks', 'hywikiquote', 'hywikisource', 'hywiktionary', 'hywwiki', 'iawiki', 'iawikibooks', 'iawiktionary', 'ibawiki', 'idwiki', 'idwikibooks', 'idwikiquote', 'idwikisource', 'idwikivoyage', 'idwiktionary', 'iewiki', 'iewiktionary', 'iglwiki', 'igwiki', 'igwikiquote', 'igwiktionary', 'ikwiki', 'ilowiki', 'incubatorwiki', 'inhwiki', 'iowiki', 'iowiktionary', 'iswiki', 'iswikibooks', 'iswikiquote', 'iswikisource', 'iswiktionary', 'itwiki', 'itwikibooks', 'itwikinews', 'itwikiquote', 'itwikisource', 'itwikiversity', 'itwikivoyage', 'itwiktionary', 'iuwiki', 'iuwiktionary', 'jamwiki', 'jawiki', 'jawikibooks', 'jawikinews', 'jawikisource', 'jawikiversity', 'jawikivoyage', 'jawiktionary', 'jbowiki', 'jbowiktionary', 'jvwiki', 'jvwikisource', 'jvwiktionary', 'kaawiki', 'kaawiktionary', 'kabwiki', 'kaiwiki', 'kajwiki', 'kawiki', 'kawikibooks', 'kawikiquote', 'kawikisource', 'kawiktionary', 'kbdwiki', 'kbdwiktionary', 'kbpwiki', 'kcgwiki', 'kcgwiktionary', 'kgewiki', 'kgwiki', 'kiwiki', 'kkwiki', 'kkwikibooks', 'kkwiktionary', 'klwiktionary', 'kmwiki', 'kmwikibooks', 'kmwiktionary', 'kncwiki', 'knwiki', 'knwikiquote', 'knwikisource', 'knwiktionary', 'koiwiki', 'kowiki', 'kowikibooks', 'kowikinews', 'kowikiquote', 'kowikisource', 'kowikiversity', 'kowiktionary', 'krcwiki', 'kshwiki', 'kswiki', 'kswiktionary', 'kuswiki', 'kuwiki', 'kuwikibooks', 'kuwikiquote', 'kuwiktionary', 'kvwiki', 'kwwiki', 'kwwiktionary', 'kywiki', 'kywikiquote', 'kywiktionary', 'labswiki', 'ladwiki', 'lawiki', 'lawikibooks', 'lawikiquote', 'lawikisource', 'lawiktionary', 'lbewiki', 'lbwiki', 'lbwiktionary', 'lezwiki', 'lfnwiki', 'lgwiki', 'lijwiki', 'lijwikisource', 'liwiki', 'liwikibooks', 'liwikinews', 'liwikiquote', 'liwikisource', 'liwiktionary', 'lldwiki', 'lmowiki', 'lmowiktionary', 'lnwiki', 'lnwiktionary', 'lowiki', 'lowiktionary', 'ltgwiki', 'ltwiki', 'ltwikibooks', 'ltwikiquote', 'ltwikisource', 'ltwiktionary', 'lvwiki', 'lvwiktionary', 'madwiki', 'madwikisource', 'madwiktionary', 'maiwiki', 'map_bmswiki', 'mdfwiki', 'mediawikiwiki', 'metawiki', 'mgwiki', 'mgwikibooks', 'mgwiktionary', 'mhrwiki', 'minwiki', 'minwikibooks', 'minwikisource', 'minwiktionary', 'miwiki', 'miwiktionary', 'mkwiki', 'mkwikibooks', 'mkwikimedia', 'mkwikisource', 'mkwiktionary', 'mlwiki', 'mlwikibooks', 'mlwikiquote', 'mlwikisource', 'mlwiktionary', 'mniwiki', 'mniwiktionary', 'mnwiki', 'mnwiktionary', 'mnwwiki', 'mnwwiktionary', 'moswiki', 'mrjwiki', 'mrwiki', 'mrwikibooks', 'mrwikiquote', 'mrwikisource', 'mrwiktionary', 'mswiki', 'mswikibooks', 'mswikiquote', 'mswikisource', 'mswiktionary', 'mtwiki', 'mtwiktionary', 'mwlwiki', 'mxwikimedia', 'myvwiki', 'mywiki', 'mywikisource', 'mywiktionary', 'mznwiki', 'nahwiki', 'nahwiktionary', 'napwiki', 'napwikisource', 'nawiktionary', 'nds_nlwiki', 'ndswiki', 'ndswiktionary', 'newiki', 'newikibooks', 'newiktionary', 'newwiki', 'niawiki', 'niawiktionary', 'nlwiki', 'nlwikibooks', 'nlwikimedia', 'nlwikinews', 'nlwikiquote', 'nlwikisource', 'nlwikivoyage', 'nlwiktionary', 'nnwiki', 'nnwikiquote', 'nnwiktionary', 'novwiki', 'nowiki', 'nowikibooks', 'nowikimedia', 'nowikinews', 'nowikiquote', 'nowikisource', 'nowiktionary', 'nqowiki', 'nrmwiki', 'nrwiki', 'nsowiki', 'nupwiki', 'nvwiki', 'nycwikimedia', 'nywiki', 'ocwiki', 'ocwikibooks', 'ocwiktionary', 'olowiki', 'omwiki', 'omwiktionary', 'orwiki', 'orwikisource', 'orwiktionary', 'oswiki', 'outreachwiki', 'pagwiki', 'pamwiki', 'papwiki', 'pawiki', 'pawikibooks', 'pawikisource', 'pawiktionary', 'pcdwiki', 'pcmwiki', 'pcmwikiquote', 'pdcwiki', 'pflwiki', 'piwiki', 'plwiki', 'plwikibooks', 'plwikimedia', 'plwikinews', 'plwikiquote', 'plwikisource', 'plwikivoyage', 'plwiktionary', 'pmswiki', 'pmswikisource', 'pnbwiki', 'pnbwiktionary', 'pntwiki', 'pplwiki', 'pswiki', 'pswikivoyage', 'pswiktionary', 'ptwiki', 'ptwikibooks', 'ptwikimedia', 'ptwikinews', 'ptwikiquote', 'ptwikisource', 'ptwikiversity', 'ptwikivoyage', 'ptwiktionary', 'pwnwiki', 'quwiktionary', 'rkiwiki', 'rmwiki', 'rmywiki', 'rnwiki', 'roa_rupwiki', 'roa_rupwiktionary', 'roa_tarawiki', 'rowiki', 'rowikibooks', 'rowikinews', 'rowikiquote', 'rowiktionary', 'rskwiki', 'ruewiki', 'ruwiki', 'ruwikibooks', 'ruwikinews', 'ruwikiquote', 'ruwikisource', 'ruwikiversity', 'ruwikivoyage', 'ruwiktionary', 'rwwiki', 'rwwiktionary', 'sahwiki', 'sahwikiquote', 'sahwikisource', 'satwiki', 'satwiktionary', 'sawiki', 'sawikibooks', 'sawikiquote', 'sawikisource', 'sawiktionary', 'scnwiki', 'scnwiktionary', 'scowiki', 'scwiki', 'sdwiki', 'sdwiktionary', 'sewiki', 'sewikimedia', 'sgwiki', 'sgwiktionary', 'shiwiki', 'shnwiki', 'shnwikibooks', 'shnwikinews', 'shnwikivoyage', 'shnwiktionary', 'shwiki', 'shwiktionary', 'shywiktionary', 'simplewiki', 'simplewiktionary', 'siwiki', 'siwikibooks', 'siwiktionary', 'skwiki', 'skrwiki', 'skrwiktionary', 'slwiki', 'slwikibooks', 'slwikiquote', 'slwikisource', 'slwikiversity', 'slwiktionary', 'smnwiki', 'smwiki', 'smwiktionary', 'snwiki', 'sourceswiki', 'sowiki', 'sowiktionary', 'specieswiki', 'sqwiki', 'sqwikibooks', 'sqwikinews', 'sqwikiquote', 'sqwiktionary', 'srnwiki', 'srwiki', 'srwikibooks', 'srwikinews', 'srwikiquote', 'srwikisource', 'srwiktionary', 'sswiki', 'sswiktionary', 'stqwiki', 'stwiki', 'stwiktionary', 'suwiki', 'suwikiquote', 'suwikisource', 'suwiktionary', 'svwiki', 'svwikibooks', 'svwikinews', 'svwikiquote', 'svwikisource', 'svwikiversity', 'svwikivoyage', 'svwiktionary', 'swwiki', 'swwiktionary', 'sylwiki', 'szlwiki', 'szywiki', 'tawiki', 'tawikibooks', 'tawikinews', 'tawikiquote', 'tawikisource', 'tawiktionary', 'taywiki', 'tcywiki', 'tcywikisource', 'tcywiktionary', 'tddwiki', 'tewiki', 'tewikibooks', 'tewikiquote', 'tewikisource', 'tewiktionary', 'tgwiki', 'tgwikibooks', 'tgwiktionary', 'thwiki', 'thwikibooks', 'thwikimedia', 'thwikiquote', 'thwikisource', 'thwiktionary', 'tigwiki', 'tiwiki', 'tiwiktionary', 'tkwiki', 'tkwiktionary', 'tlwiki', 'tlwikibooks', 'tlwikiquote', 'tlwikisource', 'tlwiktionary', 'tlywiki', 'tnwiki', 'tnwiktionary', 'tokwiki', 'towiki', 'tpiwiki', 'tpiwiktionary', 'trvwiki', 'trwiki', 'trwikibooks', 'trwikimedia', 'trwikinews', 'trwikiquote', 'trwikisource', 'trwikivoyage', 'trwiktionary', 'tswiki', 'tswiktionary', 'ttwiki', 'ttwikibooks', 'ttwikiquote', 'ttwiktionary', 'tumwiki', 'twwiki', 'twwiktionary', 'tyvwiki', 'tywiki', 'uawikimedia', 'udmwiki', 'ugwiki', 'ugwiktionary', 'ukwiki', 'ukwikibooks', 'ukwikinews', 'ukwikiquote', 'ukwikisource', 'ukwikivoyage', 'ukwiktionary', 'urwiki', 'urwikibooks', 'urwikiquote', 'urwikisource', 'urwiktionary', 'uzwiki', 'uzwikiquote', 'uzwiktionary', 'vecwiki', 'vecwikisource', 'vecwiktionary', 'vepwiki', 'vewiki', 'viwiki', 'viwikibooks', 'viwikiquote', 'viwikisource', 'viwikivoyage', 'viwiktionary', 'vlswiki', 'vowiki', 'vowiktionary', 'warwiki', 'wawiki', 'wawikisource', 'wawiktionary', 'wikidatawiki', 'wikimaniawiki', 'wowiki', 'wowiktionary', 'wuuwiki', 'xalwiki', 'xhwiki', 'xmfwiki', 'yiwiki', 'yiwikisource', 'yiwiktionary', 'yowiki', 'zawiki', 'zeawiki', 'zghwiki', 'zghwiktionary', 'zh_classicalwiki', 'zh_min_nanwiki', 'zh_min_nanwikisource', 'zh_min_nanwiktionary', 'zh_yuewiki', 'zhwiki', 'zhwikibooks', 'zhwikinews', 'zhwikiquote', 'zhwikisource', 'zhwikiversity', 'zhwikivoyage', 'zhwiktionary', 'zuwiki', 'zuwiktionary', 'test2wiki', 'testwiki'];
// Time delay between API requests to prevent rate limiting (429 errors)
const DELAY_MS = 800;
// Helper function to pause execution for a specified number of milliseconds
const sleep = ms => new Promise(r => setTimeout(r, ms));
// Cache for global user group information to avoid redundant API calls
let userCache = {};
// Cache for historical global rights changes within the audit period
let historyCache = {};
// Helper function to handle API calls with up to 3 retries on 429 errors
async function robustCall(api, params) {
let retries = 0;
const maxRetries = 3;
// Continue attempting the API request as long as we haven't hit the maximum retry limit
while (retries < maxRetries) {
try {
// Attempt to execute the API GET request
return await api.get(params);
} catch (err) {
// If the error is "429 Too Many Requests" (rate limit)
if (err?.status === 429) {
retries++;
// Get wait time from 'Retry-After' header, or use a default backoff (30s, 60s, 90s)
const retryAfter = parseInt(err.xhr?.getResponseHeader('Retry-After')) || (30 * retries);
// Update UI status message with the remaining wait time
$('#status-msg').html(
`<span style="color:orange; font-weight:bold;">Rate limit hit! Resting ${retryAfter}s... (Attempt ${retries}/${maxRetries})</span>`
);
// Pause execution before the next retry
await sleep(retryAfter * 1000);
} else {
// Rethrow any error that is not a rate limit (e.g., 404, 403, 500)
throw err;
}
}
}
// Fail if max retries are exceeded
throw new Error("Max retries reached");
}
// Converts a database name (like 'enwiki') into a real web address.
function getDomain(db) {
// Static mapping for projects with non-standard naming conventions
const mapping = {
'commonswiki': 'commons.wikimedia.org',
'metawiki': 'meta.wikimedia.org',
'wikidatawiki': 'www.wikidata.org',
'mediawikiwiki': 'www.mediawiki.org',
'sourceswiki': 'wikisource.org',
'foundationwiki': 'foundation.wikimedia.org',
'incubatorwiki': 'incubator.wikimedia.org',
'outreachwiki': 'outreach.wikimedia.org',
'be_x_oldwiki': 'be-tarask.wikipedia.org',
'labswiki': 'wikitech.wikimedia.org',
'wikimaniawiki': 'wikimania.wikimedia.org',
'specieswiki': 'species.wikimedia.org',
'abstractwiki': 'abstract.wikipedia.org',
'betawikiversity': 'beta.wikiversity.org',
'mo_wiki': 'ro.wikipedia.org',
'testwiki': 'test.wikipedia.org',
'test2wiki': 'test2.wikipedia.org',
'wikifunctionswiki': 'www.wikifunctions.org',
'testcommonswiki': 'test-commons.wikimedia.org',
'testwikidatawiki': 'test.wikidata.org',
};
// Return early if an explicit mapping exists
if (mapping[db]) return mapping[db];
// Format language codes: replace underscores with hyphens (e.g., zh_yue -> zh-yue)
const name = db.replace(/_/g, '-');
// Handle sister project families by trimming suffixes and appending correct domains
if (name.endsWith('wikisource')) return name.slice(0, -10) + '.wikisource.org';
if (name.endsWith('wikiversity')) return name.slice(0, -11) + '.wikiversity.org';
if (name.endsWith('wiktionary')) return name.slice(0, -10) + '.wiktionary.org';
if (name.endsWith('wikivoyage')) return name.slice(0, -10) + '.wikivoyage.org';
if (name.endsWith('wikibooks')) return name.slice(0, -9) + '.wikibooks.org';
if (name.endsWith('wikiquote')) return name.slice(0, -9) + '.wikiquote.org';
if (name.endsWith('wikinews')) return name.slice(0, -8) + '.wikinews.org';
if (name.endsWith('wikimedia')) return name.slice(0, -9) + '.wikimedia.org';
// Default logic for Wikipedias: remove 'wiki' suffix and append domain
if (name.endsWith('wiki')) return name.slice(0, -4) + '.wikipedia.org';
// Generic fallback
return name + '.wikipedia.org';
}
function getInterwikiPrefix(db) {
// Mapping for special projects that use non-standard interwiki shortcuts
const specials = {
'commonswiki': 'commons',
'metawiki': 'm',
'wikidatawiki': 'd',
'mediawikiwiki': 'mw',
'specieswiki': 'wikispecies',
'foundationwiki': 'wikimedia',
'sourceswiki': 'wikisource',
};
// Return early if an explicit special prefix is defined
if (specials[db]) return specials[db];
// Format name for interwiki consistency (underscores to hyphens)
const name = db.replace(/_/g, '-');
// Logic for sister project families: trim suffixes and append the standard shorthand
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 'wikimedia:' + name.slice(0, -9);
// Standard Wikipedia logic: removes 'wiki' suffix and adds 'w:' prefix
if (name.endsWith('wiki')) return 'w:' + name.slice(0, -4);
// Default fallback for any other database patterns
return 'w:' + name;
}
// Ensure required MediaWiki API and ForeignApi modules are loaded before execution
mw.loader.using(['mediawiki.api', 'mediawiki.ForeignApi']).then(function () {
// Initialize API connection to Meta-Wiki for global user information and rights logs
const metaApi = new mw.ForeignApi('https://meta.wikimedia.org/w/api.php');
// Capture current date/time for report generation and timestamping
const now = new Date();
// State management variables:
let results = {}, // Stores aggregated CU action counts per wiki/user
emptyWikis = [], // List of projects with no recorded CU actions
failedWikis = [], // List of projects that encountered API errors
scannedWikis = [], // Tracking list of successfully processed projects
isRunning = false; // Flag to control the start/stop state of the audit
// UI state variables for filtering wikis and user roles
let currentFilterMode = 'all';
let currentUserFilter = 'all';
// Initializes and builds the User Interface (UI) elements on the page.
function setupUI() {
// Get the current year to ensure the date range is always up-to-date
const currentYear = now.getFullYear();
// Generate HTML options for the year selection (starting from 2005 to current)
let yearOpts = '';
for (let y = currentYear; y >= 2005; y--) {
yearOpts += `<option value="${y}">${y}</option>`;
}
// Prepare HTML options for month selection (1-12)
let monthOptsFrom = '';
let monthOptsTo = '';
for (let m = 1; m <= 12; m++) {
// Set default: 'From' dropdown defaults to January (1)
monthOptsFrom += `<option value="${m}" ${m === 1 ? 'selected' : ''}>${m}</option>`;
// Set default: 'To' dropdown defaults to December (12)
monthOptsTo += `<option value="${m}" ${m === 12 ? 'selected' : ''}>${m}</option>`;
}
// Set the page title to identify the script
$('#firstHeading').text('GlobalCheckUserStats.js');
// Clear the main content area and append the control panel HTML
$('#mw-content-text').empty().append(`
<div style="border:1px solid #a2a9b1; padding:15px; background:#f8f9fa;">
<h3>Stats Range</h3>
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 style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>Wiki Filter:</strong>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="wiki-mode" id="btn-all" checked> All wikis</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="wiki-mode" id="btn-except"> All wikis except</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><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; font-size:12px; line-height:18px;" title="Show all wiki DB names">?</span>
</div>
<div id="filter-input-container" style="display:none; margin-top:10px;">
<input id="wiki-filter" type="text" style="width:100%; max-width:600px;" placeholder="">
</div>
<div style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>User Filter:</strong>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-all" checked> All users</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-local"> Only Local CUs</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-steward"> Only Stewards</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; color:#202122;">
<strong>Available Wikis (alphabetical):</strong><br>${[...rawWikis].sort().join(', ')}
</div>
<div style="margin-top:20px;">
<button id="start" class="mw-ui-button mw-ui-progressive">Run GlobalCheckUserStats.js</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="${rawWikis.length}" style="width:100%"></progress></div>
<textarea id="out" style="width:100%; height:450px; margin-top:10px; display:none; font-family:monospace; font-size:12px;"></textarea>
</div>
`);
// Handle clicking 'All wikis': resets filter mode and hides the input field
$('#btn-all').click(() => {
currentFilterMode = 'all';
$('#filter-input-container').hide();
});
// Handle clicking 'All wikis except': shows input field, sets mode to 'exclude', and focuses the box
$('#btn-except').click(() => {
currentFilterMode = 'exclude';
$('#filter-input-container').show();
$('#wiki-filter').attr('placeholder', 'Exclude (dbname1, dbname2)...').focus();
});
// Handle clicking 'Only these wikis': shows input field, sets mode to 'include', and focuses the box
$('#btn-only').click(() => {
currentFilterMode = 'include';
$('#filter-input-container').show();
$('#wiki-filter').attr('placeholder', 'Only (dbname1, dbname2)...').focus();
});
// User role filter buttons: define which user categories will appear in the final table
$('#u-all').click(() => { currentUserFilter = 'all'; });
$('#u-local').click(() => { currentUserFilter = 'local'; });
$('#u-steward').click(() => { currentUserFilter = 'steward'; });
// Toggle visibility of the 'Available Wikis' help list when the question mark icon is clicked
$('#wiki-help-trigger').click(() => $('#wiki-list-help').toggle());
// Main execution trigger: starts the audit using the selected date range from dropdowns
$('#start').click(() => runAudit($('#y-f').val(), $('#m-f').val(), $('#y-t').val(), $('#m-t').val()));
// Stop button: sets isRunning to false to halt the loop between wiki scans
$('#stop').click(() => isRunning = false);
}
// Fetches general statistics for a specific wiki, primarily to get the active user count.
async function fetchWikiMetrics(db) {
try {
const api = new mw.ForeignApi(`https://${getDomain(db)}/w/api.php`);
// Request site statistics via the 'siteinfo' meta module
const res = await robustCall(api, {
action: 'query', meta: 'siteinfo', siprop: 'statistics', formatversion: 2
});
// Extract the active users count; default to 0 if the data is missing
return { active: res.query.statistics.activeusers || 0 };
} catch (e) {
// Graceful fallback: return zero if the project is unreachable or API fails
return { active: 0 };
}
}
// Checks if a user held global roles (Steward, Staff, Ombuds) during the audit period.
async function checkGlobalHistory(user, start, end) {
try {
// Fetch global rights logs from Meta-Wiki for the specific user
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 state = { steward: false, staff: false, ombuds: false };
// Look at the last log entry BEFORE the audit started to see the initial status
const priorLogs = logs
.filter(l => new Date(l.timestamp) <= auditStart)
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
if (priorLogs.length > 0) {
const p = priorLogs[0].params || {};
const groupsInLog = p.newGroups || p[1] || [];
state.steward = groupsInLog.includes('steward');
state.staff = groupsInLog.includes('staff');
state.ombuds = groupsInLog.includes('ombuds');
}
// Keep track of any global roles gained during the audit period itself
let held = { wasSteward: state.steward, wasStaff: state.staff, wasOmbuds: state.ombuds };
logs
.filter(l => {
const ts = new Date(l.timestamp);
return ts > auditStart && ts < auditEnd;
})
.forEach(e => {
const p = e.params || {};
const added = p.newGroups || p[1] || [];
if (added.includes('steward')) held.wasSteward = true;
if (added.includes('staff')) held.wasStaff = true;
if (added.includes('ombuds')) held.wasOmbuds = true;
});
return held;
} catch (e) {
// If anything fails, assume no global roles were held
return { wasSteward: false, wasStaff: false, wasOmbuds: false };
}
}
// Gathers all necessary data for a specific user on a specific wiki.
async function fetchUserData(user, db, start, end) {
// Convert start and end strings into Date objects for easier comparison
const auditStart = new Date(start);
const auditEnd = new Date(end);
// 1. FETCH CURRENT GLOBAL GROUPS (Using cache to save time)
if (!userCache[user]) {
try {
// Ask Meta-Wiki for the user's current global groups (steward, staff, etc.)
const gres = await robustCall(metaApi, {
action: 'query', meta: 'globaluserinfo', guiprop: 'groups',
guiuser: user, formatversion: 2
});
userCache[user] = gres.query.globaluserinfo.groups || [];
} catch (e) {
// Fallback to an empty list if the request fails
userCache[user] = [];
}
}
// Check if the user currently holds any key global roles
const gGroups = userCache[user];
const isGloballySteward = gGroups.includes('steward');
const isGloballyStaff = gGroups.includes('staff');
const isGloballyOmbuds = gGroups.includes('ombuds') || gGroups.includes('ombudsman');
// 2. FETCH GLOBAL HISTORY (Identify roles held during the period but lost since)
if (!historyCache[user]) {
historyCache[user] = await checkGlobalHistory(user, start, end);
}
const historyRes = historyCache[user];
// 3. CHECK CURRENT LOCAL CHECKUSER STATUS
let isCurrentLocal = false;
try {
// Connect to the local wiki to see if the user is currently a CheckUser
const localApi = new mw.ForeignApi(`https://${getDomain(db)}/w/api.php`);
const ures = await robustCall(localApi, {
action: 'query', list: 'users', ususers: user,
usprop: 'groups', formatversion: 2
});
const lGroups = (ures.query.users[0] && ures.query.users[0].groups) || [];
isCurrentLocal = lGroups.includes('checkuser');
} catch (e) {
// Ignore errors; defaults to false
}
// Define the target string for rights log searching
const target = 'User:' + user + '@' + db;
// Initialize variables for log analysis and role calculation
let logText = '',
addTime = null,
removeTime = null,
isSelfAssign = false,
maxDurationMins = -1,
longestTimeStr = "",
assignCount = 0,
hasLocalRightsInPeriod = false;
// 4. DEEP RIGHTS LOG SCAN (Pagination to bypass 500-limit)
let events = [];
let continueToken = null;
let finished = false;
try {
while (!finished) {
let params = {
action: 'query', list: 'logevents', letype: 'rights',
letitle: target, ledir: 'older', lelimit: 'max', formatversion: 2
};
if (continueToken) Object.assign(params, continueToken);
const res = await robustCall(metaApi, params);
const batch = res.query.logevents || [];
events = events.concat(batch);
if (res.continue) {
continueToken = res.continue;
} else {
finished = true;
}
}
let logEntries = [];
let pendingRemoved = null;
let lastPairedDate = null;
for (let i = 0; i < events.length; i++) {
const e = events[i];
const p = e.params || {};
let cuAdded = (p.add || []).includes('checkuser') ||
((p.newgroups || []).includes('checkuser') && !(p.oldgroups || []).includes('checkuser'));
const cuRemoved = (p.remove || []).includes('checkuser') ||
(!(p.newgroups || []).includes('checkuser') && (p.oldgroups || []).includes('checkuser'));
const cuModified = (p.newgroups || []).includes('checkuser') &&
(p.oldgroups || []).includes('checkuser');
if (cuModified) cuAdded = true;
const eventDate = new Date(e.timestamp);
const exactTime = e.timestamp.replace('T', ' ').replace('Z', '');
if (cuAdded) addTime = eventDate;
if (cuRemoved) removeTime = eventDate;
if (addTime && addTime <= auditEnd && (!removeTime || removeTime >= auditStart)) {
hasLocalRightsInPeriod = true;
}
const isInPeriod = (eventDate >= auditStart && eventDate <= auditEnd);
// Process if inside period OR looking for the start of a removal pair
if ((isInPeriod && (cuAdded || cuRemoved)) || (pendingRemoved && cuAdded)) {
let expiryDate = null;
if (p.newmetadata) {
const cuMeta = p.newmetadata.find(m => m.group === 'checkuser');
if (cuMeta && cuMeta.expiry && cuMeta.expiry !== 'infinity') {
expiryDate = new Date(cuMeta.expiry);
}
}
// Label if the performer was the user themselves (Self-removed)
const isSelf = (e.user === user || !e.user) ? " (Self-removed)" : "";
if (e.user === user || !e.user) isSelfAssign = true;
if (cuAdded) {
if (pendingRemoved) {
const diffMins = Math.round(Math.abs(pendingRemoved.date - eventDate) / 60000);
const d = Math.floor(diffMins / 1440);
const h = Math.floor((diffMins % 1440) / 60);
const m = diffMins % 60;
const durStr = `${d > 0 ? d + 'd ' : ''}${h > 0 ? h + 'h ' : ''}${m}m`;
logEntries.unshift(
`* ADDED: ${exactTime} by ${e.user} | REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf} (Duration: ${durStr})`
);
if (diffMins > maxDurationMins) {
maxDurationMins = diffMins;
longestTimeStr = durStr;
}
assignCount++;
pendingRemoved = null;
lastPairedDate = eventDate;
} else if (expiryDate) {
const diffMins = Math.round(Math.abs(expiryDate - eventDate) / 60000);
const d = Math.floor(diffMins / 1440);
const h = Math.floor((diffMins % 1440) / 60);
const m = diffMins % 60;
const durStr = `${d > 0 ? d + 'd ' : ''}${h > 0 ? h + 'h ' : ''}${m}m`;
const exactExpiryTime = expiryDate.toISOString().replace('T', ' ').substring(0, 19);
logEntries.unshift(
`* ADDED: ${exactTime} by ${e.user} | EXPIRED: ${exactExpiryTime} (Duration: ${durStr})`
);
if (diffMins > maxDurationMins) {
maxDurationMins = diffMins;
longestTimeStr = durStr;
}
assignCount++;
lastPairedDate = eventDate;
} else {
if (lastPairedDate && Math.abs(lastPairedDate - eventDate) < 86400000) {
// Skip duplicates
} else {
logEntries.unshift(
`* ADDED: ${exactTime} by ${e.user} | REMOVED: (Active/Not removed)`
);
lastPairedDate = eventDate;
}
}
} else if (cuRemoved) {
if (pendingRemoved) {
logEntries.unshift(
`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf}`
);
}
pendingRemoved = { time: exactTime, date: eventDate, user: e.user, isSelf: isSelf };
}
}
}
if (pendingRemoved) {
logEntries.unshift(
`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf}`
);
}
if (logEntries.length) logText = '\n' + logEntries.join('\n');
//5. ROLE CLASSIFICATION
let roles = [];
const hasActions = (results[db] && results[db][user] && results[db][user].total > 0);
let wasCU = hasLocalRightsInPeriod || hasActions;
// 1. WMF Staff status
if (isGloballyStaff || historyRes.wasStaff) {
roles.push(isGloballyStaff ? "Current Staff" : "Former Staff (in period)");
}
// 2. Steward status
if (longestTimeStr && isSelfAssign && (isGloballySteward || historyRes.wasSteward)) {
roles.push(`Steward action (Self-assign: ${assignCount > 1 ? assignCount + 'x, longest ' : ''}${longestTimeStr})`);
} else if (isGloballySteward || historyRes.wasSteward) {
roles.push(isGloballySteward ? "Current Steward" : "Former Steward (in period)");
}
// 3. Ombudsman status
if (isGloballyOmbuds || historyRes.wasOmbuds) {
roles.push(isGloballyOmbuds ? "Current Ombudsman" : "Former Ombudsman (in period)");
}
// 4. Local CheckUser status (Completely independent)
if (isCurrentLocal) {
roles.push("Current Local CheckUser");
}
if (!isCurrentLocal && wasCU) {
roles.push("Former Local CheckUser (in period)");
}
// Combine all unique identified roles
let uniqueRoles = [...new Set(roles)];
let roleLabel = uniqueRoles.length > 0 ? uniqueRoles.join(' & ') : "Unknown role";
return { role: roleLabel, log: logText };
} catch (err) {
// Return an error state if anything crashes during user data processing
return { role: "Error fetching data", log: "" };
}
}
// The main engine that iterates through wikis and collects CheckUser logs.
async function runAudit(yf, mf, yt, mt) {
// Reset all data and state variables for a fresh run
isRunning = true;
userCache = {};
historyCache = {};
results = {};
emptyWikis = [];
failedWikis = [];
scannedWikis = [];
// Parse and prepare the wiki filter list from the UI input
const filterText = $('#wiki-filter').val().trim().toLowerCase();
const filterList = filterText
? filterText.split(',').map(s => s.trim()).filter(s => s !== "")
: [];
// Apply include/exclude logic to the master wiki list
let wikisToScan = rawWikis;
if (currentFilterMode === 'include') {
wikisToScan = rawWikis.filter(w => filterList.includes(w));
} else if (currentFilterMode === 'exclude') {
wikisToScan = rawWikis.filter(w => !filterList.includes(w));
}
if (wikisToScan.length === 0) {
alert("No wikis selected!");
return;
}
// Update UI buttons and progress bar to "running" state
$('#start').prop('disabled', true);
$('#stop').prop('disabled', false);
$('#out').hide();
$('#bar').attr('max', wikisToScan.length).val(0);
// Format the date strings into ISO format 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 columns for the table based on the selected month range
const monthCols = [];
let currY = parseInt(yf), currM = parseInt(mf);
let 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++; }
}
// Loop through each selected wiki and fetch its logs
for (let i = 0; i < wikisToScan.length; i++) {
if (!isRunning) break; // Exit loop if the "Stop" button was clicked
const db = wikisToScan[i];
scannedWikis.push(db);
$('#status-msg').text(`Scanning ${db} (${i + 1}/${wikisToScan.length})...`);
let successLocal = false;
let continueToken = null;
// Inner loop for handling pagination of the logs on a single wiki
while (!successLocal && isRunning) {
try {
const api = new mw.ForeignApi(`https://${getDomain(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);
// Process each entry in the log batch
const ent = res.query?.checkuserlog?.entries || [];
if (ent.length) {
if (!results[db]) results[db] = {};
ent.forEach(e => {
const u = e.checkuser;
if (!results[db][u]) results[db][u] = { total: 0, months: {} };
const mKey = e.timestamp.slice(0, 7);
results[db][u].total++;
results[db][u].months[mKey] = (results[db][u].months[mKey] || 0) + 1;
});
}
// Check if there are more log pages to fetch
if (res.continue && isRunning) {
continueToken = res.continue;
} else {
if (!results[db]) emptyWikis.push(db);
successLocal = true;
}
} catch (err) {
// Record failures (like 429 after retries or 404) and move to the next wiki
failedWikis.push(db);
successLocal = true;
}
}
// Update the progress bar and wait a bit to be kind to the API
$('#bar').val(i + 1);
await sleep(DELAY_MS);
}
// Update UI and prepare the Wikitable header and metadata
$('#status-msg').text(`Generating report...`);
const today = new Date().toISOString().split('T')[0];
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' (UTC)';
// Start building the Wikitext report
let wt = `== Global CheckUser Stats (${mf}/${yf} - ${mt}/${yt}) ==\n''Report generated on: ${timestamp}''\n\n`;
let rightsLog = `\n== Rights Log (In Period) ==\n`;
// Create table structure with dynamic month columns
wt += `{| class="wikitable sortable" style="font-size:90%; text-align:right;"\n`;
wt += `! Wiki / User !! Category !! '''Total''' !! per 1k active users ${monthCols.map(m => `!! ${m.label}`).join(' ')}\n`;
// Sort databases alphabetically and begin processing each project's results
const sortedDBs = Object.keys(results).sort();
for (const db of sortedDBs) {
// Get active user count; if API fails or is 0, display 'Unknown'
const metrics = await fetchWikiMetrics(db);
const fmtActive = metrics.active > 0 ? metrics.active.toLocaleString('en-US') : 'Unknown';
// Prepare interwiki links for the project and its CheckUser log
const iwPrefix = getInterwikiPrefix(db);
const projectLink = `[[${iwPrefix}:|${db}]]`;
const logLink = `[[${iwPrefix}:Special:CheckUserLog|(log)]]`;
wt += `|-\n! colspan="${4 + monthCols.length}" style="background:#eaecf0; text-align:center;" | `;
wt += `${projectLink} <small style="font-weight:normal; color:#54595d;">(Active Users as of ${today}: ${fmtActive})</small> — ${logLink}\n`;
// Initialize local wiki totals and storage for individual user rows
let wikiRows = [];
let wT = 0;
let wMS = {};
monthCols.forEach(col => wMS[col.key] = 0);
// Get users from CU logs AND users who had rights changes (if we tracked them)
// For now, let's at least make sure we don't skip users with 0 actions but active rights
let projectUsers = Object.keys(results[db] || {});
projectUsers.sort();
for (const user of projectUsers) {
// Get role and rights log details for each user
const m = await fetchUserData(user, db, START, END);
// Identify if user belongs to specific global or local groups
const isSteward = m.role.includes("Steward") || m.role.includes("Staff") || m.role.includes("Ombudsman");
const isLocalCU = m.role.includes("Local CheckUser");
// Filter out users if they don't match the selected User Filter (Local/Steward)
if (currentUserFilter === 'local' && !isLocalCU) continue;
if (currentUserFilter === 'steward' && !isSteward) continue;
const uD = results[db][user];
// Aggregate totals for the current wiki
wT += uD.total;
monthCols.forEach(col => wMS[col.key] += (uD.months[col.key] || 0));
// Calculate actions per 1k active users; display 0.0 if project activity is unknown
let uP1kU = metrics.active > 0 ? ((uD.total / metrics.active) * 1000).toFixed(1) : "0.0";
// Build the Wikitext row for the individual user
let row = `|-\n| style="text-align:left;" | ${user}@${db} || <small>${m.role}</small> || '''${uD.total}''' || ${uP1kU}`;
monthCols.forEach(col => row += ` || ${uD.months[col.key] || 0}`);
// Store the formatted row and its corresponding rights log
wikiRows.push({
html: row + "\n",
log: m.log ? `'''${user}@${db}''':${m.log}\n\n` : ""
});
}
// Create the bold TOTAL row for the current wiki
let wP1kU = metrics.active > 0 ? ((wT / metrics.active) * 1000).toFixed(1) : "0.0";
let totalRow = `|- style="background:#f8f9fa; font-weight:bold;"\n| style="text-align:left;" | TOTAL ${db} || — || ${wT} || ${wP1kU}`;
monthCols.forEach(col => totalRow += ` || ${wMS[col.key]}`);
// Append the total row first, then all individual user rows
wt += totalRow + "\n";
wikiRows.forEach(r => {
wt += r.html;
if (r.log) rightsLog += r.log;
});
}
// Append a list of wikis where no CheckUser actions were found
wt += `|}\n\n== Projects with 0 actions ==\n`;
wt += `<div style="font-size:85%; color:#54595d;">${emptyWikis.sort().join(', ')}</div>\n`;
// Append a list of projects that failed to scan (e.g., due to API errors)
if (failedWikis.length > 0) {
wt += `\n== API Errors / Skipped Projects ==\n`;
wt += `<div style="font-size:85%; color:#d33;">${failedWikis.sort().join(', ')}</div>\n`;
}
// Finalize the report with the Rights Log section
wt += rightsLog + `\n\n<references />\n`;
// Display the final output and reset UI buttons
$('#out').val(wt).show();
$('#status-msg').text(`Done.`);
$('#start').prop('disabled', false);
$('#stop').prop('disabled', true);
}
// Initialize the interface on page load
setupUI();
});
})();
g701f02vwnh2bu8l31zgujyho7sbkr2
737768
737762
2026-04-12T20:04:57Z
MrJaroslavik
44012
E
737768
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]].
// - Full Reporting: Outputs sortable Wikitables and detailed rights change logs.
// - With help of Gemini 3
// -------------------------------------------------------
// Wrap everything so this script doesn't break other Wikipedia scripts
(function () {
'use strict';
// Stop immediately if we are NOT on the specific GlobalCheckUserStats page
if (
mw.config.get('wgCanonicalSpecialPageName') !== 'Blankpage' ||
mw.config.get('wgTitle').indexOf('GlobalCheckUserStats') === -1
) return;
// A fixed list of all valid Wikimedia projects to scan
const rawWikis = ['abstractwiki', 'abwiki', 'acewiki', 'adywiki', 'afwiki', 'afwikibooks', 'afwikiquote', 'afwiktionary', 'alswiki', 'altwiki', 'amiwiki', 'amwiki', 'amwiktionary', 'angwiki', 'angwiktionary', 'annwiki', 'anpwiki', 'anwiki', 'anwiktionary', 'arcwiki', 'arwiki', 'arwikibooks', 'arwikimedia', 'arwikinews', 'arwikiquote', 'arwikisource', 'arwikiversity', 'arwiktionary', 'arywiki', 'arzwiki', 'astwiki', 'astwiktionary', 'aswiki', 'aswikiquote', 'aswikisource', 'atjwiki', 'avkwiki', 'avwiki', 'awawiki', 'aywiki', 'aywiktionary', 'azbwiki', 'azwiki', 'azwikibooks', 'azwikiquote', 'azwikisource', 'azwiktionary', 'banwiki', 'banwikisource', 'barwiki', 'bat_smgwiki', 'bawiki', 'bawikibooks', 'bbcwiki', 'bclwiki', 'bclwikiquote', 'bclwikisource', 'bclwiktionary', 'bdrwiki', 'bdwikimedia', 'be_x_oldwiki', 'betawikiversity', 'bewiki', 'bewikibooks', 'bewikimedia', 'bewikiquote', 'bewikisource', 'bewiktionary', 'bewwiki', 'bewwiktionary', 'bgwiki', 'bgwikibooks', 'bgwikiquote', 'bgwikisource', 'bgwiktionary', 'bhwiki', 'biwiki', 'bjnwiki', 'bjnwikiquote', 'bjnwiktionary', 'blkwiki', 'blkwiktionary', 'bmwiki', 'bnwiki', 'bnwikibooks', 'bnwikiquote', 'bnwikisource', 'bnwikivoyage', 'bnwiktionary', 'bowiki', 'bpywiki', 'brwiki', 'brwikimedia', 'brwikiquote', 'brwikisource', 'brwiktionary', 'bswiki', 'bswikibooks', 'bswikinews', 'bswikiquote', 'bswikisource', 'bswiktionary', 'btmwiki', 'btmwiktionary', 'bugwiki', 'bxrwiki', 'cawiki', 'cawikibooks', 'cawikimedia', 'cawikinews', 'cawikiquote', 'cawikisource', 'cawiktionary', 'cbk_zamwiki', 'cdowiki', 'cebwiki', 'cewiki', 'chrwiki', 'chrwiktionary', 'chwiki', 'chywiki', 'ckbwiki', 'ckbwiktionary', 'commonswiki', 'cowiki', 'cowikimedia', 'cowiktionary', 'crhwiki', 'csbwiki', 'csbwiktionary', 'cswiki', 'cswikibooks', 'cswikinews', 'cswikiquote', 'cswikisource', 'cswikiversity', 'cswikivoyage', 'cswiktionary', 'cuwiki', 'cvwiki', 'cvwikibooks', 'cywiki', 'cywikibooks', 'cywikiquote', 'cywikisource', 'cywiktionary', 'dagwiki', 'dawiki', 'dawikibooks', 'dawikiquote', 'dawikisource', 'dawiktionary', 'dewiki', 'dewikibooks', 'dewikinews', 'dewikiquote', 'dewikisource', 'dewikiversity', 'dewikivoyage', 'dewiktionary', 'dgawiki', 'dinwiki', 'diqwiki', 'diqwiktionary', 'dkwikimedia', 'dsbwiki', 'dtpwiki', 'dtywiki', 'dvwiki', 'dvwiktionary', 'dzwiki', 'eewiki', 'elwiki', 'elwikibooks', 'elwikinews', 'elwikiquote', 'elwikisource', 'elwikiversity', 'elwikivoyage', 'elwiktionary', 'emlwiki', 'enwiki', 'enwikibooks', 'enwikinews', 'enwikiquote', 'enwikisource', 'enwikiversity', 'enwikivoyage', 'enwiktionary', 'eowiki', 'eowikibooks', 'eowikinews', 'eowikiquote', 'eowikisource', 'eowikivoyage', 'eowiktionary', 'eswiki', 'eswikibooks', 'eswikinews', 'eswikiquote', 'eswikisource', 'eswikiversity', 'eswikivoyage', 'eswiktionary', 'etwiki', 'etwikibooks', 'eewikimedia', 'etwikiquote', 'etwikisource', 'etwiktionary', 'euwiki', 'euwikibooks', 'euwikiquote', 'euwikisource', 'euwiktionary', 'extwiki', 'fatwiki', 'fawiki', 'fawikibooks', 'fawikinews', 'fawikiquote', 'fawikisource', 'fawikivoyage', 'fawiktionary', 'ffwiki', 'fiu_vrowiki', 'fiwiki', 'fiwikibooks', 'fiwikimedia', 'fiwikinews', 'fiwikiquote', 'fiwikisource', 'fiwikiversity', 'fiwikivoyage', 'fiwiktionary', 'fjwiki', 'fjwiktionary', 'fonwiki', 'foundationwiki', 'fowiki', 'fowikisource', 'fowiktionary', 'frpwiki', 'frrwiki', 'frwiki', 'frwikibooks', 'frwikinews', 'frwikiquote', 'frwikisource', 'frwikiversity', 'frwikivoyage', 'frwiktionary', 'furwiki', 'fywiki', 'fywikibooks', 'fywiktionary', 'gagwiki', 'ganwiki', 'gawiki', 'gawiktionary', 'gcrwiki', 'gdwiki', 'gdwiktionary', 'glkwiki', 'glwiki', 'glwikibooks', 'glwikiquote', 'glwikisource', 'glwiktionary', 'gnwiki', 'gnwiktionary', 'gomwiki', 'gomwiktionary', 'gorwiki', 'gorwikiquote', 'gorwiktionary', 'gotwiki', 'gpewiki', 'gucwiki', 'gurwiki', 'guwiki', 'guwikiquote', 'guwikisource', 'guwiktionary', 'guwwiki', 'guwwikinews', 'guwwikiquote', 'guwwiktionary', 'gvwiki', 'gvwiktionary', 'hakwiki', 'hawiki', 'hawiktionary', 'hawwiki', 'hewiki', 'hewikibooks', 'hewikinews', 'hewikiquote', 'hewikisource', 'hewikivoyage', 'hewiktionary', 'hifwiki', 'hifwiktionary', 'hiwiki', 'hiwikibooks', 'hiwikiquote', 'hiwikisource', 'hiwikiversity', 'hiwikivoyage', 'hiwiktionary', 'hrwiki', 'hrwikibooks', 'hrwikiquote', 'hrwikisource', 'hrwiktionary', 'hsbwiki', 'hsbwiktionary', 'htwiki', 'huwiki', 'huwikibooks', 'huwikisource', 'huwiktionary', 'hywiki', 'hywikibooks', 'hywikiquote', 'hywikisource', 'hywiktionary', 'hywwiki', 'iawiki', 'iawikibooks', 'iawiktionary', 'ibawiki', 'idwiki', 'idwikibooks', 'idwikiquote', 'idwikisource', 'idwikivoyage', 'idwiktionary', 'iewiki', 'iewiktionary', 'iglwiki', 'igwiki', 'igwikiquote', 'igwiktionary', 'ikwiki', 'ilowiki', 'incubatorwiki', 'inhwiki', 'iowiki', 'iowiktionary', 'iswiki', 'iswikibooks', 'iswikiquote', 'iswikisource', 'iswiktionary', 'itwiki', 'itwikibooks', 'itwikinews', 'itwikiquote', 'itwikisource', 'itwikiversity', 'itwikivoyage', 'itwiktionary', 'iuwiki', 'iuwiktionary', 'jamwiki', 'jawiki', 'jawikibooks', 'jawikinews', 'jawikisource', 'jawikiversity', 'jawikivoyage', 'jawiktionary', 'jbowiki', 'jbowiktionary', 'jvwiki', 'jvwikisource', 'jvwiktionary', 'kaawiki', 'kaawiktionary', 'kabwiki', 'kaiwiki', 'kajwiki', 'kawiki', 'kawikibooks', 'kawikiquote', 'kawikisource', 'kawiktionary', 'kbdwiki', 'kbdwiktionary', 'kbpwiki', 'kcgwiki', 'kcgwiktionary', 'kgewiki', 'kgwiki', 'kiwiki', 'kkwiki', 'kkwikibooks', 'kkwiktionary', 'klwiktionary', 'kmwiki', 'kmwikibooks', 'kmwiktionary', 'kncwiki', 'knwiki', 'knwikiquote', 'knwikisource', 'knwiktionary', 'koiwiki', 'kowiki', 'kowikibooks', 'kowikinews', 'kowikiquote', 'kowikisource', 'kowikiversity', 'kowiktionary', 'krcwiki', 'kshwiki', 'kswiki', 'kswiktionary', 'kuswiki', 'kuwiki', 'kuwikibooks', 'kuwikiquote', 'kuwiktionary', 'kvwiki', 'kwwiki', 'kwwiktionary', 'kywiki', 'kywikiquote', 'kywiktionary', 'labswiki', 'ladwiki', 'lawiki', 'lawikibooks', 'lawikiquote', 'lawikisource', 'lawiktionary', 'lbewiki', 'lbwiki', 'lbwiktionary', 'lezwiki', 'lfnwiki', 'lgwiki', 'lijwiki', 'lijwikisource', 'liwiki', 'liwikibooks', 'liwikinews', 'liwikiquote', 'liwikisource', 'liwiktionary', 'lldwiki', 'lmowiki', 'lmowiktionary', 'lnwiki', 'lnwiktionary', 'lowiki', 'lowiktionary', 'ltgwiki', 'ltwiki', 'ltwikibooks', 'ltwikiquote', 'ltwikisource', 'ltwiktionary', 'lvwiki', 'lvwiktionary', 'madwiki', 'madwikisource', 'madwiktionary', 'maiwiki', 'map_bmswiki', 'mdfwiki', 'mediawikiwiki', 'metawiki', 'mgwiki', 'mgwikibooks', 'mgwiktionary', 'mhrwiki', 'minwiki', 'minwikibooks', 'minwikisource', 'minwiktionary', 'miwiki', 'miwiktionary', 'mkwiki', 'mkwikibooks', 'mkwikimedia', 'mkwikisource', 'mkwiktionary', 'mlwiki', 'mlwikibooks', 'mlwikiquote', 'mlwikisource', 'mlwiktionary', 'mniwiki', 'mniwiktionary', 'mnwiki', 'mnwiktionary', 'mnwwiki', 'mnwwiktionary', 'moswiki', 'mrjwiki', 'mrwiki', 'mrwikibooks', 'mrwikiquote', 'mrwikisource', 'mrwiktionary', 'mswiki', 'mswikibooks', 'mswikiquote', 'mswikisource', 'mswiktionary', 'mtwiki', 'mtwiktionary', 'mwlwiki', 'mxwikimedia', 'myvwiki', 'mywiki', 'mywikisource', 'mywiktionary', 'mznwiki', 'nahwiki', 'nahwiktionary', 'napwiki', 'napwikisource', 'nawiktionary', 'nds_nlwiki', 'ndswiki', 'ndswiktionary', 'newiki', 'newikibooks', 'newiktionary', 'newwiki', 'niawiki', 'niawiktionary', 'nlwiki', 'nlwikibooks', 'nlwikimedia', 'nlwikinews', 'nlwikiquote', 'nlwikisource', 'nlwikivoyage', 'nlwiktionary', 'nnwiki', 'nnwikiquote', 'nnwiktionary', 'novwiki', 'nowiki', 'nowikibooks', 'nowikimedia', 'nowikinews', 'nowikiquote', 'nowikisource', 'nowiktionary', 'nqowiki', 'nrmwiki', 'nrwiki', 'nsowiki', 'nupwiki', 'nvwiki', 'nycwikimedia', 'nywiki', 'ocwiki', 'ocwikibooks', 'ocwiktionary', 'olowiki', 'omwiki', 'omwiktionary', 'orwiki', 'orwikisource', 'orwiktionary', 'oswiki', 'outreachwiki', 'pagwiki', 'pamwiki', 'papwiki', 'pawiki', 'pawikibooks', 'pawikisource', 'pawiktionary', 'pcdwiki', 'pcmwiki', 'pcmwikiquote', 'pdcwiki', 'pflwiki', 'piwiki', 'plwiki', 'plwikibooks', 'plwikimedia', 'plwikinews', 'plwikiquote', 'plwikisource', 'plwikivoyage', 'plwiktionary', 'pmswiki', 'pmswikisource', 'pnbwiki', 'pnbwiktionary', 'pntwiki', 'pplwiki', 'pswiki', 'pswikivoyage', 'pswiktionary', 'ptwiki', 'ptwikibooks', 'ptwikimedia', 'ptwikinews', 'ptwikiquote', 'ptwikisource', 'ptwikiversity', 'ptwikivoyage', 'ptwiktionary', 'pwnwiki', 'quwiktionary', 'rkiwiki', 'rmwiki', 'rmywiki', 'rnwiki', 'roa_rupwiki', 'roa_rupwiktionary', 'roa_tarawiki', 'rowiki', 'rowikibooks', 'rowikinews', 'rowikiquote', 'rowiktionary', 'rskwiki', 'ruewiki', 'ruwiki', 'ruwikibooks', 'ruwikinews', 'ruwikiquote', 'ruwikisource', 'ruwikiversity', 'ruwikivoyage', 'ruwiktionary', 'rwwiki', 'rwwiktionary', 'sahwiki', 'sahwikiquote', 'sahwikisource', 'satwiki', 'satwiktionary', 'sawiki', 'sawikibooks', 'sawikiquote', 'sawikisource', 'sawiktionary', 'scnwiki', 'scnwiktionary', 'scowiki', 'scwiki', 'sdwiki', 'sdwiktionary', 'sewiki', 'sewikimedia', 'sgwiki', 'sgwiktionary', 'shiwiki', 'shnwiki', 'shnwikibooks', 'shnwikinews', 'shnwikivoyage', 'shnwiktionary', 'shwiki', 'shwiktionary', 'shywiktionary', 'simplewiki', 'simplewiktionary', 'siwiki', 'siwikibooks', 'siwiktionary', 'skwiki', 'skrwiki', 'skrwiktionary', 'slwiki', 'slwikibooks', 'slwikiquote', 'slwikisource', 'slwikiversity', 'slwiktionary', 'smnwiki', 'smwiki', 'smwiktionary', 'snwiki', 'sourceswiki', 'sowiki', 'sowiktionary', 'specieswiki', 'sqwiki', 'sqwikibooks', 'sqwikinews', 'sqwikiquote', 'sqwiktionary', 'srnwiki', 'srwiki', 'srwikibooks', 'srwikinews', 'srwikiquote', 'srwikisource', 'srwiktionary', 'sswiki', 'sswiktionary', 'stqwiki', 'stwiki', 'stwiktionary', 'suwiki', 'suwikiquote', 'suwikisource', 'suwiktionary', 'svwiki', 'svwikibooks', 'svwikinews', 'svwikiquote', 'svwikisource', 'svwikiversity', 'svwikivoyage', 'svwiktionary', 'swwiki', 'swwiktionary', 'sylwiki', 'szlwiki', 'szywiki', 'tawiki', 'tawikibooks', 'tawikinews', 'tawikiquote', 'tawikisource', 'tawiktionary', 'taywiki', 'tcywiki', 'tcywikisource', 'tcywiktionary', 'tddwiki', 'tewiki', 'tewikibooks', 'tewikiquote', 'tewikisource', 'tewiktionary', 'tgwiki', 'tgwikibooks', 'tgwiktionary', 'thwiki', 'thwikibooks', 'thwikimedia', 'thwikiquote', 'thwikisource', 'thwiktionary', 'tigwiki', 'tiwiki', 'tiwiktionary', 'tkwiki', 'tkwiktionary', 'tlwiki', 'tlwikibooks', 'tlwikiquote', 'tlwikisource', 'tlwiktionary', 'tlywiki', 'tnwiki', 'tnwiktionary', 'tokwiki', 'towiki', 'tpiwiki', 'tpiwiktionary', 'trvwiki', 'trwiki', 'trwikibooks', 'trwikimedia', 'trwikinews', 'trwikiquote', 'trwikisource', 'trwikivoyage', 'trwiktionary', 'tswiki', 'tswiktionary', 'ttwiki', 'ttwikibooks', 'ttwikiquote', 'ttwiktionary', 'tumwiki', 'twwiki', 'twwiktionary', 'tyvwiki', 'tywiki', 'uawikimedia', 'udmwiki', 'ugwiki', 'ugwiktionary', 'ukwiki', 'ukwikibooks', 'ukwikinews', 'ukwikiquote', 'ukwikisource', 'ukwikivoyage', 'ukwiktionary', 'urwiki', 'urwikibooks', 'urwikiquote', 'urwikisource', 'urwiktionary', 'uzwiki', 'uzwikiquote', 'uzwiktionary', 'vecwiki', 'vecwikisource', 'vecwiktionary', 'vepwiki', 'vewiki', 'viwiki', 'viwikibooks', 'viwikiquote', 'viwikisource', 'viwikivoyage', 'viwiktionary', 'vlswiki', 'vowiki', 'vowiktionary', 'warwiki', 'wawiki', 'wawikisource', 'wawiktionary', 'wikidatawiki', 'wikimaniawiki', 'wowiki', 'wowiktionary', 'wuuwiki', 'xalwiki', 'xhwiki', 'xmfwiki', 'yiwiki', 'yiwikisource', 'yiwiktionary', 'yowiki', 'zawiki', 'zeawiki', 'zghwiki', 'zghwiktionary', 'zh_classicalwiki', 'zh_min_nanwiki', 'zh_min_nanwikisource', 'zh_min_nanwiktionary', 'zh_yuewiki', 'zhwiki', 'zhwikibooks', 'zhwikinews', 'zhwikiquote', 'zhwikisource', 'zhwikiversity', 'zhwikivoyage', 'zhwiktionary', 'zuwiki', 'zuwiktionary', 'test2wiki', 'testwiki'];
// Time delay between API requests to prevent rate limiting (429 errors)
const DELAY_MS = 800;
// Helper function to pause execution for a specified number of milliseconds
const sleep = ms => new Promise(r => setTimeout(r, ms));
// Cache for global user group information to avoid redundant API calls
let userCache = {};
// Cache for historical global rights changes within the audit period
let historyCache = {};
// Helper function to handle API calls with up to 3 retries on 429 errors
async function robustCall(api, params) {
let retries = 0;
const maxRetries = 3;
// Continue attempting the API request as long as we haven't hit the maximum retry limit
while (retries < maxRetries) {
try {
// Attempt to execute the API GET request
return await api.get(params);
} catch (err) {
// If the error is "429 Too Many Requests" (rate limit)
if (err?.status === 429) {
retries++;
// Get wait time from 'Retry-After' header, or use a default backoff (30s, 60s, 90s)
const retryAfter = parseInt(err.xhr?.getResponseHeader('Retry-After')) || (30 * retries);
// Update UI status message with the remaining wait time
$('#status-msg').html(
`<span style="color:orange; font-weight:bold;">Rate limit hit! Resting ${retryAfter}s... (Attempt ${retries}/${maxRetries})</span>`
);
// Pause execution before the next retry
await sleep(retryAfter * 1000);
} else {
// Rethrow any error that is not a rate limit (e.g., 404, 403, 500)
throw err;
}
}
}
// Fail if max retries are exceeded
throw new Error("Max retries reached");
}
// Converts a database name (like 'enwiki') into a real web address.
function getDomain(db) {
// Static mapping for projects with non-standard naming conventions
const mapping = {
'commonswiki': 'commons.wikimedia.org',
'metawiki': 'meta.wikimedia.org',
'wikidatawiki': 'www.wikidata.org',
'mediawikiwiki': 'www.mediawiki.org',
'sourceswiki': 'wikisource.org',
'foundationwiki': 'foundation.wikimedia.org',
'incubatorwiki': 'incubator.wikimedia.org',
'outreachwiki': 'outreach.wikimedia.org',
'be_x_oldwiki': 'be-tarask.wikipedia.org',
'labswiki': 'wikitech.wikimedia.org',
'wikimaniawiki': 'wikimania.wikimedia.org',
'specieswiki': 'species.wikimedia.org',
'abstractwiki': 'abstract.wikipedia.org',
'betawikiversity': 'beta.wikiversity.org',
'mo_wiki': 'ro.wikipedia.org',
'testwiki': 'test.wikipedia.org',
'test2wiki': 'test2.wikipedia.org',
'wikifunctionswiki': 'www.wikifunctions.org',
'testcommonswiki': 'test-commons.wikimedia.org',
'testwikidatawiki': 'test.wikidata.org',
};
// Return early if an explicit mapping exists
if (mapping[db]) return mapping[db];
// Format language codes: replace underscores with hyphens (e.g., zh_yue -> zh-yue)
const name = db.replace(/_/g, '-');
// Handle sister project families by trimming suffixes and appending correct domains
if (name.endsWith('wikisource')) return name.slice(0, -10) + '.wikisource.org';
if (name.endsWith('wikiversity')) return name.slice(0, -11) + '.wikiversity.org';
if (name.endsWith('wiktionary')) return name.slice(0, -10) + '.wiktionary.org';
if (name.endsWith('wikivoyage')) return name.slice(0, -10) + '.wikivoyage.org';
if (name.endsWith('wikibooks')) return name.slice(0, -9) + '.wikibooks.org';
if (name.endsWith('wikiquote')) return name.slice(0, -9) + '.wikiquote.org';
if (name.endsWith('wikinews')) return name.slice(0, -8) + '.wikinews.org';
if (name.endsWith('wikimedia')) return name.slice(0, -9) + '.wikimedia.org';
// Default logic for Wikipedias: remove 'wiki' suffix and append domain
if (name.endsWith('wiki')) return name.slice(0, -4) + '.wikipedia.org';
// Generic fallback
return name + '.wikipedia.org';
}
function getInterwikiPrefix(db) {
// Mapping for special projects that use non-standard interwiki shortcuts
const specials = {
'commonswiki': 'commons',
'metawiki': 'm',
'wikidatawiki': 'd',
'mediawikiwiki': 'mw',
'specieswiki': 'wikispecies',
'foundationwiki': 'wikimedia',
'sourceswiki': 'wikisource',
};
// Return early if an explicit special prefix is defined
if (specials[db]) return specials[db];
// Format name for interwiki consistency (underscores to hyphens)
const name = db.replace(/_/g, '-');
// Logic for sister project families: trim suffixes and append the standard shorthand
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 'wikimedia:' + name.slice(0, -9);
// Standard Wikipedia logic: removes 'wiki' suffix and adds 'w:' prefix
if (name.endsWith('wiki')) return 'w:' + name.slice(0, -4);
// Default fallback for any other database patterns
return 'w:' + name;
}
// Ensure required MediaWiki API and ForeignApi modules are loaded before execution
mw.loader.using(['mediawiki.api', 'mediawiki.ForeignApi']).then(function () {
// Initialize API connection to Meta-Wiki for global user information and rights logs
const metaApi = new mw.ForeignApi('https://meta.wikimedia.org/w/api.php');
// Capture current date/time for report generation and timestamping
const now = new Date();
// State management variables:
let results = {}, // Stores aggregated CU action counts per wiki/user
emptyWikis = [], // List of projects with no recorded CU actions
failedWikis = [], // List of projects that encountered API errors
scannedWikis = [], // Tracking list of successfully processed projects
isRunning = false; // Flag to control the start/stop state of the audit
// UI state variables for filtering wikis and user roles
let currentFilterMode = 'all';
let currentUserFilter = 'all';
// Initializes and builds the User Interface (UI) elements on the page.
function setupUI() {
// Get the current year to ensure the date range is always up-to-date
const currentYear = now.getFullYear();
// Generate HTML options for the year selection (starting from 2005 to current)
let yearOpts = '';
for (let y = currentYear; y >= 2005; y--) {
yearOpts += `<option value="${y}">${y}</option>`;
}
// Prepare HTML options for month selection (1-12)
let monthOptsFrom = '';
let monthOptsTo = '';
for (let m = 1; m <= 12; m++) {
// Set default: 'From' dropdown defaults to January (1)
monthOptsFrom += `<option value="${m}" ${m === 1 ? 'selected' : ''}>${m}</option>`;
// Set default: 'To' dropdown defaults to December (12)
monthOptsTo += `<option value="${m}" ${m === 12 ? 'selected' : ''}>${m}</option>`;
}
// Set the page title to identify the script
$('#firstHeading').text('GlobalCheckUserStats.js');
// Clear the main content area and append the control panel HTML
$('#mw-content-text').empty().append(`
<div style="border:1px solid #a2a9b1; padding:15px; background:#f8f9fa;">
<h3>Stats Range</h3>
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 style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>Wiki Filter:</strong>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="wiki-mode" id="btn-all" checked> All wikis</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="wiki-mode" id="btn-except"> All wikis except</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><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; font-size:12px; line-height:18px;" title="Show all wiki DB names">?</span>
</div>
<div id="filter-input-container" style="display:none; margin-top:10px;">
<input id="wiki-filter" type="text" style="width:100%; max-width:600px;" placeholder="">
</div>
<div style="margin-top:15px; display:flex; align-items:center; gap:20px; font-size:13px;">
<strong>User Filter:</strong>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-all" checked> All users</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-local"> Only Local CUs</label>
<label style="cursor:pointer; display:flex; align-items:center; gap:5px;"><input type="radio" name="user-mode" id="u-steward"> Only Stewards</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; color:#202122;">
<strong>Available Wikis (alphabetical):</strong><br>${[...rawWikis].sort().join(', ')}
</div>
<div style="margin-top:20px;">
<button id="start" class="mw-ui-button mw-ui-progressive">Run GlobalCheckUserStats.js</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="${rawWikis.length}" style="width:100%"></progress></div>
<textarea id="out" style="width:100%; height:450px; margin-top:10px; display:none; font-family:monospace; font-size:12px;"></textarea>
</div>
`);
// Handle clicking 'All wikis': resets filter mode and hides the input field
$('#btn-all').click(() => {
currentFilterMode = 'all';
$('#filter-input-container').hide();
});
// Handle clicking 'All wikis except': shows input field, sets mode to 'exclude', and focuses the box
$('#btn-except').click(() => {
currentFilterMode = 'exclude';
$('#filter-input-container').show();
$('#wiki-filter').attr('placeholder', 'Exclude (dbname1, dbname2)...').focus();
});
// Handle clicking 'Only these wikis': shows input field, sets mode to 'include', and focuses the box
$('#btn-only').click(() => {
currentFilterMode = 'include';
$('#filter-input-container').show();
$('#wiki-filter').attr('placeholder', 'Only (dbname1, dbname2)...').focus();
});
// User role filter buttons: define which user categories will appear in the final table
$('#u-all').click(() => { currentUserFilter = 'all'; });
$('#u-local').click(() => { currentUserFilter = 'local'; });
$('#u-steward').click(() => { currentUserFilter = 'steward'; });
// Toggle visibility of the 'Available Wikis' help list when the question mark icon is clicked
$('#wiki-help-trigger').click(() => $('#wiki-list-help').toggle());
// Main execution trigger: starts the audit using the selected date range from dropdowns
$('#start').click(() => runAudit($('#y-f').val(), $('#m-f').val(), $('#y-t').val(), $('#m-t').val()));
// Stop button: sets isRunning to false to halt the loop between wiki scans
$('#stop').click(() => isRunning = false);
}
// Fetches general statistics for a specific wiki, primarily to get the active user count.
async function fetchWikiMetrics(db) {
try {
const api = new mw.ForeignApi(`https://${getDomain(db)}/w/api.php`);
// Request site statistics via the 'siteinfo' meta module
const res = await robustCall(api, {
action: 'query', meta: 'siteinfo', siprop: 'statistics', formatversion: 2
});
// Extract the active users count; default to 0 if the data is missing
return { active: res.query.statistics.activeusers || 0 };
} catch (e) {
// Graceful fallback: return zero if the project is unreachable or API fails
return { active: 0 };
}
}
// Checks if a user held global roles (Steward, Staff, Ombuds) during the audit period.
async function checkGlobalHistory(user, start, end) {
try {
// Fetch global rights logs from Meta-Wiki for the specific user
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 state = { steward: false, staff: false, ombuds: false };
// Look at the last log entry BEFORE the audit started to see the initial status
const priorLogs = logs
.filter(l => new Date(l.timestamp) <= auditStart)
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
if (priorLogs.length > 0) {
const p = priorLogs[0].params || {};
const groupsInLog = p.newGroups || p[1] || [];
state.steward = groupsInLog.includes('steward');
state.staff = groupsInLog.includes('staff');
state.ombuds = groupsInLog.includes('ombuds');
}
// Keep track of any global roles gained during the audit period itself
let held = { wasSteward: state.steward, wasStaff: state.staff, wasOmbuds: state.ombuds };
logs
.filter(l => {
const ts = new Date(l.timestamp);
return ts > auditStart && ts < auditEnd;
})
.forEach(e => {
const p = e.params || {};
const added = p.newGroups || p[1] || [];
if (added.includes('steward')) held.wasSteward = true;
if (added.includes('staff')) held.wasStaff = true;
if (added.includes('ombuds')) held.wasOmbuds = true;
});
return held;
} catch (e) {
// If anything fails, assume no global roles were held
return { wasSteward: false, wasStaff: false, wasOmbuds: false };
}
}
// Gathers all necessary data for a specific user on a specific wiki.
async function fetchUserData(user, db, start, end) {
// Convert start and end strings into Date objects for easier comparison
const auditStart = new Date(start);
const auditEnd = new Date(end);
// 1. FETCH CURRENT GLOBAL GROUPS (Using cache to save time)
if (!userCache[user]) {
try {
// Ask Meta-Wiki for the user's current global groups (steward, staff, etc.)
const gres = await robustCall(metaApi, {
action: 'query', meta: 'globaluserinfo', guiprop: 'groups',
guiuser: user, formatversion: 2
});
userCache[user] = gres.query.globaluserinfo.groups || [];
} catch (e) {
// Fallback to an empty list if the request fails
userCache[user] = [];
}
}
// Check if the user currently holds any key global roles
const gGroups = userCache[user];
const isGloballySteward = gGroups.includes('steward');
const isGloballyStaff = gGroups.includes('staff');
const isGloballyOmbuds = gGroups.includes('ombuds') || gGroups.includes('ombudsman');
// 2. FETCH GLOBAL HISTORY (Identify roles held during the period but lost since)
if (!historyCache[user]) {
historyCache[user] = await checkGlobalHistory(user, start, end);
}
const historyRes = historyCache[user];
// 3. CHECK CURRENT LOCAL CHECKUSER STATUS
let isCurrentLocal = false;
try {
// Connect to the local wiki to see if the user is currently a CheckUser
const localApi = new mw.ForeignApi(`https://${getDomain(db)}/w/api.php`);
const ures = await robustCall(localApi, {
action: 'query', list: 'users', ususers: user,
usprop: 'groups', formatversion: 2
});
const lGroups = (ures.query.users[0] && ures.query.users[0].groups) || [];
isCurrentLocal = lGroups.includes('checkuser');
} catch (e) {
// Ignore errors; defaults to false
}
// Define the target string for rights log searching
const target = 'User:' + user + '@' + db;
// Initialize variables for log analysis and role calculation
let logText = '',
addTime = null,
removeTime = null,
isSelfAssign = false,
maxDurationMins = -1,
longestTimeStr = "",
assignCount = 0,
hasLocalRightsInPeriod = false;
// 4. DEEP RIGHTS LOG SCAN (Pagination to bypass 500-limit)
let events = [];
let continueToken = null;
let finished = false;
try {
while (!finished) {
let params = {
action: 'query', list: 'logevents', letype: 'rights',
letitle: target, ledir: 'older', lelimit: 'max', formatversion: 2
};
if (continueToken) Object.assign(params, continueToken);
const res = await robustCall(metaApi, params);
const batch = res.query.logevents || [];
events = events.concat(batch);
if (res.continue) {
continueToken = res.continue;
} else {
finished = true;
}
}
let logEntries = [];
let pendingRemoved = null;
let lastPairedDate = null;
for (let i = 0; i < events.length; i++) {
const e = events[i];
const p = e.params || {};
let cuAdded = (p.add || []).includes('checkuser') ||
((p.newgroups || []).includes('checkuser') && !(p.oldgroups || []).includes('checkuser'));
const cuRemoved = (p.remove || []).includes('checkuser') ||
(!(p.newgroups || []).includes('checkuser') && (p.oldgroups || []).includes('checkuser'));
const cuModified = (p.newgroups || []).includes('checkuser') &&
(p.oldgroups || []).includes('checkuser');
if (cuModified) cuAdded = true;
const eventDate = new Date(e.timestamp);
const exactTime = e.timestamp.replace('T', ' ').replace('Z', '');
if (cuAdded) addTime = eventDate;
if (cuRemoved) removeTime = eventDate;
if (addTime && addTime <= auditEnd && (!removeTime || removeTime >= auditStart)) {
hasLocalRightsInPeriod = true;
}
const isInPeriod = (eventDate >= auditStart && eventDate <= auditEnd);
// Process if inside period OR looking for the start of a removal pair
if ((isInPeriod && (cuAdded || cuRemoved)) || (pendingRemoved && cuAdded)) {
let expiryDate = null;
if (p.newmetadata) {
const cuMeta = p.newmetadata.find(m => m.group === 'checkuser');
if (cuMeta && cuMeta.expiry && cuMeta.expiry !== 'infinity') {
expiryDate = new Date(cuMeta.expiry);
}
}
// Label if the performer was the user themselves (Self-removed)
const isSelf = (e.user === user || !e.user) ? " (Self-removed)" : "";
if (e.user === user || !e.user) isSelfAssign = true;
if (cuAdded) {
if (pendingRemoved) {
const diffMins = Math.round(Math.abs(pendingRemoved.date - eventDate) / 60000);
const d = Math.floor(diffMins / 1440);
const h = Math.floor((diffMins % 1440) / 60);
const m = diffMins % 60;
const durStr = `${d > 0 ? d + 'd ' : ''}${h > 0 ? h + 'h ' : ''}${m}m`;
logEntries.unshift(
`* ADDED: ${exactTime} by ${e.user} | REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf} (Duration: ${durStr})`
);
if (diffMins > maxDurationMins) {
maxDurationMins = diffMins;
longestTimeStr = durStr;
}
assignCount++;
pendingRemoved = null;
lastPairedDate = eventDate;
} else if (expiryDate) {
const diffMins = Math.round(Math.abs(expiryDate - eventDate) / 60000);
const d = Math.floor(diffMins / 1440);
const h = Math.floor((diffMins % 1440) / 60);
const m = diffMins % 60;
const durStr = `${d > 0 ? d + 'd ' : ''}${h > 0 ? h + 'h ' : ''}${m}m`;
const exactExpiryTime = expiryDate.toISOString().replace('T', ' ').substring(0, 19);
logEntries.unshift(
`* ADDED: ${exactTime} by ${e.user} | EXPIRED: ${exactExpiryTime} (Duration: ${durStr})`
);
if (diffMins > maxDurationMins) {
maxDurationMins = diffMins;
longestTimeStr = durStr;
}
assignCount++;
lastPairedDate = eventDate;
} else {
if (lastPairedDate && Math.abs(lastPairedDate - eventDate) < 86400000) {
// Skip duplicates
} else {
logEntries.unshift(
`* ADDED: ${exactTime} by ${e.user} | REMOVED: (Active/Not removed)`
);
lastPairedDate = eventDate;
}
}
} else if (cuRemoved) {
if (pendingRemoved) {
logEntries.unshift(
`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf}`
);
}
pendingRemoved = { time: exactTime, date: eventDate, user: e.user, isSelf: isSelf };
}
}
}
if (pendingRemoved) {
logEntries.unshift(
`* REMOVED: ${pendingRemoved.time} by ${pendingRemoved.user}${pendingRemoved.isSelf}`
);
}
if (logEntries.length) logText = '\n' + logEntries.join('\n');
//5. ROLE CLASSIFICATION
let roles = [];
const hasActions = (results[db] && results[db][user] && results[db][user].total > 0);
let wasCU = hasLocalRightsInPeriod || hasActions;
// 1. WMF Staff status
if (isGloballyStaff || historyRes.wasStaff) {
roles.push(isGloballyStaff ? "Current Staff" : "Former Staff (in period)");
}
// 2. Steward status
if (longestTimeStr && isSelfAssign && (isGloballySteward || historyRes.wasSteward)) {
roles.push(`Steward action (Self-assign: ${assignCount > 1 ? assignCount + 'x, longest ' : ''}${longestTimeStr})`);
} else if (isGloballySteward || historyRes.wasSteward) {
roles.push(isGloballySteward ? "Current Steward" : "Former Steward (in period)");
}
// 3. Ombudsman status
if (isGloballyOmbuds || historyRes.wasOmbuds) {
roles.push(isGloballyOmbuds ? "Current Ombudsman" : "Former Ombudsman (in period)");
}
// 4. Local CheckUser status
const isTempSteward = roles.some(r => r.includes("Steward action"));
if (isCurrentLocal) {
roles.push("Current Local CheckUser");
}
// Only show Former Local CU if they are NOT a temporary steward in this audit
if (!isCurrentLocal && wasCU && !isTempSteward) {
roles.push("Former Local CheckUser (in period)");
}
// Combine all unique identified roles
let uniqueRoles = [...new Set(roles)];
let roleLabel = uniqueRoles.length > 0 ? uniqueRoles.join(' & ') : "Unknown role";
return { role: roleLabel, log: logText };
} catch (err) {
// Return an error state if anything crashes during user data processing
return { role: "Error fetching data", log: "" };
}
}
// The main engine that iterates through wikis and collects CheckUser logs.
async function runAudit(yf, mf, yt, mt) {
// Reset all data and state variables for a fresh run
isRunning = true;
userCache = {};
historyCache = {};
results = {};
emptyWikis = [];
failedWikis = [];
scannedWikis = [];
// Parse and prepare the wiki filter list from the UI input
const filterText = $('#wiki-filter').val().trim().toLowerCase();
const filterList = filterText
? filterText.split(',').map(s => s.trim()).filter(s => s !== "")
: [];
// Apply include/exclude logic to the master wiki list
let wikisToScan = rawWikis;
if (currentFilterMode === 'include') {
wikisToScan = rawWikis.filter(w => filterList.includes(w));
} else if (currentFilterMode === 'exclude') {
wikisToScan = rawWikis.filter(w => !filterList.includes(w));
}
if (wikisToScan.length === 0) {
alert("No wikis selected!");
return;
}
// Update UI buttons and progress bar to "running" state
$('#start').prop('disabled', true);
$('#stop').prop('disabled', false);
$('#out').hide();
$('#bar').attr('max', wikisToScan.length).val(0);
// Format the date strings into ISO format 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 columns for the table based on the selected month range
const monthCols = [];
let currY = parseInt(yf), currM = parseInt(mf);
let 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++; }
}
// Loop through each selected wiki and fetch its logs
for (let i = 0; i < wikisToScan.length; i++) {
if (!isRunning) break; // Exit loop if the "Stop" button was clicked
const db = wikisToScan[i];
scannedWikis.push(db);
$('#status-msg').text(`Scanning ${db} (${i + 1}/${wikisToScan.length})...`);
let successLocal = false;
let continueToken = null;
// Inner loop for handling pagination of the logs on a single wiki
while (!successLocal && isRunning) {
try {
const api = new mw.ForeignApi(`https://${getDomain(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);
// Process each entry in the log batch
const ent = res.query?.checkuserlog?.entries || [];
if (ent.length) {
if (!results[db]) results[db] = {};
ent.forEach(e => {
const u = e.checkuser;
if (!results[db][u]) results[db][u] = { total: 0, months: {} };
const mKey = e.timestamp.slice(0, 7);
results[db][u].total++;
results[db][u].months[mKey] = (results[db][u].months[mKey] || 0) + 1;
});
}
// Check if there are more log pages to fetch
if (res.continue && isRunning) {
continueToken = res.continue;
} else {
if (!results[db]) emptyWikis.push(db);
successLocal = true;
}
} catch (err) {
// Record failures (like 429 after retries or 404) and move to the next wiki
failedWikis.push(db);
successLocal = true;
}
}
// Update the progress bar and wait a bit to be kind to the API
$('#bar').val(i + 1);
await sleep(DELAY_MS);
}
// Update UI and prepare the Wikitable header and metadata
$('#status-msg').text(`Generating report...`);
const today = new Date().toISOString().split('T')[0];
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' (UTC)';
// Start building the Wikitext report
let wt = `== Global CheckUser Stats (${mf}/${yf} - ${mt}/${yt}) ==\n''Report generated on: ${timestamp}''\n\n`;
let rightsLog = `\n== Rights Log (In Period) ==\n`;
// Create table structure with dynamic month columns
wt += `{| class="wikitable sortable" style="font-size:90%; text-align:right;"\n`;
wt += `! Wiki / User !! Category !! '''Total''' !! per 1k active users ${monthCols.map(m => `!! ${m.label}`).join(' ')}\n`;
// Sort databases alphabetically and begin processing each project's results
const sortedDBs = Object.keys(results).sort();
for (const db of sortedDBs) {
// Get active user count; if API fails or is 0, display 'Unknown'
const metrics = await fetchWikiMetrics(db);
const fmtActive = metrics.active > 0 ? metrics.active.toLocaleString('en-US') : 'Unknown';
// Prepare interwiki links for the project and its CheckUser log
const iwPrefix = getInterwikiPrefix(db);
const projectLink = `[[${iwPrefix}:|${db}]]`;
const logLink = `[[${iwPrefix}:Special:CheckUserLog|(log)]]`;
wt += `|-\n! colspan="${4 + monthCols.length}" style="background:#eaecf0; text-align:center;" | `;
wt += `${projectLink} <small style="font-weight:normal; color:#54595d;">(Active Users as of ${today}: ${fmtActive})</small> — ${logLink}\n`;
// Initialize local wiki totals and storage for individual user rows
let wikiRows = [];
let wT = 0;
let wMS = {};
monthCols.forEach(col => wMS[col.key] = 0);
// Get users from CU logs AND users who had rights changes (if we tracked them)
// For now, let's at least make sure we don't skip users with 0 actions but active rights
let projectUsers = Object.keys(results[db] || {});
projectUsers.sort();
for (const user of projectUsers) {
// Get role and rights log details for each user
const m = await fetchUserData(user, db, START, END);
// Identify if user belongs to specific global or local groups
const isSteward = m.role.includes("Steward") || m.role.includes("Staff") || m.role.includes("Ombudsman");
const isLocalCU = m.role.includes("Local CheckUser");
// Filter out users if they don't match the selected User Filter (Local/Steward)
if (currentUserFilter === 'local' && !isLocalCU) continue;
if (currentUserFilter === 'steward' && !isSteward) continue;
const uD = results[db][user];
// Aggregate totals for the current wiki
wT += uD.total;
monthCols.forEach(col => wMS[col.key] += (uD.months[col.key] || 0));
// Calculate actions per 1k active users; display 0.0 if project activity is unknown
let uP1kU = metrics.active > 0 ? ((uD.total / metrics.active) * 1000).toFixed(1) : "0.0";
// Build the Wikitext row for the individual user
let row = `|-\n| style="text-align:left;" | ${user}@${db} || <small>${m.role}</small> || '''${uD.total}''' || ${uP1kU}`;
monthCols.forEach(col => row += ` || ${uD.months[col.key] || 0}`);
// Store the formatted row and its corresponding rights log
wikiRows.push({
html: row + "\n",
log: m.log ? `'''${user}@${db}''':${m.log}\n\n` : ""
});
}
// Create the bold TOTAL row for the current wiki
let wP1kU = metrics.active > 0 ? ((wT / metrics.active) * 1000).toFixed(1) : "0.0";
let totalRow = `|- style="background:#f8f9fa; font-weight:bold;"\n| style="text-align:left;" | TOTAL ${db} || — || ${wT} || ${wP1kU}`;
monthCols.forEach(col => totalRow += ` || ${wMS[col.key]}`);
// Append the total row first, then all individual user rows
wt += totalRow + "\n";
wikiRows.forEach(r => {
wt += r.html;
if (r.log) rightsLog += r.log;
});
}
// Append a list of wikis where no CheckUser actions were found
wt += `|}\n\n== Projects with 0 actions ==\n`;
wt += `<div style="font-size:85%; color:#54595d;">${emptyWikis.sort().join(', ')}</div>\n`;
// Append a list of projects that failed to scan (e.g., due to API errors)
if (failedWikis.length > 0) {
wt += `\n== API Errors / Skipped Projects ==\n`;
wt += `<div style="font-size:85%; color:#d33;">${failedWikis.sort().join(', ')}</div>\n`;
}
// Finalize the report with the Rights Log section
wt += rightsLog + `\n\n<references />\n`;
// Display the final output and reset UI buttons
$('#out').val(wt).show();
$('#status-msg').text(`Done.`);
$('#start').prop('disabled', false);
$('#stop').prop('disabled', true);
}
// Initialize the interface on page load
setupUI();
});
})();
k8sr7yoomg8p3c1dp7wfejrvxtsd24q
Module:Sandbox/KockaAdmiralac
828
174805
737796
737624
2026-04-13T06:17:26Z
KockaAdmiralac
73426
Test
737796
Scribunto
text/plain
-- <nowiki>
local p = {}
local function addCategory(cat)
mw.getCurrentFrame():preprocess('[[Category:' .. cat .. ']]')
end
function p.tmpl()
addCategory('Cats')
return 'Oh no'
end
function p.decat(frame)
return frame:preprocess('{{#invoke:Sandbox/KockaAdmiralac|tmpl}}')
end
function p.samples(frame)
return frame:preprocess('<ref>test</ref>')
end
return p
-- </nowiki>
ihp8bde386llfbb1ks49d9k7yt3pxun
User:KockaAdmiralac/Sandbox
2
174806
737797
737623
2026-04-13T06:18:49Z
KockaAdmiralac
73426
Test
737797
wikitext
text/x-wiki
{{#invoke:Sandbox/KockaAdmiralac|samples}}
f84htuz2qdn812zvtx37q6uho30vl3o
Wikipedia:Articles for deletion/Log/2026 April 12
4
174822
737787
737745
2026-04-12T21:49:18Z
Novem Linguae
49714
Adding [[:Wikipedia:Articles for deletion/NovemTest110]].
737787
wikitext
text/x-wiki
{{Recent AfDs}}
<div class="boilerplate metadata vfd" style="background-color: #F3F9FF; margin: 0 auto; padding: 0 1px 0 0; border: 1px solid #AAAAAA; font-size:10px">
{| width = "100%"
|-
! width="50%" align="left" | <span style="color:gray"><</span> [[Wikipedia:Articles for deletion/Log/2026 April 11|April 11]]
! width="50%" align="right" | [[Wikipedia:Articles for deletion/Log/2026 April 13|April 13]] <span style="color:gray">></span>
|}
</div>
<div align = "center">'''[[Wikipedia:Guide to deletion|Guide to deletion]]'''</div>
{{Cent}}
<small>{{purge|Purge server cache}}</small>
__TOC__
<!-- Add new entries to the TOP of the following list -->
{{Wikipedia:Articles for deletion/NovemTest110}}
{{Wikipedia:Articles for deletion/Mainspace (22th nomination)}}
{{Wikipedia:Articles for deletion/Mainspace (21th nomination)}}
{{Wikipedia:Articles for deletion/Mainspace (20th nomination)}}
fysu7x3ne79qozv3fgvmo5j9g5q1xmn
737792
737787
2026-04-12T21:51:50Z
Novem Linguae
49714
Adding [[:Wikipedia:Articles for deletion/NovemTest110 (2nd nomination)]].
737792
wikitext
text/x-wiki
{{Recent AfDs}}
<div class="boilerplate metadata vfd" style="background-color: #F3F9FF; margin: 0 auto; padding: 0 1px 0 0; border: 1px solid #AAAAAA; font-size:10px">
{| width = "100%"
|-
! width="50%" align="left" | <span style="color:gray"><</span> [[Wikipedia:Articles for deletion/Log/2026 April 11|April 11]]
! width="50%" align="right" | [[Wikipedia:Articles for deletion/Log/2026 April 13|April 13]] <span style="color:gray">></span>
|}
</div>
<div align = "center">'''[[Wikipedia:Guide to deletion|Guide to deletion]]'''</div>
{{Cent}}
<small>{{purge|Purge server cache}}</small>
__TOC__
<!-- Add new entries to the TOP of the following list -->
{{Wikipedia:Articles for deletion/NovemTest110 (2nd nomination)}}
{{Wikipedia:Articles for deletion/NovemTest110}}
{{Wikipedia:Articles for deletion/Mainspace (22th nomination)}}
{{Wikipedia:Articles for deletion/Mainspace (21th nomination)}}
{{Wikipedia:Articles for deletion/Mainspace (20th nomination)}}
javkhqhh1rzm410lr59lgqpnurwj08l
NovemTest110
0
174827
737783
2026-04-12T21:48:19Z
NovemBot
52214
Created page with "test"
737783
wikitext
text/x-wiki
test
jrwjerxiekdtj9k82lg930wpkr6tq6r
737784
737783
2026-04-12T21:48:53Z
Novem Linguae
49714
Nominated for deletion; see [[:Wikipedia:Articles for deletion/NovemTest110]].
737784
wikitext
text/x-wiki
<!-- Please do not remove or change this AfD message until the discussion has been closed. -->
{{Article for deletion/dated|page=NovemTest110|timestamp=20260412214853|year=2026|month=April|day=12|substed=yes|help=off}}
<!-- Once discussion is closed, please place on talk page: {{Old AfD multi|page=NovemTest110|date=12 April 2026|result='''keep'''}} -->
<!-- End of AfD message, feel free to edit beyond this point -->
test
28or9hk5buyrpe5sv23ht7wt4wo7e6s
737789
737784
2026-04-12T21:50:31Z
Novem Linguae
49714
Nominated for deletion; see [[:Wikipedia:Articles for deletion/NovemTest110 (2nd nomination)]].
737789
wikitext
text/x-wiki
<!-- Please do not remove or change this AfD message until the discussion has been closed. -->
{{AfDM|page=NovemTest110 (2nd nomination)|year=2026|month=April|day=12|substed=yes|origtag=afdx|help=off|outcome=draftification}}
<!-- End of AfD message, feel free to edit beyond this point -->
test
4tcx3gmprv8gcaaaxpr2ks326puzjl4
Wikipedia:Articles for deletion/NovemTest110
4
174828
737785
2026-04-12T21:48:53Z
Novem Linguae
49714
Creating deletion discussion page for [[:NovemTest110]].
737785
wikitext
text/x-wiki
===[[:NovemTest110]]===
{{REMOVE THIS TEMPLATE WHEN CLOSING THIS AfD|?}}
<noinclude>{{AFD help}}</noinclude>
:{{la|NovemTest110}} – (<includeonly>[[Wikipedia:Articles for deletion/NovemTest110|View AfD]]</includeonly><noinclude>[[Wikipedia:Articles for deletion/Log/2026 April 12#{{anchorencode:NovemTest110}}|View log]]</noinclude>{{int:dot-separator}} <span class="plainlinks">[https://tools.wmflabs.org/jackbot/snottywong/cgi-bin/votecounter.cgi?page=Wikipedia:Articles_for_deletion/NovemTest110 Stats]</span>)
:({{Find sources AFD|NovemTest110}})
–[[User:Novem Linguae|<span style="color:blue">'''Novem Linguae'''</span>]] <small>([[User talk:Novem Linguae|talk]])</small> 21:48, 12 April 2026 (UTC)
rplmuyrjalypflxgixf3potuiyz3jjd
Wikipedia:Articles for deletion/NovemTest110 (2nd nomination)
4
174829
737790
2026-04-12T21:51:49Z
Novem Linguae
49714
Creating deletion discussion page for [[:NovemTest110]].
737790
wikitext
text/x-wiki
===[[:NovemTest110]]===
{{REMOVE THIS TEMPLATE WHEN CLOSING THIS AfD|?}}
<div class="infobox" style="width:33%">AfDs for this article:<ul class="listify">{{Special:Prefixindex/Wikipedia:Articles for deletion/NovemTest110}}</ul></div>
<noinclude>{{AFD help}}</noinclude>
:{{la|NovemTest110}} – (<includeonly>[[Wikipedia:Articles for deletion/NovemTest110 (2nd nomination)|View AfD]]</includeonly><noinclude>[[Wikipedia:Articles for deletion/Log/2026 April 12#{{anchorencode:NovemTest110}}|View log]]</noinclude>{{int:dot-separator}} <span class="plainlinks">[https://tools.wmflabs.org/jackbot/snottywong/cgi-bin/votecounter.cgi?page=Wikipedia:Articles_for_deletion/NovemTest110_(2nd_nomination) Stats]</span>)
:({{Find sources AFD|NovemTest110}})
–[[User:Novem Linguae|<span style="color:blue">'''Novem Linguae'''</span>]] <small>([[User talk:Novem Linguae|talk]])</small> 21:51, 12 April 2026 (UTC)
0eddhazghgwbwl7o5gupp9vl1um9nh0
User:MMunyoki (WMF)/Starter kit/Nairobi
2
174830
737794
2026-04-12T22:46:36Z
MMunyoki (WMF)
53374
Initialised by StarterKit tool — ready for translation
737794
wikitext
text/x-wiki
<div style="margin:10px 0;box-shadow:0 1px 1px rgba(0,0,0,0.1);background:#fff;">
{| style="border-spacing:1px;border-collapse:separate;width:100%;text-align:center;font-size:0.9em;padding:2px 3px;" class="hp-portalen"
| style="background:#F9F9F0;border-top:5px solid #999933;padding:3px 0.25em;width:20%;text-align:center;" | Arts & Literature
| style="background:#F4F9F0;border-top:5px solid #669933;padding:3px 0.25em;width:20%;text-align:center;" | Countries & Geography
| style="background:#F0F9F9;border-top:5px solid #339999;padding:3px 0.25em;width:20%;text-align:center;" | Science & Technology
| style="background:#F9F0F9;border-top:5px solid #993399;padding:3px 0.25em;width:20%;text-align:center;" | History & Events
| style="background:#F9F0F0;border-top:5px solid #993333;padding:3px 0.25em;width:20%;text-align:center;" | Requested Articles
|}
</div>
<noinclude>[[Category:Starter Kit templates]][[Category:Main page templates]]</noinclude>
3t5poh0l20nphxsbfk1v8cuwi2mb2pn