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&nbsp;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&nbsp;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&nbsp;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&nbsp;[[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&nbsp;million&nbsp;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&nbsp;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&nbsp;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&nbsp;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&nbsp;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&nbsp;million years old to just an estimated 6&nbsp;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&nbsp;km in diameter seen near the equator. Surrounding this caldera is a volcanic dome that stretches for roughly 2,000&nbsp;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&nbsp;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&nbsp;[[kelvin|K]] above the ambient surface temperature of 37&nbsp;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&nbsp;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&nbsp;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&nbsp;km and width of halo is between 20 and 30&nbsp;km. Some speculate the maculae are outliers of the south polar cap, which retreats in summer. --> The maculae typically have diameters of about 100&nbsp;km and widths of the halos of between 20 and 30&nbsp;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&nbsp;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&nbsp;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&nbsp;million years old) than for the cratered regions (≤ 50&nbsp;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&nbsp;km. Subsequent measurement attempts arrived at values ranging from 2,500 to 6,000&nbsp;km, or from slightly smaller than the Moon (3,474.2&nbsp;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&nbsp;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}}&nbsp;kg. Combined mass of 12 other known moons of Neptune: 7.53{{e|19}}&nbsp;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&nbsp;km), Uranus's [[Sycorax (moon)|Sycorax]] (160&nbsp;km), and Jupiter's [[Himalia (moon)|Himalia]] (140&nbsp;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&nbsp;— 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=&#32; (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> &nbsp;&nbsp;&nbsp;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> &nbsp;&nbsp;&nbsp;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> &nbsp;&nbsp;&nbsp;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> &nbsp;&nbsp;&nbsp;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> &nbsp;&nbsp;&nbsp;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> &nbsp;&nbsp;&nbsp;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> &nbsp;&nbsp;&nbsp;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> &nbsp;&nbsp;&nbsp;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> &nbsp;&nbsp;&nbsp;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> &nbsp;&nbsp;&nbsp;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> &nbsp;&nbsp;&nbsp;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> &nbsp;&nbsp;&nbsp;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">&lt;</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">&gt;</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">&lt;</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">&gt;</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