Wikipedia
testwiki
https://test.wikipedia.org/wiki/Main_Page
MediaWiki 1.48.0-wmf.1
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
Package
Package talk
CNBanner
CNBanner talk
Translations
Translations talk
Event
Event talk
Topic
Newsletter
Newsletter talk
User talk:200.234.4.199
3
2081
781323
3419
2026-10-28T00:40:32Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781323
wikitext
text/x-wiki
{{Consent}}
<pre>
_ _ _ _ _ _ _ _ _
| |_| |__ (_)___ (_)___ | |_ ___ ___| |_ __ _(_) | _(_)
| __| '_ \| / __| | / __| | __/ _ \/ __| __| \ \ /\ / / | |/ / |
| |_| | | | \__ \ | \__ \ | || __/\__ \ |_ \ V V /| | <| |
\__|_| |_|_|___/ |_|___/ \__\___||___/\__| \_/\_/ |_|_|\_\_|
</pre>
See [[Pi]]. -- 23:33, 4 June 2006 (UTC)
19u27qjcx87y1hrepvkojt165w5vwdv
781358
781323
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
3419
wikitext
text/x-wiki
<pre>
_ _ _ _ _ _ _ _ _
| |_| |__ (_)___ (_)___ | |_ ___ ___| |_ __ _(_) | _(_)
| __| '_ \| / __| | / __| | __/ _ \/ __| __| \ \ /\ / / | |/ / |
| |_| | | | \__ \ | \__ \ | || __/\__ \ |_ \ V V /| | <| |
\__|_| |_|_|___/ |_|___/ \__\___||___/\__| \_/\_/ |_|_|\_\_|
</pre>
See [[Pi]]. -- 23:33, 4 June 2006 (UTC)
mry6mv0948lp3el3za3h9wgfucmts0x
User talk:147.188.244.143
3
12018
781309
25036
2026-10-28T00:38:52Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781309
wikitext
text/x-wiki
{{Consent}}
[[bugzilla:9213]] test
rygqeqqqy7g1spyiazn18ywsvnu024b
781379
781309
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
25036
wikitext
text/x-wiki
[[bugzilla:9213]] test
t6ulub33gtenbcaqhfqvr2u8zkh9jnk
MediaWiki:Gadgets-definition
8
12894
781455
770321
2026-10-28T10:46:58Z
ItsNyoty
68948
781455
wikitext
text/x-wiki
<noinclude>{{collapse|1={{#invoke:validate gadgets|validate}}|title=Issues in gadget definitions}}</noinclude>
== Twinkle ==
* Twinkle [dependencies=ext.gadget.morebits, ext.gadget.select2, mediawiki.language |rights=autoconfirmed |type=general |peers=Twinkle-pagestyles] |Twinkle.js |Twinkle.css |twinklearv.js |twinklewarn.js |twinkleblock.js |twinklewelcome.js |twinkletalkback.js |twinklespeedy.js |twinkleprod.js |twinklexfd.js |twinkleimage.js |twinkleprotect.js |twinkletag.js |twinklediff.js |twinkleunlink.js |twinklerollback.js |twinkledeprod.js |twinklebatchdelete.js |twinklebatchprotect.js |twinklebatchundelete.js |twinkleconfig.js
* morebits [dependencies=mediawiki.user, mediawiki.util, mediawiki.Title, mediawiki.api |hidden] |morebits.js |morebits.css
* Twinkle-pagestyles [hidden |skins=vector, vector-2022] |Twinkle-pagestyles.css
* select2 [hidden] |select2.min.js |select2.min.css
*morebitsV3[dependencies=mediawiki.user,mediawiki.util,mediawiki.Title,jquery.ui|hidden]|morebitsV3.js|morebitsV3.css
*twinkle-enwiki[dependencies=ext.gadget.morebitsV3,ext.gadget.select2,mediawiki.api,mediawiki.storage,mediawiki.libs.pluralruleparser|peers=Twinkle-pagestyles]|TwinkleV3.js|TwinkleV3.css
*twinkle-starter[dependencies=ext.gadget.morebitsV3,ext.gadget.select2,mediawiki.api,mediawiki.storage|peers=Twinkle-pagestyles]|Twinkle-starter.js|Twinkle-starter.css
*orange-i18n [dependencies = mediawiki.libs.pluralruleparser ] | orange-i18n.js
*banana-i18n | banana-i18n.js
== Recent-Tests ==
* Global-Hiruwiki|hiruwiki-core.js
* ReferenceTooltips [skins=vector, vector-2022, monobook, timeless, modern, cologneblue |type=general |dependencies=mediawiki.cookie, jquery.client] |ReferenceTooltips.js |ReferenceTooltips.css
* InterProjectLinks[default|type=general|skins=vector,vector-2022,monobook,timeless,modern,cologneblue]|InterProjectLinks.js|InterProjectLinks.css
* nomin-test[requiresES6] | nomin-test.js
* regex-test|regex-test.js
* es6-test[requiresES6]|es6-test.js
* throwError[dependencies=mediawiki.util]|throwError.js
* tabbedwindow[default|dependencies=mediawiki.Title]|tabbedwindow.js
* markblocked[dependencies=mediawiki.util,mediawiki.page.ready]|markblocked.js
* djtest[skins=minerva]|djtest.js
* packagefilebase[requiresES6|package|hidden]|packagefilebase-file1.js|packagefilebase-file2.js|packagefilebase-file3.json
* packagefileuser[requiresES6|package|dependencies=ext.gadget.packagefilebase]|packagefileuser.js
* MobileCategories[dependencies=mediawiki.api,mediawiki.util|skins=minerva]|MobileCategories.js
* Sharer|Sharer.js
* citations[dependencies=mediawiki.util]|citations.js
* watchlist-notice[default]|watchlist-notice.js
* watchlist-notice-core[dependencies=mediawiki.storage|hidden]|watchlist-notice-core.js
* geonotice[default]|geonotice.js
* geonotice-core[package|dependencies=mediawiki.util,mediawiki.storage|hidden]|geonotice-core.js|geonotice-list.json|geonotice-core.css
* legacyToolbar |legacyToolbar.js |extra-toolbar-buttons-core.js |refToolbarLegacy.js
* refToolbar[default|dependencies=mediawiki.util|actions=edit|contentModels=wikitext]|refToolbar.js
* refToolbarBase[hidden]|refToolbarBase.js
* contribsrange[dependencies=mediawiki.util,jquery.spinner]|contribsrange.js
* another-contribsrange[dependencies=mediawiki.util,jquery.spinner]|another-contribsrange.js
* charinsert|charinsert.js
* charinsert-core[hidden|dependencies=jquery.textSelection,user,mediawiki.storage]|charinsert-styles.css|charinsert-core.js
* charinsert-old|charinsert-old.js|charinsert-old.css
* diffTools[dependencies=mediawiki.util]|diffTools.js|diffTools.css
* ImagelessTabs|ImagelessTabs.css
* fastbuttons[dependencies=mediawiki.util]|fastbuttons.js|fastbuttons.css
* ExternalTranslate[dependencies=jquery,mediawiki.api]|ExternalTranslate.js|ExternalTranslate.html|ExternalTranslate.css
* destacar|highlightsUsername.js|highlightsUsername.css
* blockNotificationsButton[dependencies=mediawiki.api,ext.gadget.mediawiki.api.ptwiki,mediawiki.util,user.options|rights=block]|blockNotificationsButton.js|blockNotificationsButton.css
* NewVillagePump|NewVillagePump.js|NewVillagePump.css
* mediawiki.api.ptwiki[dependencies=mediawiki.api|hidden]|mediawiki.api.ptwiki.js
* NewVillagePumpCore[dependencies=mediawiki.util,jquery.placeholder,jquery.spinner,ext.gadget.mediawiki.api.ptwiki|hidden]|NewVillagePump.js/Core.js
* Navigation_popups [dependencies=mediawiki.api, mediawiki.user, mediawiki.util, user.options, mediawiki.jqueryMsg |type=general] |popups.js |navpop.css
* teahouse[dependencies=mediawiki.user,mediawiki.cookie]|teahouse/main.js|teahouse/config.js|teahouse/teahouse.css
* teahouse-opt-in|teahouse-opt-in/main.js
* PB[dependencies=mediawiki.api,mediawiki.util]|PB.js
* Wikimedia Learning Survey|WikimediaL.js
* formWizard [default |rights=minoredit] |formWizard.js
* formWizard-core [hidden |dependencies=mediawiki.api, mediawiki.cookie, mediawiki.ui, mediawiki.user, jquery.chosen, jquery.ui, user.options] |formWizard-core.js |formWizard.css
* requestForAdminship|requestForAdminship.js
* watchUserContribs[dependencies=jquery.ui,mediawiki.util,mediawiki.ui.button,ext.gadget.TipsyDeprecated]|watchUserContribs.js|watchUserContribs.css
* WatchlistGreenIndicators[skins=vector]|WatchlistGreenIndicators.css
* WatchlistChangesBold|WatchlistChangesBold.css
* Hotcat[rights=edit]|HotCat.js
* RevisionSlider[dependencies=jquery.ui,mediawiki.api,mediawiki.util]|revisionslider.js|revisionslider.css
* toolbartocnav[dependencies=ext.wikiEditor]|toolbartocnav.css|toolbartocnav.js
* purgetab[dependencies=mediawiki.util,mediawiki.api]|purgetab.js
* databoxEditor[dependencies=jquery.client,jquery.mwEmbedUtil,mw.MwEmbedSupport,jquery.ui]|databoxeditor.js
* wikEd|wikEd.js
* revisionsSysopsActions[dependencies=mediawiki.util,ext.gadget.mediawiki.api.ptwiki,jquery.placeholder]|revisionsSysopsActions.js
* validateBlockRollbackers|validateBlockRollbackers.js
* watchlist[top]|watchlist.js
* addsection-plus[skins=monobook,vector]|addsection-plus.css
* CleanDeleteReasons[default|rights=delete]|CleanDeleteReasons.js
* contentFeatured[dependencies=mediawiki.util,ext.gadget.mediawiki.api.ptwiki,jquery.ui]|contentFeatured.js|contentFeatured.css
* easyPeerReview[rights=review|dependencies=mediawiki.util]|easyPeerReview.js
* UTCLiveClock[type=general|dependencies=mediawiki.util,mediawiki.api|peers=UTCLiveClock-pagestyles]|UTCLiveClock.js|UTCLiveClock.css
* UTCLiveClock-pagestyles[hidden|skins=vector,monobook]|UTCLiveClock-pagestyles.css
* afchelper [dependencies=mediawiki.api, mediawiki.util, mediawiki.user, jquery.chosen, jquery.spinner, jquery.ui |namespaces=2, 118] |afchelper.js |afchelper.css
* Shortdesc-helper-loader[dependencies=mediawiki.api,user.options,mediawiki.util|skins=vector,monobook,modern,timeless|peers=Shortdesc-helper-pagestyles-vector]|Shortdesc-helper.js
* Shortdesc-helper-pagestyles-vector[hidden|skins=vector]|Shortdesc-helper-pagestyles-vector.css
* MoreMenu-local[dependencies=mediawiki.api,mediawiki.util,mediawiki.Title,user.options|peers=MoreMenu-local-pagestyles]|MoreMenu.js
* MoreMenu-local-pagestyles[hidden]|MoreMenu-pagestyles.css
* scribe[dependencies=ext.visualEditor.core,mediawiki.api]| scribe.js|scribe.css
* scribe-v2[dependencies=ext.visualEditor.core,mediawiki.api]| scribe-v2.js|scribe-v2.css
* XFDcloser[dependencies=mediawiki.user|type=general]|XFDcloser.js
* XFDcloser-core[dependencies=mediawiki.util,mediawiki.api,mediawiki.Title,oojs-ui-core,oojs-ui-widgets,oojs-ui-windows,oojs-ui.styles.icons-interactions,oojs-ui.styles.icons-content,oojs-ui.styles.icons-moderation,oojs-ui.styles.icons-alerts|hidden|type=general]|XFDcloser-core.js|XFDcloser-core.css
* XFDcloser-core-beta[dependencies=mediawiki.util,mediawiki.api,mediawiki.Title,oojs-ui-core,oojs-ui-widgets,oojs-ui-windows,oojs-ui.styles.icons-interactions,oojs-ui.styles.icons-content,oojs-ui.styles.icons-moderation,oojs-ui.styles.icons-alerts|hidden|type=general]|XFDcloser-core-beta.js|XFDcloser-core-beta.css
* libExtraUtil[hidden]|libExtraUtil.js
* GoogleImagesTineye [dependencies=mediawiki.util]|GoogleImagesTineye.js
* ShowJavascriptErrors[type=general]|ShowJavascriptErrors.js
* lastEditUser[dependencies=mediawiki.util]|lastEditUser.js
* dark-mode-toggle[dependencies=mediawiki.util,mediawiki.api,mediawiki.Uri,mediawiki.storage|peers=dark-mode-toggle-pagestyles]|dark-mode-toggle.js
* dark-mode-toggle-pagestyles[hidden|targets=desktop,mobile|skins=vector,vector-2022,minerva,monobook]|dark-mode-toggle-pagestyles.css
* dark-mode[supportsUrlLoad|targets=desktop,mobile|skins=vector,vector-2022,monobook,modern,minerva,timeless]|dark-mode.css
* addMe[default|dependencies=oojs-ui,mediawiki.api,mediawiki.util,mediawiki.jqueryMsg]|addMe.js
* AutosuggestSitelink-local[dependencies=mediawiki.action.view.postEdit,mediawiki.ForeignApi,mediawiki.Title,oojs-ui-core,oojs-ui-windows]|AutosuggestSitelink.js
* WpLibExtraDev[hidden]|WpLibExtraDev.js
* MarkBLocked-core[hidden|dependencies=mediawiki.user,mediawiki.api,mediawiki.ForeignApi,mediawiki.storage,mediawiki.util,jquery.ui,oojs-ui,oojs-ui.styles.icons-moderation]|MarkBLocked-core.js|MarkBLocked-core.css
* MarkBLocked[supportsUrlLoad|package|dependencies=mediawiki.user,mediawiki.api,mediawiki.ForeignApi,mediawiki.storage,mediawiki.util,jquery.ui,oojs-ui,oojs-ui.styles.icons-moderation]|MarkBLocked.js|MarkBLocked-core.js|MarkBLocked-core.css
* MassRevisionDelete[rights=deleterevision|dependencies=jquery.makeCollapsible,oojs-ui,oojs-ui.styles.icons-movement,mediawiki.api]|MassRevisionDelete.js
* edit-styles[ actions=edit|type=styles]|edit-styles.css
* firstedit-test|firstedit-test.js
* firstedit-short-test|firstedit-short-test.js
* section-share[default]|section-share.js
* akinput-core|akinput-core.js
* akinput[dependencies=mediawiki.util,mediawiki.storage|akinput-core]|akinput-mediawiki.js|akinput.css
* userMessages|UserMessages.js
==Wikivoyage-Experimental==
* Carousel[type=general|dependencies=mediawiki.util]|Carousel.js|Carousel.css
== modules ==
* fastButtonsCore[dependencies=mediawiki.api,ext.gadget.mediawiki.api.ptwiki,mediawiki.page.ready,jquery.ui,mediawiki.cookie]|fastbuttons.js/core.js
* diffToolsCore[dependencies=mediawiki.api,mediawiki.util,user.options,ext.gadget.mediawiki.api.ptwiki,jquery.ui]|diffTools.js/core.js
* validateBlockRollbackersCore[dependencies=mediawiki.util]|validateBlockRollbackers.js/core.js
* requestForAdminshipCore[dependencies=ext.gadget.mediawiki.api.ptwiki,jquery.ui]|requestForAdminship.js/core.js|requestForAdminship.css
* TipsyDeprecated[hidden]|tipsyDeprecated.js
== arwiki ==
* changlist|changlist.css
== Old-tests ==
* backgroundTest[top|type=styles]|backgroundTest.css
== ruwiki ==
* ruwiki-test|ruwiki-test.js
* ruwiki-core|ruwiki-core.js
* ruwiki-common-action-edit[dependencies=user.options]|ruwiki-common-action-edit.js
* ruwiki-common-namespace-file[hidden]|ruwiki-common-namespace-file.js
* ruwiki-common-action-history[hidden]|ruwiki-common-action-history.js
* ruwiki-common-special-delete|ruwiki-common-special-delete.js
* ruwiki-common-special-block[type=general]|ruwiki-common-special-block.js|ruwiki-common-special-block.css
* ruwiki-common-special-log|ruwiki-common-special-log.js
* ruwiki-common-special-newpages|ruwiki-common-special-newpages.js
* ruwiki-common-special-movepage|ruwiki-common-special-movepage.js
* ruwiki-common-special-watchlist|ruwiki-common-special-watchlist.js
* ruwiki-common-special-search|ruwiki-common-special-search.js
* ruwiki-common-special-upload|ruwiki-common-special-upload.js
* ruwiki-wikificator[dependencies=user.options,jquery.textSelection,ext.gadget.ruwiki-registerTool]|ruwiki-wikificator.js
* ruwiki-summaryButtons[type=general|hidden]|ruwiki-summaryButtons.js|ruwiki-summaryButtons.css
* ruwiki-registerTool[hidden|dependencies=user.options]|ruwiki-registerTool.js
* ruwiki-urldecoder[dependencies=ext.gadget.ruwiki-registerTool]|ruwiki-urldecoder.js
=== ruwiki-iw ===
* ruwiki-iwcore[hidden|type=general]|ruwiki-iwcore.js|ruwiki-iwcore.css
* ruwiki-iwlocalnames[dependencies=ext.gadget.ruwiki-iwcore]|ruwiki-iwlocalnames.js
* ruwiki-iwhints[dependencies=ext.gadget.ruwiki-iwcore]|ruwiki-iwhints.js
* ruwiki-iwen[dependencies=ext.gadget.ruwiki-iwcore]|ruwiki-iwen.js
* ruwiki-iwrussia[dependencies=ext.gadget.ruwiki-iwcore]|ruwiki-iwrussia.js
== nowiki ==
* template-wizzard[rights=edit|type=general]|template-wizzard.js
== azwiki ==
* Katalitik|Katalitik.js
* XTools-ArticleInfo|XTools-ArticleInfo.js
* addsection-plus|addsection-plus.js
* markAdmins[dependencies=mediawiki.util]|markAdmins.js
* directLinkToCommons[dependencies=mediawiki.util]|directLinkToCommons.js
* switcher|switcher.js
* patrolRevisions[dependencies=mediawiki.api,mediawiki.util|rights=patrol]|patrolRevisions.js
== fawiki ==
* ScriptOne|ScriptOne.js
* ScriptTwo|ScriptTwo.js|ScriptTwo.css
* ScriptHidden[hidden]|ScriptHidden.css
* ScriptThree[dependencies=mediawiki.api|peers=ScriptHidden]|ScriptThree.js
* Morebits-i18n[dependencies=ext.gadget.morebitsV3,jquery.i18n]|Morebits-i18n.js
== bnwiki ==
*markAdmins[dependencies=mediawiki.util]|markAdmins.js
* DiscussionCloser[dependencies=mediawiki.util]|DiscussionCloser.js|DiscussionCloser.css
* Twinkle-bn[dependencies=ext.gadget.morebits-bn,ext.gadget.select2-bn|rights=autoconfirmed|type=general|peers=Twinkle-pagestyles-bn]|Twinkle-bn.js|twinkleprod-bn.js|twinkleimage-bn.js|twinklebatchundelete-bn.js|twinklewarn-bn.js|twinklespeedy-bn.js|friendlyshared-bn.js|twinklediff-bn.js|twinkleunlink-bn.js|friendlytag-bn.js|twinkledeprod-bn.js|friendlywelcome-bn.js|twinklexfd-bn.js|twinklebatchdelete-bn.js|twinklebatchprotect-bn.js|twinkleconfig-bn.js|twinklefluff-bn.js|twinkleprotect-bn.js|twinklearv-bn.js|twinkleblock-bn.js|friendlytalkback-bn.js|Twinkle-bn.css
* morebits-bn[dependencies=mediawiki.user,mediawiki.util,jquery.ui|hidden]|morebits-bn.js|morebits-bn.css
* Twinkle-pagestyles-bn[hidden|skins=vector]|Twinkle-pagestyles-bn.css
* select2-bn[hidden]|select2.min-bn.js|select2.min-bn.css
==bnwiktionary==
* Rep2Ad|Rep2Ad.js
==ukwiki==
* Wikificator[hidden]|Wikificator.js
* Onlyifediting[action=edit|dependencies=ext.visualEditor.desktopArticleTarget.init,ext.gadget.Wikificator]|Onlyifediting.js
* PostBlockActions[namespaces=-1]|PostBlockActions.js
* ImgsCleanUp[package|dependencies=mediawiki.util,vue,@wikimedia/codex|rights=autoconfirmed|namespaces=6]|ImgsCleanUp.js|ImgsCleanUp-main.vue
* valcio[dependencies=vue,codex]|valcio.js
* codexTableBuilder[package|dependencies=vue,@wikimedia/codex|actions=edit,submit|rights=edit]|codexTableBuilder.js|codexTableBuilder.vue
* codexTabs[package|default|dependencies=vue,@wikimedia/codex|type=general]|codexTabs.js|codexTabs.vue|codexTabs.css
mlkb2cjhkrkcz89xbfzly397m8eghst
User talk:"This is a very long username but why"?
3
31761
781294
137525
2026-10-28T00:37:08Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781294
wikitext
text/x-wiki
{{Consent}}
==Welcome!==
[[Image:Wiki.png|right|120px]]
Hello {{BASEPAGENAME}}, and '''welcome to [[Test Wikipedia]],''' a [[en:wiki|wiki]] designed for testing of software, among other things.
''Some good links include:''
*[[Sandbox]]
*[[Wikipedia:What test wiki is not|What test wiki is not]]
*[[Wikipedia:Requests for permissions|Requests for permissions]]
If you have any questions, please don't hesitate to ask me on my talk page. Thanks.
== Chinese Interlinear Version (Test) ==
Why does template:hidden work here and on the English Wiki
[http://test.wikipedia.org/wiki/User:Dudy001 Chinese interlinear version]
[http://en.wikipedia.org/wiki/User:Dudy001 Chinese interlinear version]
but not on:
[http://incubator.wikimedia.org/wiki/Wp/cmn/S%C4%81nxi%C3%A1_g%C5%8Dngch%C3%A9ng Wp/cmn/Sānxiá gōngchéng]
where the pictures or maybe the invisible hide bar influence each other?[[User:Dudy001|Dudy001]] ([[User talk:Dudy001|talk]]) 11:04, 26 September 2011 (UTC)
61av69im3kp1idxzqf51er9xr77v42x
781386
781294
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
137525
wikitext
text/x-wiki
==Welcome!==
[[Image:Wiki.png|right|120px]]
Hello {{BASEPAGENAME}}, and '''welcome to [[Test Wikipedia]],''' a [[en:wiki|wiki]] designed for testing of software, among other things.
''Some good links include:''
*[[Sandbox]]
*[[Wikipedia:What test wiki is not|What test wiki is not]]
*[[Wikipedia:Requests for permissions|Requests for permissions]]
If you have any questions, please don't hesitate to ask me on my talk page. Thanks.
== Chinese Interlinear Version (Test) ==
Why does template:hidden work here and on the English Wiki
[http://test.wikipedia.org/wiki/User:Dudy001 Chinese interlinear version]
[http://en.wikipedia.org/wiki/User:Dudy001 Chinese interlinear version]
but not on:
[http://incubator.wikimedia.org/wiki/Wp/cmn/S%C4%81nxi%C3%A1_g%C5%8Dngch%C3%A9ng Wp/cmn/Sānxiá gōngchéng]
where the pictures or maybe the invisible hide bar influence each other?[[User:Dudy001|Dudy001]] ([[User talk:Dudy001|talk]]) 11:04, 26 September 2011 (UTC)
4q3r5ad3f9el4ch7a4uwhdf03ofsifx
User talk:% (percent)
3
31762
781296
61521
2026-10-28T00:37:16Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781296
wikitext
text/x-wiki
{{Consent}}
==Welcome!==
[[Image:Wiki.png|right|120px]]
Hello {{BASEPAGENAME}}, and '''welcome to [[Test Wikipedia]],''' a [[en:wiki|wiki]] designed for testing of software, among other things.
''Some good links include:''
*[[Sandbox]]
*[[Wikipedia:What test wiki is not|What test wiki is not]]
*[[Wikipedia:Requests for permissions|Requests for permissions]]
If you have any questions, please don't hesitate to ask me on my talk page. Thanks.
ar2192bcjwoxv9j42cqd36sm9xc8nqg
781375
781296
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
61521
wikitext
text/x-wiki
==Welcome!==
[[Image:Wiki.png|right|120px]]
Hello {{BASEPAGENAME}}, and '''welcome to [[Test Wikipedia]],''' a [[en:wiki|wiki]] designed for testing of software, among other things.
''Some good links include:''
*[[Sandbox]]
*[[Wikipedia:What test wiki is not|What test wiki is not]]
*[[Wikipedia:Requests for permissions|Requests for permissions]]
If you have any questions, please don't hesitate to ask me on my talk page. Thanks.
nn6lzi5x2zx3weg1tpj5fbd2k1fgx8v
User talk:-Midorihana-
3
31763
781298
61522
2026-10-28T00:37:27Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781298
wikitext
text/x-wiki
{{Consent}}
==Welcome!==
[[Image:Wiki.png|right|120px]]
Hello {{BASEPAGENAME}}, and '''welcome to [[Test Wikipedia]],''' a [[en:wiki|wiki]] designed for testing of software, among other things.
''Some good links include:''
*[[Sandbox]]
*[[Wikipedia:What test wiki is not|What test wiki is not]]
*[[Wikipedia:Requests for permissions|Requests for permissions]]
If you have any questions, please don't hesitate to ask me on my talk page. Thanks.
ar2192bcjwoxv9j42cqd36sm9xc8nqg
781354
781298
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
61522
wikitext
text/x-wiki
==Welcome!==
[[Image:Wiki.png|right|120px]]
Hello {{BASEPAGENAME}}, and '''welcome to [[Test Wikipedia]],''' a [[en:wiki|wiki]] designed for testing of software, among other things.
''Some good links include:''
*[[Sandbox]]
*[[Wikipedia:What test wiki is not|What test wiki is not]]
*[[Wikipedia:Requests for permissions|Requests for permissions]]
If you have any questions, please don't hesitate to ask me on my talk page. Thanks.
nn6lzi5x2zx3weg1tpj5fbd2k1fgx8v
User talk:124.43.60.133
3
36492
781307
82985
2026-10-28T00:38:32Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781307
wikitext
text/x-wiki
{{Consent}}
Please stop creating pages as you are doing right now, the [[WP:NOT|test wiki]] isn't for that. [[User:Mentifisto|Mentifisto]] ([[User talk:Mentifisto|talk]]) 10:33, 18 January 2010 (UTC)
fx6qhigw3cor01yxemwodk59cn8nffy
781369
781307
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
82985
wikitext
text/x-wiki
Please stop creating pages as you are doing right now, the [[WP:NOT|test wiki]] isn't for that. [[User:Mentifisto|Mentifisto]] ([[User talk:Mentifisto|talk]]) 10:33, 18 January 2010 (UTC)
qx4mqp1yh0vbyth2nybvqt8i0u98yfw
User talk:*devunt
3
37039
781297
221473
2026-10-28T00:37:24Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781297
wikitext
text/x-wiki
{{Consent}}
==Welcome!==
[[Category:Really big category]]
[[Image:Wiki.png|right|120px]]
'''Hello {{BASEPAGENAME}}''', and welcome to [[Wikipedia:Test Wikipedia|Test Wikipedia]], a [[:en:wiki|wiki]] designed for testing of software, among other things.
''Some good links include:''
*[[Sandbox]]
*[[Wikipedia:What test wiki is|What test wiki is]]
*[[Wikipedia:What test wiki is not|What test wiki is '''not''']]
*[[Wikipedia:Requests|Requests for permissions, renames and others]]
*[[Talk:Main Page]]
*[[Wikipedia:Administrators]]
*[[Wikipedia:Bureaucrats]]
If you have any questions, please don't hesitate to ask me on my talk page. Thanks. [[User:Jeff G.|Jeff G.]] ([[User talk:Jeff G.|talk]]) 21:41, 25 April 2010 (UTC)
== Main page ==
Since you replaced the text under "What we do on this wiki" on the [[Main Page]] with the template from [[Wikipedia:What we do on this wiki]], shouldn't you protect that page as well so someone can't indirectly alter the text on the main page by editing the template? [[User:95j|95j]] ([[User talk:95j|talk]]) 22:52, 9 May 2010 (UTC)
Never mind, there is cascading protection. I was just surprised because there was an "Edit this page" tab at the top of my screen. [[User:95j|95j]] ([[User talk:95j|talk]]) 22:57, 9 May 2010 (UTC)
: yes. It is protected by cascading from [[Main Page]] --[[User:Devunt|Devunt]] ([[User talk:Devunt#top|talk]]) 08:58, 10 May 2010 (UTC)
== just a meaningless comment ==
the word 'crat' sounds like <s>'crap'</s>... --§[[user:Triple.Rhu|Triple]].[[User Talk:Triple.Rhu|Rhu]] on: 03:11, 1 June 2010 (UTC)
: LoL. so what, Triple.Rhu.--[[User:Devunt|Devunt]] ([[User talk:Devunt#top|talk]]) 10:22, 2 June 2010 (UTC)
== About [[User:WikiRightBot]] ==
Hello Devunt.
Please see [[User talk:WikiRightBot]]. Feel free to re-grant if you wish to use the bot. -- [[User:Quentinv57|Quentinv57]] ([[User talk:Quentinv57|talk]]) 14:36, 30 April 2011 (UTC)
== A7x (bureaucrat) ==
Hello, I was only asking if I could volunteer as a bureaucrat. I know the Test Wiki already has several bureaucrats, but some of the current bureaucrats are not regularly active and I think maybe one more user couldn't hurt. Btw, I've been an administrator for several months and I understand what the Test Wiki is and is not. Kindly, [[User:A7x|A7x]] ([[User talk:A7x|talk]]) 16:32, 8 July 2011 (UTC)
: Well, Granted :-) --[[User:Devunt|Devunt]] ([[User talk:Devunt#top|talk]]) 22:39, 8 July 2011 (UTC)
::Thanks :D [[User:A7x|A7x]] ([[User talk:A7x|talk]]) 01:24, 9 July 2011 (UTC)
== Deadmin ==
Hi Devunt,
Would you readd my admin status? I was active last time on 12 June 2011. [[User:Docu]] ([[User talk:Docu|talk]]) 14:04, 11 February 2012 (UTC)
: {{done}} [[User:Axpde|Axpde]] ([[User talk:Axpde|talk]]) 16:31, 13 February 2012 (UTC)
::Thanks. [[User:Docu|-- [[User:Docu]]]] ([[User talk:Docu|talk]]) 12:00, 14 February 2012 (UTC)
== Deadmin 2 ==
Why did you close the request early? Besides the fact that I started this request, I wrote ''january, 30th 2012'' to give them seven days to answer! And what about the crat flags? Do you think they'll vanish? I cared for them. [[User:Axpde|Axpde]] ([[User talk:Axpde|talk]]) 15:59, 13 February 2012 (UTC)
== Removal of rights ==
Hi Devunt. I see you recently removed the rights of several users because of inactivity. You did so too for [[User:Theo10011|Theo10011]] and [[User:Waihorace|Waihorace]], but both replied on the given page, see [[Wikipedia:Requests/Permissions/de-admin 01-2012|here]]. Could you please reinstate their administrator flag you removed? Please be a bit more careful in removing rights. Thanks for your understanding. Kind regards, [[User:Trijnstel|Trijnstel]] ([[User talk:Trijnstel|talk]]) 16:25, 13 February 2012 (UTC)
== Desysop ==
Hello, I've removed your sysop flag per [//test.wikipedia.org/w/index.php?title=User_talk:Mike.lifeguard&oldid=124347#Blocked_Mass_IP_Edit this incident]. It's one month old, but people here can't be forced to monitor this wiki 24h to ensure it's left functioning. If you disagree with other users good-faith usage of the wiki, you should open a discussion about the wiki's purpose, not throw your permissions against them. [[User:Nemo bis|Nemo bis]] ([[User talk:Nemo bis|talk]]) 14:14, 27 February 2012 (UTC)
== RfX ==
Hello! Just letting you know that I just closed [[Wikipedia:Requests/Permissions/Devunt (2)|your RfX]] as not successful. Best, -[[User:Barras|<span style="color:blue; font-family:Bookman Old Style">'''Barras'''</span>]] [[User Talk:Barras|<span style="color:red; font-family:Bookman Old Style">'''talk'''</span>]] 13:20, 10 March 2013 (UTC)
27yxvt9nchy7u3iqryhjxo8r2xq79vj
781395
781297
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
221473
wikitext
text/x-wiki
==Welcome!==
[[Category:Really big category]]
[[Image:Wiki.png|right|120px]]
'''Hello {{BASEPAGENAME}}''', and welcome to [[Wikipedia:Test Wikipedia|Test Wikipedia]], a [[:en:wiki|wiki]] designed for testing of software, among other things.
''Some good links include:''
*[[Sandbox]]
*[[Wikipedia:What test wiki is|What test wiki is]]
*[[Wikipedia:What test wiki is not|What test wiki is '''not''']]
*[[Wikipedia:Requests|Requests for permissions, renames and others]]
*[[Talk:Main Page]]
*[[Wikipedia:Administrators]]
*[[Wikipedia:Bureaucrats]]
If you have any questions, please don't hesitate to ask me on my talk page. Thanks. [[User:Jeff G.|Jeff G.]] ([[User talk:Jeff G.|talk]]) 21:41, 25 April 2010 (UTC)
== Main page ==
Since you replaced the text under "What we do on this wiki" on the [[Main Page]] with the template from [[Wikipedia:What we do on this wiki]], shouldn't you protect that page as well so someone can't indirectly alter the text on the main page by editing the template? [[User:95j|95j]] ([[User talk:95j|talk]]) 22:52, 9 May 2010 (UTC)
Never mind, there is cascading protection. I was just surprised because there was an "Edit this page" tab at the top of my screen. [[User:95j|95j]] ([[User talk:95j|talk]]) 22:57, 9 May 2010 (UTC)
: yes. It is protected by cascading from [[Main Page]] --[[User:Devunt|Devunt]] ([[User talk:Devunt#top|talk]]) 08:58, 10 May 2010 (UTC)
== just a meaningless comment ==
the word 'crat' sounds like <s>'crap'</s>... --§[[user:Triple.Rhu|Triple]].[[User Talk:Triple.Rhu|Rhu]] on: 03:11, 1 June 2010 (UTC)
: LoL. so what, Triple.Rhu.--[[User:Devunt|Devunt]] ([[User talk:Devunt#top|talk]]) 10:22, 2 June 2010 (UTC)
== About [[User:WikiRightBot]] ==
Hello Devunt.
Please see [[User talk:WikiRightBot]]. Feel free to re-grant if you wish to use the bot. -- [[User:Quentinv57|Quentinv57]] ([[User talk:Quentinv57|talk]]) 14:36, 30 April 2011 (UTC)
== A7x (bureaucrat) ==
Hello, I was only asking if I could volunteer as a bureaucrat. I know the Test Wiki already has several bureaucrats, but some of the current bureaucrats are not regularly active and I think maybe one more user couldn't hurt. Btw, I've been an administrator for several months and I understand what the Test Wiki is and is not. Kindly, [[User:A7x|A7x]] ([[User talk:A7x|talk]]) 16:32, 8 July 2011 (UTC)
: Well, Granted :-) --[[User:Devunt|Devunt]] ([[User talk:Devunt#top|talk]]) 22:39, 8 July 2011 (UTC)
::Thanks :D [[User:A7x|A7x]] ([[User talk:A7x|talk]]) 01:24, 9 July 2011 (UTC)
== Deadmin ==
Hi Devunt,
Would you readd my admin status? I was active last time on 12 June 2011. [[User:Docu]] ([[User talk:Docu|talk]]) 14:04, 11 February 2012 (UTC)
: {{done}} [[User:Axpde|Axpde]] ([[User talk:Axpde|talk]]) 16:31, 13 February 2012 (UTC)
::Thanks. [[User:Docu|-- [[User:Docu]]]] ([[User talk:Docu|talk]]) 12:00, 14 February 2012 (UTC)
== Deadmin 2 ==
Why did you close the request early? Besides the fact that I started this request, I wrote ''january, 30th 2012'' to give them seven days to answer! And what about the crat flags? Do you think they'll vanish? I cared for them. [[User:Axpde|Axpde]] ([[User talk:Axpde|talk]]) 15:59, 13 February 2012 (UTC)
== Removal of rights ==
Hi Devunt. I see you recently removed the rights of several users because of inactivity. You did so too for [[User:Theo10011|Theo10011]] and [[User:Waihorace|Waihorace]], but both replied on the given page, see [[Wikipedia:Requests/Permissions/de-admin 01-2012|here]]. Could you please reinstate their administrator flag you removed? Please be a bit more careful in removing rights. Thanks for your understanding. Kind regards, [[User:Trijnstel|Trijnstel]] ([[User talk:Trijnstel|talk]]) 16:25, 13 February 2012 (UTC)
== Desysop ==
Hello, I've removed your sysop flag per [//test.wikipedia.org/w/index.php?title=User_talk:Mike.lifeguard&oldid=124347#Blocked_Mass_IP_Edit this incident]. It's one month old, but people here can't be forced to monitor this wiki 24h to ensure it's left functioning. If you disagree with other users good-faith usage of the wiki, you should open a discussion about the wiki's purpose, not throw your permissions against them. [[User:Nemo bis|Nemo bis]] ([[User talk:Nemo bis|talk]]) 14:14, 27 February 2012 (UTC)
== RfX ==
Hello! Just letting you know that I just closed [[Wikipedia:Requests/Permissions/Devunt (2)|your RfX]] as not successful. Best, -[[User:Barras|<span style="color:blue; font-family:Bookman Old Style">'''Barras'''</span>]] [[User Talk:Barras|<span style="color:red; font-family:Bookman Old Style">'''talk'''</span>]] 13:20, 10 March 2013 (UTC)
62c70lavyxc0rs1xynplciqe6yjrcyw
User talk:110.55.12.221/Archive 1
3
39985
781289
119675
2026-10-28T00:36:24Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781289
wikitext
text/x-wiki
{{Consent}}
{{Talk archive navigation}}
== March 2010 ==
[[Image:Information.svg|25px]] Welcome to Wikipedia. It might not have been your intention, but your recent edit removed content from {{#if:Willie Revillame|[[:Willie Revillame]]|Wikipedia}}. When removing text, please specify a reason in the [[Help:Edit summary|edit summary]] and discuss edits that are likely to be controversial on the article's [[Wikipedia:Talk page|talk page]]. If this was a mistake, don't worry; the text has been restored, as you can see from the {{#if:Willie Revillame|<span class="plainlinks">[{{fullurl:Willie Revillame|action=history}} page history]</span>|[[Help:Page history|page history]]}}. Take a look at the [[Wikipedia:Welcoming committee/Welcome to Wikipedia|welcome page]] to learn more about contributing to this encyclopedia, and if you would like to experiment, please use the [[Wikipedia:Sandbox|sandbox]]. Thank you.<!-- Template:uw-delete1 --> --- [[User:Titopao|Tito Pao]] ([[User talk:Titopao|talk]]) 05:48, 11 March 2010 (UTC)
[[Image:Information.svg|25px]] Please do not delete content or templates from pages on Wikipedia{{#if:Willie Revillame|, as you did to [[:Willie Revillame]],}} without giving a valid reason for the removal in the [[Help:Edit summary|edit summary]]. Your content removal does not appear constructive, and has been [[Help:Reverting|reverted]]. Please make use of the [[Wikipedia:Sandbox|sandbox]] if you'd like to experiment with test edits. Thank you.<!-- Template:uw-delete2 --> --- [[User:Titopao|Tito Pao]] ([[User talk:Titopao|talk]]) 05:51, 11 March 2010 (UTC)
cztk76zvwjdjqznharkfmkosznwdatn
781389
781289
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
119675
wikitext
text/x-wiki
{{Talk archive navigation}}
== March 2010 ==
[[Image:Information.svg|25px]] Welcome to Wikipedia. It might not have been your intention, but your recent edit removed content from {{#if:Willie Revillame|[[:Willie Revillame]]|Wikipedia}}. When removing text, please specify a reason in the [[Help:Edit summary|edit summary]] and discuss edits that are likely to be controversial on the article's [[Wikipedia:Talk page|talk page]]. If this was a mistake, don't worry; the text has been restored, as you can see from the {{#if:Willie Revillame|<span class="plainlinks">[{{fullurl:Willie Revillame|action=history}} page history]</span>|[[Help:Page history|page history]]}}. Take a look at the [[Wikipedia:Welcoming committee/Welcome to Wikipedia|welcome page]] to learn more about contributing to this encyclopedia, and if you would like to experiment, please use the [[Wikipedia:Sandbox|sandbox]]. Thank you.<!-- Template:uw-delete1 --> --- [[User:Titopao|Tito Pao]] ([[User talk:Titopao|talk]]) 05:48, 11 March 2010 (UTC)
[[Image:Information.svg|25px]] Please do not delete content or templates from pages on Wikipedia{{#if:Willie Revillame|, as you did to [[:Willie Revillame]],}} without giving a valid reason for the removal in the [[Help:Edit summary|edit summary]]. Your content removal does not appear constructive, and has been [[Help:Reverting|reverted]]. Please make use of the [[Wikipedia:Sandbox|sandbox]] if you'd like to experiment with test edits. Thank you.<!-- Template:uw-delete2 --> --- [[User:Titopao|Tito Pao]] ([[User talk:Titopao|talk]]) 05:51, 11 March 2010 (UTC)
aodslo13qdjtpof0bekni3cqeytdus5
User talk:111.92.13.253
3
39988
781292
120126
2026-10-28T00:36:35Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781292
wikitext
text/x-wiki
{{Consent}}
{{User:MiszaBot/config
| algo = old(72h)
| archive = User talk:111.92.13.253/Archive
}}
<!--Template:Archivebox begins-->
{| class="infobox" width="315px"
|-
! align="center" | [[Image:Vista-file-manager.png|50px|Archive]]<br />[[Wikipedia:How to archive a talk page|Archives]]
----
|-
|
# [[User talk:111.92.13.253/Archive]]
#
|}<!--Template:Archivebox ends-->
{{isp/new|[[Asianet Communications Limited]] (Trivandrum, India)}}
cc816wi81ymtkag6xd8afpzxrnxj7k1
781396
781292
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
120126
wikitext
text/x-wiki
{{User:MiszaBot/config
| algo = old(72h)
| archive = User talk:111.92.13.253/Archive
}}
<!--Template:Archivebox begins-->
{| class="infobox" width="315px"
|-
! align="center" | [[Image:Vista-file-manager.png|50px|Archive]]<br />[[Wikipedia:How to archive a talk page|Archives]]
----
|-
|
# [[User talk:111.92.13.253/Archive]]
#
|}<!--Template:Archivebox ends-->
{{isp/new|[[Asianet Communications Limited]] (Trivandrum, India)}}
5438rsfiagd9850by0i4xck52y51lcj
User talk:111.235.88.252/Archive
3
40001
781291
119808
2026-10-28T00:36:32Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781291
wikitext
text/x-wiki
{{Consent}}
{{talkarchive}}
<!--Template:Archivebox begins-->
{| class="infobox" width="315px"
|-
! align="center" | [[Image:Vista-file-manager.png|50px|Archive]]<br />[[Wikipedia:How to archive a talk page|Archives]]
----
|-
|
# [[User talk:111.235.88.252/Archive]]
# <!--[[User talk:111.235.88.252/Archive]]-->
#
|}<!--Template:Archivebox ends-->
{{talkarchive}}
<!--Template:Archivebox begins-->
{| class="infobox" width="315px"
|-
! align="center" | [[Image:Vista-file-manager.png|50px|Archive]]<br />[[Wikipedia:How to archive a talk page|Archives]]
----
|-
|
# [[User talk:111.235.88.252/Archive]]
# <!--[[User talk:111.235.88.252/Archive]]-->
#
|}<!--Template:Archivebox ends-->
{{talkarchive}}
<!--Template:Archivebox begins-->
{| class="infobox" width="315px"
|-
! align="center" | [[Image:Vista-file-manager.png|50px|Archive]]<br />[[Wikipedia:How to archive a talk page|Archives]]
----
|-
|
# [[User talk:111.235.88.252/Archive]]
# <!--[[User talk:111.235.88.252/Archive]]-->
#
|}<!--Template:Archivebox ends-->
{{talkarchive}}
<!--Template:Archivebox begins-->
{| class="infobox" width="315px"
|-
! align="center" | [[Image:Vista-file-manager.png|50px|Archive]]<br />[[Wikipedia:How to archive a talk page|Archives]]
----
|-
|
# [[User talk:111.235.88.252/Archive]]
# <!--[[User talk:111.235.88.252/Archive]]-->
#
|}<!--Template:Archivebox ends-->
== October 2009 ==
[[File:Information.svg|25px]] Welcome and thank you for experimenting with Wikipedia. Your test {{#if:San Jose del Monte City|on the page [[:San Jose del Monte City]]}} worked, and it has been [[Help:Reverting|reverted]] or removed. Please take a look at the [[Wikipedia:Introduction|welcome page]] to learn more about contributing to [[Wikipedia|this encyclopedia]]. If you would like to experiment further, please use the [[Wikipedia:Sandbox|sandbox]] instead. Thank you.<!-- Template:uw-test1 --> --- [[User:Titopao|Tito Pao]] ([[User talk:Titopao|talk]]) 01:48, 23 October 2009 (UTC)
dp7os9sdaflsgtrq4tz6wmdpmxajkm2
781374
781291
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
119808
wikitext
text/x-wiki
{{talkarchive}}
<!--Template:Archivebox begins-->
{| class="infobox" width="315px"
|-
! align="center" | [[Image:Vista-file-manager.png|50px|Archive]]<br />[[Wikipedia:How to archive a talk page|Archives]]
----
|-
|
# [[User talk:111.235.88.252/Archive]]
# <!--[[User talk:111.235.88.252/Archive]]-->
#
|}<!--Template:Archivebox ends-->
{{talkarchive}}
<!--Template:Archivebox begins-->
{| class="infobox" width="315px"
|-
! align="center" | [[Image:Vista-file-manager.png|50px|Archive]]<br />[[Wikipedia:How to archive a talk page|Archives]]
----
|-
|
# [[User talk:111.235.88.252/Archive]]
# <!--[[User talk:111.235.88.252/Archive]]-->
#
|}<!--Template:Archivebox ends-->
{{talkarchive}}
<!--Template:Archivebox begins-->
{| class="infobox" width="315px"
|-
! align="center" | [[Image:Vista-file-manager.png|50px|Archive]]<br />[[Wikipedia:How to archive a talk page|Archives]]
----
|-
|
# [[User talk:111.235.88.252/Archive]]
# <!--[[User talk:111.235.88.252/Archive]]-->
#
|}<!--Template:Archivebox ends-->
{{talkarchive}}
<!--Template:Archivebox begins-->
{| class="infobox" width="315px"
|-
! align="center" | [[Image:Vista-file-manager.png|50px|Archive]]<br />[[Wikipedia:How to archive a talk page|Archives]]
----
|-
|
# [[User talk:111.235.88.252/Archive]]
# <!--[[User talk:111.235.88.252/Archive]]-->
#
|}<!--Template:Archivebox ends-->
== October 2009 ==
[[File:Information.svg|25px]] Welcome and thank you for experimenting with Wikipedia. Your test {{#if:San Jose del Monte City|on the page [[:San Jose del Monte City]]}} worked, and it has been [[Help:Reverting|reverted]] or removed. Please take a look at the [[Wikipedia:Introduction|welcome page]] to learn more about contributing to [[Wikipedia|this encyclopedia]]. If you would like to experiment further, please use the [[Wikipedia:Sandbox|sandbox]] instead. Thank you.<!-- Template:uw-test1 --> --- [[User:Titopao|Tito Pao]] ([[User talk:Titopao|talk]]) 01:48, 23 October 2009 (UTC)
ngpm0rpa07xw7a6sjbifdswqh84w7bn
User talk:!Silent
3
41067
781280
684440
2026-10-28T00:32:53Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781280
wikitext
text/x-wiki
{{Consent}}
==Welcome!==
[[Category:Really big category]]
[[Image:Wiki.png|right|120px]]
'''Hello {{BASEPAGENAME}}''', and welcome to [[Wikipedia:Test Wikipedia|Test Wikipedia]], a [[:en:wiki|wiki]] designed for testing of software, among other things.
''Some good links include:''
*[[Sandbox]]
*[[Wikipedia:What test wiki is|What test wiki is]]
*[[Wikipedia:What test wiki is not|What test wiki is '''not''']]
*[[Wikipedia:Requests|Requests for permissions, renames and others]]
*[[Talk:Main Page]]
*[[Wikipedia:Administrators]]
*[[Wikipedia:Bureaucrats]]
If you have any questions, please don't hesitate to ask me on my talk page. Thanks. [[User:Ruy Pugliesi|<span title="Ruy Pugliesi"><font face="Arial Rounded MT Bold">Ruy Pugliesi</font></span>]]<sup>[[User talk:Ruy Pugliesi|<span title="Talk">◥</span>]]</sup> 19:48, 21 April 2012 (UTC)
== Administrator ==
Hi !Silent,
Just wanted to let you know that [http://test.wikipedia.org/w/index.php?diff=131500&oldid=131325 I have added the administrator right] to your account, per your request.
Regards, [[User:Ruy Pugliesi|<span title="Ruy Pugliesi"><font face="Arial Rounded MT Bold">Ruy Pugliesi</font></span>]]<sup>[[User talk:Ruy Pugliesi|<span title="Talk">◥</span>]]</sup> 19:48, 21 April 2012 (UTC)
:Fala, velho. Valeu por vir testar aqui.<br>Diz se não é melhor poder testar com mais tranquilidade? :)<br>Abraço‴ [[user:Teles|'''T'''<sub>eleS</sub>]] ([[User talk:Teles|<font color="#EC2300">'''T'''</font>]] <sub>[[:m:user talk:Teles|M]] [[Special:Emailuser/Teles|@]] [[Special:Contributions/Teles|C]] [[sulutil:Teles|S]]</sub>) 02:11, 23 April 2012 (UTC)
== when ==
Notei que está passando uma função em vez de um objeto Defered para a [http://api.jquery.com/jQuery.when/ $.when(...)]. Em vez de
$.when( '''(function() {'''
jsMsg( 'Consultando registros de bloqueio...' );
//Pega a duração, motivo e verifica se o usuário está permitido a editar a própria discussão
$.ajax({
tente algo como
$.when( '''$.ajax'''({ ...
(isto é, sem criar uma nova função dentro do when), pois o valor de retorna da $.ajax será um objeto Defered e talvez funcione. [[b:pt:User:Helder.wiki|Helder]] 03:52, 27 June 2012 (UTC)
:Lembro que tentei algo assim mais não deu muito certo. Vou testar novamente. [[User:!Silent|!Silent]] ([[User talk:!Silent#top|talk]]) 15:33, 27 June 2012 (UTC)
{{subst:Novo administrador}}
== How we will see unregistered users ==
<section begin=content/>
Hi!
You get this message because you are an admin on a Wikimedia wiki.
When someone edits a Wikimedia wiki without being logged in today, we show their IP address. As you may already know, we will not be able to do this in the future. This is a decision by the Wikimedia Foundation Legal department, because norms and regulations for privacy online have changed.
Instead of the IP we will show a masked identity. You as an admin '''will still be able to access the IP'''. There will also be a new user right for those who need to see the full IPs of unregistered users to fight vandalism, harassment and spam without being admins. Patrollers will also see part of the IP even without this user right. We are also working on [[m:IP Editing: Privacy Enhancement and Abuse Mitigation/Improving tools|better tools]] to help.
If you have not seen it before, you can [[m:IP Editing: Privacy Enhancement and Abuse Mitigation|read more on Meta]]. If you want to make sure you don’t miss technical changes on the Wikimedia wikis, you can [[m:Global message delivery/Targets/Tech ambassadors|subscribe]] to [[m:Tech/News|the weekly technical newsletter]].
We have [[m:IP Editing: Privacy Enhancement and Abuse Mitigation#IP Masking Implementation Approaches (FAQ)|two suggested ways]] this identity could work. '''We would appreciate your feedback''' on which way you think would work best for you and your wiki, now and in the future. You can [[m:Talk:IP Editing: Privacy Enhancement and Abuse Mitigation|let us know on the talk page]]. You can write in your language. The suggestions were posted in October and we will decide after 17 January.
Thank you.
/[[m:User:Johan (WMF)|Johan (WMF)]]<section end=content/>
18:19, 4 January 2022 (UTC)
<!-- Message sent by User:Johan (WMF)@metawiki using the list at https://meta.wikimedia.org/w/index.php?title=User:Johan_(WMF)/Target_lists/Admins2022(7)&oldid=22532681 -->
{{subst:Novo administrador}}
== Test ==
This is a test. This is not a test. This is'nt a test. [[Special:Contributions/~2025-35252-77|~2025-35252-77]] ([[User talk:~2025-35252-77|talk]]) 12:55, 22 November 2025 (UTC)
julsuxg2s06pmth93jmma9wblwglxyh
781381
781280
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
684440
wikitext
text/x-wiki
==Welcome!==
[[Category:Really big category]]
[[Image:Wiki.png|right|120px]]
'''Hello {{BASEPAGENAME}}''', and welcome to [[Wikipedia:Test Wikipedia|Test Wikipedia]], a [[:en:wiki|wiki]] designed for testing of software, among other things.
''Some good links include:''
*[[Sandbox]]
*[[Wikipedia:What test wiki is|What test wiki is]]
*[[Wikipedia:What test wiki is not|What test wiki is '''not''']]
*[[Wikipedia:Requests|Requests for permissions, renames and others]]
*[[Talk:Main Page]]
*[[Wikipedia:Administrators]]
*[[Wikipedia:Bureaucrats]]
If you have any questions, please don't hesitate to ask me on my talk page. Thanks. [[User:Ruy Pugliesi|<span title="Ruy Pugliesi"><font face="Arial Rounded MT Bold">Ruy Pugliesi</font></span>]]<sup>[[User talk:Ruy Pugliesi|<span title="Talk">◥</span>]]</sup> 19:48, 21 April 2012 (UTC)
== Administrator ==
Hi !Silent,
Just wanted to let you know that [http://test.wikipedia.org/w/index.php?diff=131500&oldid=131325 I have added the administrator right] to your account, per your request.
Regards, [[User:Ruy Pugliesi|<span title="Ruy Pugliesi"><font face="Arial Rounded MT Bold">Ruy Pugliesi</font></span>]]<sup>[[User talk:Ruy Pugliesi|<span title="Talk">◥</span>]]</sup> 19:48, 21 April 2012 (UTC)
:Fala, velho. Valeu por vir testar aqui.<br>Diz se não é melhor poder testar com mais tranquilidade? :)<br>Abraço‴ [[user:Teles|'''T'''<sub>eleS</sub>]] ([[User talk:Teles|<font color="#EC2300">'''T'''</font>]] <sub>[[:m:user talk:Teles|M]] [[Special:Emailuser/Teles|@]] [[Special:Contributions/Teles|C]] [[sulutil:Teles|S]]</sub>) 02:11, 23 April 2012 (UTC)
== when ==
Notei que está passando uma função em vez de um objeto Defered para a [http://api.jquery.com/jQuery.when/ $.when(...)]. Em vez de
$.when( '''(function() {'''
jsMsg( 'Consultando registros de bloqueio...' );
//Pega a duração, motivo e verifica se o usuário está permitido a editar a própria discussão
$.ajax({
tente algo como
$.when( '''$.ajax'''({ ...
(isto é, sem criar uma nova função dentro do when), pois o valor de retorna da $.ajax será um objeto Defered e talvez funcione. [[b:pt:User:Helder.wiki|Helder]] 03:52, 27 June 2012 (UTC)
:Lembro que tentei algo assim mais não deu muito certo. Vou testar novamente. [[User:!Silent|!Silent]] ([[User talk:!Silent#top|talk]]) 15:33, 27 June 2012 (UTC)
{{subst:Novo administrador}}
== How we will see unregistered users ==
<section begin=content/>
Hi!
You get this message because you are an admin on a Wikimedia wiki.
When someone edits a Wikimedia wiki without being logged in today, we show their IP address. As you may already know, we will not be able to do this in the future. This is a decision by the Wikimedia Foundation Legal department, because norms and regulations for privacy online have changed.
Instead of the IP we will show a masked identity. You as an admin '''will still be able to access the IP'''. There will also be a new user right for those who need to see the full IPs of unregistered users to fight vandalism, harassment and spam without being admins. Patrollers will also see part of the IP even without this user right. We are also working on [[m:IP Editing: Privacy Enhancement and Abuse Mitigation/Improving tools|better tools]] to help.
If you have not seen it before, you can [[m:IP Editing: Privacy Enhancement and Abuse Mitigation|read more on Meta]]. If you want to make sure you don’t miss technical changes on the Wikimedia wikis, you can [[m:Global message delivery/Targets/Tech ambassadors|subscribe]] to [[m:Tech/News|the weekly technical newsletter]].
We have [[m:IP Editing: Privacy Enhancement and Abuse Mitigation#IP Masking Implementation Approaches (FAQ)|two suggested ways]] this identity could work. '''We would appreciate your feedback''' on which way you think would work best for you and your wiki, now and in the future. You can [[m:Talk:IP Editing: Privacy Enhancement and Abuse Mitigation|let us know on the talk page]]. You can write in your language. The suggestions were posted in October and we will decide after 17 January.
Thank you.
/[[m:User:Johan (WMF)|Johan (WMF)]]<section end=content/>
18:19, 4 January 2022 (UTC)
<!-- Message sent by User:Johan (WMF)@metawiki using the list at https://meta.wikimedia.org/w/index.php?title=User:Johan_(WMF)/Target_lists/Admins2022(7)&oldid=22532681 -->
{{subst:Novo administrador}}
== Test ==
This is a test. This is not a test. This is'nt a test. [[Special:Contributions/~2025-35252-77|~2025-35252-77]] ([[User talk:~2025-35252-77|talk]]) 12:55, 22 November 2025 (UTC)
fc7ouol7at7fdqzm7m8xpyuum25kg36
User talk:!SilentTest
3
42459
781282
656241
2026-10-28T00:35:27Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781282
wikitext
text/x-wiki
{{Consent}}
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=1 segundo|2=[[WP:Arbitration Committee/Procedures#Enforcement|Arbitration enforcement]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 02:18, 26 July 2021 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=1 segundo|2=[[WP:Arbitration Committee/Procedures#Enforcement|Arbitration enforcement]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 02:18, 26 July 2021 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=2 horas|2=[[WP:Edit warring|Edit warring]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 21:26, 27 July 2021 (UTC)
{{subst:Vandalismo|1|User talk:!SilentTest}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 16:43, 4 September 2021 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=1 dia|2=Violations of the [[WP:Biographies of living persons|biographies of living persons]] policy.}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 21:51, 29 October 2021 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=2 horas|2=Violations of the [[WP:Biographies of living persons|biographies of living persons]] policy.}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 22:00, 29 October 2021 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=1 segundo|2=[[WP:Vandalism|Vandalism]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 16:43, 30 October 2021 (UTC)
{{subst:Aviso-ESR|Testes10}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 01:01, 27 March 2022 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=infinito|2=Conta de [[WP:CPU|propósito único]] nocivo: [[Wikipédia:nome impróprio|nome impróprio]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 22:42, 12 November 2022 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=infinito|2=Conta de [[WP:CPU|propósito único]] nocivo: propaganda ou [[WP:SPAM|spam]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 11:54, 14 November 2022 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=infinito|2=Conta de [[WP:CPU|propósito único]] nocivo: propaganda ou [[WP:SPAM|spam]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 23:46, 15 November 2022 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=infinito|2=Conta de [[WP:CPU|propósito único]] nocivo: tentativa de [[WP:VAN|vandalizar]] frustrada por [[WP:FE|filtro]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 23:50, 15 November 2022 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=infinito|2=Conta de [[WP:CPU|propósito único]] nocivo: tentativa de [[WP:VAN|vandalizar]] frustrada por [[WP:FE|filtro]] na página [[User talk:!SilentTest]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 23:52, 15 November 2022 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=infinito|2=Conta de [[WP:CPU|propósito único]] nocivo: tentativa de [[WP:VAN|vandalizar]] frustrada por [[WP:FE|filtro]] na página [[Testesda]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 23:52, 15 November 2022 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=infinito|2=Conta de [[WP:CPU|propósito único]] nocivo: tentativa de [[WP:VAN|vandalizar]] frustrada por [[WP:FE|filtro]] na página [[Testesda]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 23:53, 15 November 2022 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=infinito|2=Conta de [[WP:CPU|propósito único]] nocivo: vandalismo [[Wikipédia:Vandalismo|contumaz]] na página [[Testesda]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 23:54, 15 November 2022 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=infinito|2=Conta de [[WP:CPU|propósito único]] nocivo: nome [[A:CCC|impróprio de usuário]] - para contribuir, siga nossas [[Predefinição:Nome impróprio|recomendações]] na página [[Testesda]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 23:54, 15 November 2022 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=infinito|2=Conta de [[WP:CPU|propósito único]] nocivo: propaganda ou [[WP:SPAM|spam]] na página [[Testesda]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 23:54, 15 November 2022 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=infinito|2=Conta de [[WP:CPU|propósito único]] nocivo: tentativa de [[WP:VAN|vandalizar]] frustrada por [[WP:FE|filtro]] na página [[ spam]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 23:57, 15 November 2022 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=infinito|2=Conta de [[WP:CPU|propósito único]] nocivo: tentativa de [[WP:VAN|vandalizar]] frustrada por [[WP:FE|filtro]] na página [[14 Ocak]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 23:59, 15 November 2022 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=infinito|2=Conta de [[WP:CPU|propósito único]] nocivo: contorno de [[WP:Contorno de bloqueio|bloqueio]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 00:42, 11 December 2022 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=infinito|2=Conta de [[WP:CPU|propósito único]] nocivo: contorno de [[WP:Contorno de bloqueio|bloqueio]] na página [[examine]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 00:21, 12 December 2022 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=infinito|2=Conta de [[WP:CPU|propósito único]] nocivo: contorno de [[WP:Contorno de bloqueio|bloqueio]] na página [[14 Ocak]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 00:22, 12 December 2022 (UTC)
{{subst:Novo administrador}}
e9je4cwc74ksut5mfdlfltu6igzx5rw
781398
781282
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
656241
wikitext
text/x-wiki
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=1 segundo|2=[[WP:Arbitration Committee/Procedures#Enforcement|Arbitration enforcement]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 02:18, 26 July 2021 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=1 segundo|2=[[WP:Arbitration Committee/Procedures#Enforcement|Arbitration enforcement]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 02:18, 26 July 2021 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=2 horas|2=[[WP:Edit warring|Edit warring]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 21:26, 27 July 2021 (UTC)
{{subst:Vandalismo|1|User talk:!SilentTest}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 16:43, 4 September 2021 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=1 dia|2=Violations of the [[WP:Biographies of living persons|biographies of living persons]] policy.}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 21:51, 29 October 2021 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=2 horas|2=Violations of the [[WP:Biographies of living persons|biographies of living persons]] policy.}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 22:00, 29 October 2021 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=1 segundo|2=[[WP:Vandalism|Vandalism]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 16:43, 30 October 2021 (UTC)
{{subst:Aviso-ESR|Testes10}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 01:01, 27 March 2022 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=infinito|2=Conta de [[WP:CPU|propósito único]] nocivo: [[Wikipédia:nome impróprio|nome impróprio]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 22:42, 12 November 2022 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=infinito|2=Conta de [[WP:CPU|propósito único]] nocivo: propaganda ou [[WP:SPAM|spam]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 11:54, 14 November 2022 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=infinito|2=Conta de [[WP:CPU|propósito único]] nocivo: propaganda ou [[WP:SPAM|spam]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 23:46, 15 November 2022 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=infinito|2=Conta de [[WP:CPU|propósito único]] nocivo: tentativa de [[WP:VAN|vandalizar]] frustrada por [[WP:FE|filtro]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 23:50, 15 November 2022 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=infinito|2=Conta de [[WP:CPU|propósito único]] nocivo: tentativa de [[WP:VAN|vandalizar]] frustrada por [[WP:FE|filtro]] na página [[User talk:!SilentTest]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 23:52, 15 November 2022 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=infinito|2=Conta de [[WP:CPU|propósito único]] nocivo: tentativa de [[WP:VAN|vandalizar]] frustrada por [[WP:FE|filtro]] na página [[Testesda]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 23:52, 15 November 2022 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=infinito|2=Conta de [[WP:CPU|propósito único]] nocivo: tentativa de [[WP:VAN|vandalizar]] frustrada por [[WP:FE|filtro]] na página [[Testesda]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 23:53, 15 November 2022 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=infinito|2=Conta de [[WP:CPU|propósito único]] nocivo: vandalismo [[Wikipédia:Vandalismo|contumaz]] na página [[Testesda]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 23:54, 15 November 2022 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=infinito|2=Conta de [[WP:CPU|propósito único]] nocivo: nome [[A:CCC|impróprio de usuário]] - para contribuir, siga nossas [[Predefinição:Nome impróprio|recomendações]] na página [[Testesda]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 23:54, 15 November 2022 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=infinito|2=Conta de [[WP:CPU|propósito único]] nocivo: propaganda ou [[WP:SPAM|spam]] na página [[Testesda]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 23:54, 15 November 2022 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=infinito|2=Conta de [[WP:CPU|propósito único]] nocivo: tentativa de [[WP:VAN|vandalizar]] frustrada por [[WP:FE|filtro]] na página [[ spam]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 23:57, 15 November 2022 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=infinito|2=Conta de [[WP:CPU|propósito único]] nocivo: tentativa de [[WP:VAN|vandalizar]] frustrada por [[WP:FE|filtro]] na página [[14 Ocak]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 23:59, 15 November 2022 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=infinito|2=Conta de [[WP:CPU|propósito único]] nocivo: contorno de [[WP:Contorno de bloqueio|bloqueio]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 00:42, 11 December 2022 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=infinito|2=Conta de [[WP:CPU|propósito único]] nocivo: contorno de [[WP:Contorno de bloqueio|bloqueio]] na página [[examine]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 00:21, 12 December 2022 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado-disc|1=infinito|2=Conta de [[WP:CPU|propósito único]] nocivo: contorno de [[WP:Contorno de bloqueio|bloqueio]] na página [[14 Ocak]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 00:22, 12 December 2022 (UTC)
{{subst:Novo administrador}}
ikemqpbkjv816n0sw18u2n9di5cei9k
User talk:$wgUser
3
68773
781295
450828
2026-10-28T00:37:13Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781295
wikitext
text/x-wiki
{{Consent}}
Hello there.
qjp4ihu5couljx8r3qytzzshdklg8my
781399
781295
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
165513
wikitext
text/x-wiki
Hello there.
hibc542vslb1g7m2pjxb2zvh4p7c8sd
Sandbox
0
68971
781158
768960
2026-10-27T12:30:30Z
~2026-57447-03
76441
Benign iframe navigation test in the designated sandbox
781158
wikitext
text/x-wiki
!!!!
== Android WebView sandbox navigation test 2026-10-27 ==
[https://www.mediawiki.org/wiki/Help:Links Harmless external navigation link]
jjyej3lruyxiakdoqmnwh2scsg26z7f
User talk:124.205.120.130
3
69166
781306
168979
2026-10-28T00:38:28Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781306
wikitext
text/x-wiki
{{Consent}}
Test all you like in mainspace, template space etc, but please don't mess around with the infrastructure pages. They are needed to keep this wiki running smoothly. Thanks, [[User:This, that and the other|This, that and the other]] ([[User talk:This, that and the other|talk]]) 09:44, 16 April 2013 (UTC)
28l5fc85c35lwqkr7zbz3y0zy764kd8
781393
781306
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
168979
wikitext
text/x-wiki
Test all you like in mainspace, template space etc, but please don't mess around with the infrastructure pages. They are needed to keep this wiki running smoothly. Thanks, [[User:This, that and the other|This, that and the other]] ([[User talk:This, that and the other|talk]]) 09:44, 16 April 2013 (UTC)
l1kj4tdcuuz6teqds3hanwruuien661
User talk:199.182.120.7
3
71458
781318
177367
2026-10-28T00:40:10Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781318
wikitext
text/x-wiki
{{Consent}}
== August 2013 ==
[[File:Information.svg|25px|alt=Information icon]] Hello, I'm [[User:UHHEFUH|UHHEFUH]]. I wanted to let you know that I reverted one of [[Special:Contributions/199.182.120.7|your recent contributions]] —the one you made with <span class="plainlinks">[http://test.wikipedia.org/wiki/User%3aMike.lifeguard%2f04-edit.t?diff=177359 this edit]</span> to [[:User:Mike.lifeguard/04-edit.t]]— because it did not appear constructive. If you would like to experiment, please use the [[Wikipedia:Sandbox|sandbox]]. If you think I made a mistake, or if you have any questions, you can leave me a message on [[User_talk:UHHEFUH|my talk page]]. Thanks. <!-- Template:Huggle/warn-1 --><!-- Template:uw-vandalism1 -->[[User:UHHEFUH|UHHEFUH]] ([[User talk:UHHEFUH|talk]]) 00:40, 28 October 2026 (UTC)
[[User:ASammour|ASammour]] ([[User talk:ASammour|talk]]) 21:10, 2 August 2013 (UTC)
9q1xq27vr7x724wlgb4uk5ndeh74rlz
781394
781318
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
177367
wikitext
text/x-wiki
== August 2013 ==
{{subst:huggle/warn-1|1=User:Mike.lifeguard/04-edit.t|2=http://test.wikipedia.org/wiki/User%3aMike.lifeguard%2f04-edit.t?diff=177359}}
[[User:ASammour|ASammour]] ([[User talk:ASammour|talk]]) 21:10, 2 August 2013 (UTC)
s3d89p4ltptgmdi09f4tjat3yz9y236
Wikipedia:Test
4
71460
781264
781140
2026-10-27T22:59:31Z
~2026-57078-19
76423
/* Test */ Start now
781264
wikitext
text/x-wiki
= Test =
1027
Candy Crush Saga Wiki: Tasty Sugar Track Edits Is Out Of Control (Mostly 6/23) and until 24 Restriction until no edits made all day, Use Account Sometimes.
Up To Date.
2:6 — 10:18, 252 days, (10:19+ to fill out).
*10:19, 10:20, 10:21, 10:22, 10:23 (?)
*10:24 (10:25)
''10:26''
*202 6525, Pot with Fork and Spoon, Bags with Sunglasses.
*202 6526, Baskets, Tobacco Stuff or Socks.
*202 6527, Food Shopping like Cola Bottles, Cans, Cordial and Others.
*202 6528, Socks, Pairs of Shoes, Dressing Gowns.
*202 6529, Towels, Cubes with Things.
*202 6530, Cloths, Flowers Frisun or Soap.
*202 6531, Food Shopping like Party Pies, Waters and Others.
*202 6601, Pairs of Boots, Pillow or Podiatrist.
*202 6602, Plates or Pans, Grated Cheese or Baskets.
*202 6603, Forks, Pans and Other Kitchen Tools.
*202 6604, Cups, Cordial Jug or Strawberry Milk.
*202 6605, Food Shopping like Cans of Coca Cola, Waters, Cordials or Pile of Sauce.
*202 6606, Purses, Lu Papers or Paper Towels.
*202 6607, Candles, Statues or Pizza Boxes.
*202 6608, Cups, Blankets and Others.
*202 6609, Food Shoppibg like Cola Bottles, Sour Cream and Onion Chips or Cans.
*202 6610, Towels, Cloths, Soap or Containers.
*202 6611, Nachos, Apple N Raspberry Codrial or Stationary.
*202 6612, Basket, Electric Bed with Instructions or Paint.
*202 6613, Aerobar, Birthday Cake or Packet of Candles.
*202 6614, Plates, Blankets or even Vacuum Cleaner.
*202 6615, Towels, Cloths or Dressing Gowns.
*202 6616, Smiths Chips, Heater Vents, Stuff for Lasagna or Cloths.
*202 6617, Pans, Table Spoons or Lids.
*202 6618, Food Shopping like Cans of Coca Cola, Waters, Milk and Others.
*202 6619, Chocolate Milk, Cups of Tea or Extra Mints.
*202 6620, Kitkats, Pens or Domino's Pizza.
*202 6621, Blankets and Pillows or Vacuum Cleaner.
*202 6622, Tea Towels, Cans of Coca Cola or Carlton Draught.
*202 6623, Food Shopping like Bottles of Cola, Waters or New Pyjamas.
*202 6624, Staples, Socks, Hats and Others.
*202 6625, Kitchen Bowls, Pans and Others.
*202 6626, Food Shopping like Colas, Milk or Rainbow Paddlepop.
*202 6627, Tablemats, Sunglasses or Garden Plant.
*202 6628, Boots, Sleep Medicine or Pillow.
*202 6629, Sewing Machine, Tobacco Stuff or Pan.
*202 6630, Food Shopping like Cans of Cocacola, Bottles, Kitkat or M&Ms.
*202 6701, BBQ Chips, Biskits or Grated Cheese.
*202 6702, Lighters, Plates or Cloths.
*202 6703, Hot Dogs, Cola Cans or Lasagna.
*202 6704, Mop, Battery Packs or Doorhandle.
*202 6705, Video Cameras, Internet or McDonalds Bag?.
*202 6706, Pans, Freshmint, or Hotdog Roll.
*202 6707, Food Shopping like Cans of Coca Cola, Cola Bottles, Waters, Shopping Bag or Grated Cheese.
*202 6708, Ktimes, Nutrigrain or Mueslibars.
*202 6709, Saladas, Socks, Vegemite or Textures.
*202 6710, Baskets, Favourites or Cordial Jug.
*202 6711, Shoes like Novo, Vacuum or Carlton Draught.
*202 6712, Aldi Stuff like Mints, Airfryer or Towel.
*202 6713, Food Shopping like Cola Bottles, Cheese, Milk or Paddle Pops.
*202 6714, Baskets, Mask Fresheners or Coat.
*202 6715, Towels, Cloths or Lasagna?.
*202 6716, Soaps, Toothpastes, Cotton Buds and Others.
*202 6717, Cans of Coca Cola, Bottles, Waters, Novo Shoes and Others.
*202 6718, Food Shopping like Upngo, Breads or Cans of Coca Cola.
*202 6719, Quichebox, Plate or Cup or Cheese Machine.
*202 6720, Cups, Milkshakes, Jug or Can of Coca Cola?.
*202 6721, Baskets, Coathangers, Chocolate Milk or Frypan?.
*202 6722, Food Shopping like Cola Bottles, Waters, Milk or Nachos?
*202 6723, Slippers, Blanket Sheets, Clothes Pair and Others.
*202 6724, Pans, Plates, Scissors or Tools.
*202 6725, Aldi Stuff, Domino's Pizza or Lynxspray.
*202 6726, Towels, Lighter or Journal Books.
*202 6727, Food Shopping like Bottles of Cola or Water, Cans or Milk.
*202 6728, Baskets, Battery Packs or Vaporizer and Others.
*202 6729, Sleep Medicines, Deep Heat or Pillows.
*202 6730, Schnitzels, Nesquiks, Milos and Others.
*202 6731, Toothpicks and Nuts and Others.
*202 6801, Food Shopping like Bottles, Cans, Waters and Others.
*202 6802, Plates, Bowls or Battery Packs.
*202 6803, Towels, Blankets, Purse or Boots.
*202 6804, M&M's, Baskets, Cloths or Coathangers.
*202 6805, Boots, Leaves, Texture and Others.
*202 6806, Food Shopping like Cans of Coca Cola, Nachos, Cheese or Milk.
*202 6807, Dressing Gowns, Glasses or Pillows.
*202 6808, Grater Cheese, Pizza, Mat or Plate.
*202 6809, Stylus, Mask, Soaps and Others.
*202 6810, Paper Towels, Staples, Lighter and Others.
*202 6811, Baskets, Cans of Coca Cola or Biskit Shapes.
*202 6812, Blackngold Cola, Blankets or Chocolatebox.
*202 6813, Smiths Chips, Cheese Mozarella or Bread.
*202 6814, Textures, Pens or Basket and Others.
*202 6815, Smoothies, Toys, Chopping Board or Box.
*202 6816, Cups like Gold, Cup of Tea, Metal Bottle and Others.
*202 6817, Food Shopping like Water Bottles, Cans of Coca Cola, Cola Bottles or Chocolate Milk.
*202 6818, Baskets, Keys or Cordial Jug and Others.
*202 6819, Aldi Stuff, Pizza or Pumpsoap.
*202 6820, Towels, Blankets Sheets and Others.
*202 6821, Cheese, Breads, Waters or Cans of Coca Cola and Others.
*202 6822, Sleep Medicone, Quichebox, Towels and Others.
*202 6823, Shoes, Hats or Ashtray and Others.
*202 6824, Cups, Milk, Towels or some Aldi and Others.
*202 6825, Battery Pack, Clothes and Others.
*202 6826, Cans of Coca Cola, Lime Cordial, Milk and Others.
*202 6827, Blankets, Pillows, Circle Table and Others.
*202 6828, Grated Cheese, Scissors, Knifes and Forks and Others.
*202 6829, Cans of Coca Cola, Bottles, Waters, Milk and Others.
*202 6830, Plates, Stand, Frypans and Others.
*202 6831, Metal Things, Water Bottles, Cans of Coca Cola and Others.
*202 6901, Aldi Bag with Stuff or Aprons.
*202 6902, Food Shopping like Cola Bottles, Cheese with Nachos and Others.
*202 6903, Mask Perfumes, Soaps and Others.
*202 6904, Baskets, Battery Pack, Pillows or Lasagna and Others
*202 6905, Frypans, Lids, Tablemats and Others.
*202 6906, Fathers Day Presents, Glasses, Bags and Others.
*202 6907, Deep Heat, Scissors, Milo or Container and Others.
*202 6908, Baskets, Coathanger, Blankets and Others.
*202 6909, Container Biscuit, Pastas or Pans.
*202 6910, Food Shopping like Milks, M&Ms, Sauces, Coca Cola Cans and Others.
*202 6911, Pillows, Dressing Gowns, Extra Mint and Others.
*202 6912, Laundry Stuff like boxes, bags or Dad's Wrist and Others.
*202 6913, Grater Stuff, Pans and Others.
*202 6914, Bottles of Cola, Breads, Milk to make Chocolate.
*202 6915, Iced Coffee, Water Bottles, Baskets and Others.
*202 6916, Aldi Bag, Strawberry Mints and Others.
*202 6917, Lighters, Plates, Freshener or Spray and Others.
*202 6918, New Clothes, Basket, Extra Mint and Others.
*202 6919, Face Cloths, Lu Paper, Water and Others.
*202 6920, Domino's Pizza, Pillows, Notes and Others.
*202 6921, Remotes, Pizza and Barbecue Shapes, Square Pillow and Others.
*202 6922, Food Shopping like Waters, Cans of Coca Cola, Cheese, Lasagna and Others.
*202 6923, Cloths, Facecloth, New Pot and Others.
*202 6924, Aldi Stuff, Glass Cups, Candles and Others.
*202 6925, Copy Paper, Glasses, Lasagna Tray Pan and Others.
*202 6926, Tax Boxes, Bags, Lighter, Texter and Others.
*202 6927, Cans of Coca Cola, Bottles, Cordial and Others.
*202 6928, Pairs of Shoes or Slippers, Square Pillow and Others.
*202 6929, Big Spoon and Others.
*202 6930, Cola Bottle and Others.
*202 61001, Yellow Bag and Others.
*202 61002, Coathangers or Towels and Others.
*202 61003, Knives, Forks and Others.
*202 61004, Hats like DNA hat and Others.
*202 61005, Pizza SHAPES and Others.
*202 61006, Old Pillow or Cola and Others.
*202 61007, Baskets, Coathangers and Others.
*202 61008, Plastic Cups and Others.
*202 61009, Towels, Black and White and Others.
*202 61010, Vacuum Cleaner and Others.
*202 61011, Blue Pens, Other Pens and Others.
*202 61012, Bag, Bars and Others.
*202 61013, Baskets, Blankets, Staples and Others.
*202 61014, K-Times, Bandai, Betadine and Others.
*202 61015, Food Shopping like Chipstack, Waters and Others.
*202 61016, Plates, Container, Foil, Battery Pack and Others.
Current, 202 61017. ($#!+).
*202 61018. (Snakes and Ladders, Towel or Board Game)
''20261019''
qk4tpppfcq894ek20qvbryjciio2asf
781265
781264
2026-10-27T23:00:25Z
~2026-57078-19
76423
/* Test */ 23:00 now
781265
wikitext
text/x-wiki
= Test =
1027
Candy Crush Saga Wiki: Tasty Sugar Track Edits Is Out Of Control (Mostly 6/23) and until 24 Restriction until no edits made all day, Use Account Sometimes.
Up To Date
2:6 — 10:18, 252 days, (10:19+ to fill out).
*10:19, 10:20, 10:21, 10:22, 10:23 (?)
*10:24 (10:25)
''10:26''
*202 6525, Pot with Fork and Spoon, Bags with Sunglasses.
*202 6526, Baskets, Tobacco Stuff or Socks.
*202 6527, Food Shopping like Cola Bottles, Cans, Cordial and Others.
*202 6528, Socks, Pairs of Shoes, Dressing Gowns.
*202 6529, Towels, Cubes with Things.
*202 6530, Cloths, Flowers Frisun or Soap.
*202 6531, Food Shopping like Party Pies, Waters and Others.
*202 6601, Pairs of Boots, Pillow or Podiatrist.
*202 6602, Plates or Pans, Grated Cheese or Baskets.
*202 6603, Forks, Pans and Other Kitchen Tools.
*202 6604, Cups, Cordial Jug or Strawberry Milk.
*202 6605, Food Shopping like Cans of Coca Cola, Waters, Cordials or Pile of Sauce.
*202 6606, Purses, Lu Papers or Paper Towels.
*202 6607, Candles, Statues or Pizza Boxes.
*202 6608, Cups, Blankets and Others.
*202 6609, Food Shoppibg like Cola Bottles, Sour Cream and Onion Chips or Cans.
*202 6610, Towels, Cloths, Soap or Containers.
*202 6611, Nachos, Apple N Raspberry Codrial or Stationary.
*202 6612, Basket, Electric Bed with Instructions or Paint.
*202 6613, Aerobar, Birthday Cake or Packet of Candles.
*202 6614, Plates, Blankets or even Vacuum Cleaner.
*202 6615, Towels, Cloths or Dressing Gowns.
*202 6616, Smiths Chips, Heater Vents, Stuff for Lasagna or Cloths.
*202 6617, Pans, Table Spoons or Lids.
*202 6618, Food Shopping like Cans of Coca Cola, Waters, Milk and Others.
*202 6619, Chocolate Milk, Cups of Tea or Extra Mints.
*202 6620, Kitkats, Pens or Domino's Pizza.
*202 6621, Blankets and Pillows or Vacuum Cleaner.
*202 6622, Tea Towels, Cans of Coca Cola or Carlton Draught.
*202 6623, Food Shopping like Bottles of Cola, Waters or New Pyjamas.
*202 6624, Staples, Socks, Hats and Others.
*202 6625, Kitchen Bowls, Pans and Others.
*202 6626, Food Shopping like Colas, Milk or Rainbow Paddlepop.
*202 6627, Tablemats, Sunglasses or Garden Plant.
*202 6628, Boots, Sleep Medicine or Pillow.
*202 6629, Sewing Machine, Tobacco Stuff or Pan.
*202 6630, Food Shopping like Cans of Cocacola, Bottles, Kitkat or M&Ms.
*202 6701, BBQ Chips, Biskits or Grated Cheese.
*202 6702, Lighters, Plates or Cloths.
*202 6703, Hot Dogs, Cola Cans or Lasagna.
*202 6704, Mop, Battery Packs or Doorhandle.
*202 6705, Video Cameras, Internet or McDonalds Bag?.
*202 6706, Pans, Freshmint, or Hotdog Roll.
*202 6707, Food Shopping like Cans of Coca Cola, Cola Bottles, Waters, Shopping Bag or Grated Cheese.
*202 6708, Ktimes, Nutrigrain or Mueslibars.
*202 6709, Saladas, Socks, Vegemite or Textures.
*202 6710, Baskets, Favourites or Cordial Jug.
*202 6711, Shoes like Novo, Vacuum or Carlton Draught.
*202 6712, Aldi Stuff like Mints, Airfryer or Towel.
*202 6713, Food Shopping like Cola Bottles, Cheese, Milk or Paddle Pops.
*202 6714, Baskets, Mask Fresheners or Coat.
*202 6715, Towels, Cloths or Lasagna?.
*202 6716, Soaps, Toothpastes, Cotton Buds and Others.
*202 6717, Cans of Coca Cola, Bottles, Waters, Novo Shoes and Others.
*202 6718, Food Shopping like Upngo, Breads or Cans of Coca Cola.
*202 6719, Quichebox, Plate or Cup or Cheese Machine.
*202 6720, Cups, Milkshakes, Jug or Can of Coca Cola?.
*202 6721, Baskets, Coathangers, Chocolate Milk or Frypan?.
*202 6722, Food Shopping like Cola Bottles, Waters, Milk or Nachos?
*202 6723, Slippers, Blanket Sheets, Clothes Pair and Others.
*202 6724, Pans, Plates, Scissors or Tools.
*202 6725, Aldi Stuff, Domino's Pizza or Lynxspray.
*202 6726, Towels, Lighter or Journal Books.
*202 6727, Food Shopping like Bottles of Cola or Water, Cans or Milk.
*202 6728, Baskets, Battery Packs or Vaporizer and Others.
*202 6729, Sleep Medicines, Deep Heat or Pillows.
*202 6730, Schnitzels, Nesquiks, Milos and Others.
*202 6731, Toothpicks and Nuts and Others.
*202 6801, Food Shopping like Bottles, Cans, Waters and Others.
*202 6802, Plates, Bowls or Battery Packs.
*202 6803, Towels, Blankets, Purse or Boots.
*202 6804, M&M's, Baskets, Cloths or Coathangers.
*202 6805, Boots, Leaves, Texture and Others.
*202 6806, Food Shopping like Cans of Coca Cola, Nachos, Cheese or Milk.
*202 6807, Dressing Gowns, Glasses or Pillows.
*202 6808, Grater Cheese, Pizza, Mat or Plate.
*202 6809, Stylus, Mask, Soaps and Others.
*202 6810, Paper Towels, Staples, Lighter and Others.
*202 6811, Baskets, Cans of Coca Cola or Biskit Shapes.
*202 6812, Blackngold Cola, Blankets or Chocolatebox.
*202 6813, Smiths Chips, Cheese Mozarella or Bread.
*202 6814, Textures, Pens or Basket and Others.
*202 6815, Smoothies, Toys, Chopping Board or Box.
*202 6816, Cups like Gold, Cup of Tea, Metal Bottle and Others.
*202 6817, Food Shopping like Water Bottles, Cans of Coca Cola, Cola Bottles or Chocolate Milk.
*202 6818, Baskets, Keys or Cordial Jug and Others.
*202 6819, Aldi Stuff, Pizza or Pumpsoap.
*202 6820, Towels, Blankets Sheets and Others.
*202 6821, Cheese, Breads, Waters or Cans of Coca Cola and Others.
*202 6822, Sleep Medicone, Quichebox, Towels and Others.
*202 6823, Shoes, Hats or Ashtray and Others.
*202 6824, Cups, Milk, Towels or some Aldi and Others.
*202 6825, Battery Pack, Clothes and Others.
*202 6826, Cans of Coca Cola, Lime Cordial, Milk and Others.
*202 6827, Blankets, Pillows, Circle Table and Others.
*202 6828, Grated Cheese, Scissors, Knifes and Forks and Others.
*202 6829, Cans of Coca Cola, Bottles, Waters, Milk and Others.
*202 6830, Plates, Stand, Frypans and Others.
*202 6831, Metal Things, Water Bottles, Cans of Coca Cola and Others.
*202 6901, Aldi Bag with Stuff or Aprons.
*202 6902, Food Shopping like Cola Bottles, Cheese with Nachos and Others.
*202 6903, Mask Perfumes, Soaps and Others.
*202 6904, Baskets, Battery Pack, Pillows or Lasagna and Others
*202 6905, Frypans, Lids, Tablemats and Others.
*202 6906, Fathers Day Presents, Glasses, Bags and Others.
*202 6907, Deep Heat, Scissors, Milo or Container and Others.
*202 6908, Baskets, Coathanger, Blankets and Others.
*202 6909, Container Biscuit, Pastas or Pans.
*202 6910, Food Shopping like Milks, M&Ms, Sauces, Coca Cola Cans and Others.
*202 6911, Pillows, Dressing Gowns, Extra Mint and Others.
*202 6912, Laundry Stuff like boxes, bags or Dad's Wrist and Others.
*202 6913, Grater Stuff, Pans and Others.
*202 6914, Bottles of Cola, Breads, Milk to make Chocolate.
*202 6915, Iced Coffee, Water Bottles, Baskets and Others.
*202 6916, Aldi Bag, Strawberry Mints and Others.
*202 6917, Lighters, Plates, Freshener or Spray and Others.
*202 6918, New Clothes, Basket, Extra Mint and Others.
*202 6919, Face Cloths, Lu Paper, Water and Others.
*202 6920, Domino's Pizza, Pillows, Notes and Others.
*202 6921, Remotes, Pizza and Barbecue Shapes, Square Pillow and Others.
*202 6922, Food Shopping like Waters, Cans of Coca Cola, Cheese, Lasagna and Others.
*202 6923, Cloths, Facecloth, New Pot and Others.
*202 6924, Aldi Stuff, Glass Cups, Candles and Others.
*202 6925, Copy Paper, Glasses, Lasagna Tray Pan and Others.
*202 6926, Tax Boxes, Bags, Lighter, Texter and Others.
*202 6927, Cans of Coca Cola, Bottles, Cordial and Others.
*202 6928, Pairs of Shoes or Slippers, Square Pillow and Others.
*202 6929, Big Spoon and Others.
*202 6930, Cola Bottle and Others.
*202 61001, Yellow Bag and Others.
*202 61002, Coathangers or Towels and Others.
*202 61003, Knives, Forks and Others.
*202 61004, Hats like DNA hat and Others.
*202 61005, Pizza SHAPES and Others.
*202 61006, Old Pillow or Cola and Others.
*202 61007, Baskets, Coathangers and Others.
*202 61008, Plastic Cups and Others.
*202 61009, Towels, Black and White and Others.
*202 61010, Vacuum Cleaner and Others.
*202 61011, Blue Pens, Other Pens and Others.
*202 61012, Bag, Bars and Others.
*202 61013, Baskets, Blankets, Staples and Others.
*202 61014, K-Times, Bandai, Betadine and Others.
*202 61015, Food Shopping like Chipstack, Waters and Others.
*202 61016, Plates, Container, Foil, Battery Pack and Others.
Current, 202 61017. ($#!+).
*202 61018. (Snakes and Ladders, Towel or Board Game)
''20261019''
2wntfv295r9bc7kqmdwpgclgvw5tuva
781266
781265
2026-10-27T23:01:11Z
~2026-57078-19
76423
781266
wikitext
text/x-wiki
= Test =
1027 - Generation
Candy Crush Saga Wiki: Tasty Sugar Track Edits Is Out Of Control (Mostly 6/23) and until 24 Restriction until no edits made all day, Use Account Sometimes.
Up To Date
2:6 — 10:18, 252 days, (10:19+ to fill out).
*10:19, 10:20, 10:21, 10:22, 10:23 (?)
*10:24 (10:25)
''10:26''
*202 6525, Pot with Fork and Spoon, Bags with Sunglasses.
*202 6526, Baskets, Tobacco Stuff or Socks.
*202 6527, Food Shopping like Cola Bottles, Cans, Cordial and Others.
*202 6528, Socks, Pairs of Shoes, Dressing Gowns.
*202 6529, Towels, Cubes with Things.
*202 6530, Cloths, Flowers Frisun or Soap.
*202 6531, Food Shopping like Party Pies, Waters and Others.
*202 6601, Pairs of Boots, Pillow or Podiatrist.
*202 6602, Plates or Pans, Grated Cheese or Baskets.
*202 6603, Forks, Pans and Other Kitchen Tools.
*202 6604, Cups, Cordial Jug or Strawberry Milk.
*202 6605, Food Shopping like Cans of Coca Cola, Waters, Cordials or Pile of Sauce.
*202 6606, Purses, Lu Papers or Paper Towels.
*202 6607, Candles, Statues or Pizza Boxes.
*202 6608, Cups, Blankets and Others.
*202 6609, Food Shoppibg like Cola Bottles, Sour Cream and Onion Chips or Cans.
*202 6610, Towels, Cloths, Soap or Containers.
*202 6611, Nachos, Apple N Raspberry Codrial or Stationary.
*202 6612, Basket, Electric Bed with Instructions or Paint.
*202 6613, Aerobar, Birthday Cake or Packet of Candles.
*202 6614, Plates, Blankets or even Vacuum Cleaner.
*202 6615, Towels, Cloths or Dressing Gowns.
*202 6616, Smiths Chips, Heater Vents, Stuff for Lasagna or Cloths.
*202 6617, Pans, Table Spoons or Lids.
*202 6618, Food Shopping like Cans of Coca Cola, Waters, Milk and Others.
*202 6619, Chocolate Milk, Cups of Tea or Extra Mints.
*202 6620, Kitkats, Pens or Domino's Pizza.
*202 6621, Blankets and Pillows or Vacuum Cleaner.
*202 6622, Tea Towels, Cans of Coca Cola or Carlton Draught.
*202 6623, Food Shopping like Bottles of Cola, Waters or New Pyjamas.
*202 6624, Staples, Socks, Hats and Others.
*202 6625, Kitchen Bowls, Pans and Others.
*202 6626, Food Shopping like Colas, Milk or Rainbow Paddlepop.
*202 6627, Tablemats, Sunglasses or Garden Plant.
*202 6628, Boots, Sleep Medicine or Pillow.
*202 6629, Sewing Machine, Tobacco Stuff or Pan.
*202 6630, Food Shopping like Cans of Cocacola, Bottles, Kitkat or M&Ms.
*202 6701, BBQ Chips, Biskits or Grated Cheese.
*202 6702, Lighters, Plates or Cloths.
*202 6703, Hot Dogs, Cola Cans or Lasagna.
*202 6704, Mop, Battery Packs or Doorhandle.
*202 6705, Video Cameras, Internet or McDonalds Bag?.
*202 6706, Pans, Freshmint, or Hotdog Roll.
*202 6707, Food Shopping like Cans of Coca Cola, Cola Bottles, Waters, Shopping Bag or Grated Cheese.
*202 6708, Ktimes, Nutrigrain or Mueslibars.
*202 6709, Saladas, Socks, Vegemite or Textures.
*202 6710, Baskets, Favourites or Cordial Jug.
*202 6711, Shoes like Novo, Vacuum or Carlton Draught.
*202 6712, Aldi Stuff like Mints, Airfryer or Towel.
*202 6713, Food Shopping like Cola Bottles, Cheese, Milk or Paddle Pops.
*202 6714, Baskets, Mask Fresheners or Coat.
*202 6715, Towels, Cloths or Lasagna?.
*202 6716, Soaps, Toothpastes, Cotton Buds and Others.
*202 6717, Cans of Coca Cola, Bottles, Waters, Novo Shoes and Others.
*202 6718, Food Shopping like Upngo, Breads or Cans of Coca Cola.
*202 6719, Quichebox, Plate or Cup or Cheese Machine.
*202 6720, Cups, Milkshakes, Jug or Can of Coca Cola?.
*202 6721, Baskets, Coathangers, Chocolate Milk or Frypan?.
*202 6722, Food Shopping like Cola Bottles, Waters, Milk or Nachos?
*202 6723, Slippers, Blanket Sheets, Clothes Pair and Others.
*202 6724, Pans, Plates, Scissors or Tools.
*202 6725, Aldi Stuff, Domino's Pizza or Lynxspray.
*202 6726, Towels, Lighter or Journal Books.
*202 6727, Food Shopping like Bottles of Cola or Water, Cans or Milk.
*202 6728, Baskets, Battery Packs or Vaporizer and Others.
*202 6729, Sleep Medicines, Deep Heat or Pillows.
*202 6730, Schnitzels, Nesquiks, Milos and Others.
*202 6731, Toothpicks and Nuts and Others.
*202 6801, Food Shopping like Bottles, Cans, Waters and Others.
*202 6802, Plates, Bowls or Battery Packs.
*202 6803, Towels, Blankets, Purse or Boots.
*202 6804, M&M's, Baskets, Cloths or Coathangers.
*202 6805, Boots, Leaves, Texture and Others.
*202 6806, Food Shopping like Cans of Coca Cola, Nachos, Cheese or Milk.
*202 6807, Dressing Gowns, Glasses or Pillows.
*202 6808, Grater Cheese, Pizza, Mat or Plate.
*202 6809, Stylus, Mask, Soaps and Others.
*202 6810, Paper Towels, Staples, Lighter and Others.
*202 6811, Baskets, Cans of Coca Cola or Biskit Shapes.
*202 6812, Blackngold Cola, Blankets or Chocolatebox.
*202 6813, Smiths Chips, Cheese Mozarella or Bread.
*202 6814, Textures, Pens or Basket and Others.
*202 6815, Smoothies, Toys, Chopping Board or Box.
*202 6816, Cups like Gold, Cup of Tea, Metal Bottle and Others.
*202 6817, Food Shopping like Water Bottles, Cans of Coca Cola, Cola Bottles or Chocolate Milk.
*202 6818, Baskets, Keys or Cordial Jug and Others.
*202 6819, Aldi Stuff, Pizza or Pumpsoap.
*202 6820, Towels, Blankets Sheets and Others.
*202 6821, Cheese, Breads, Waters or Cans of Coca Cola and Others.
*202 6822, Sleep Medicone, Quichebox, Towels and Others.
*202 6823, Shoes, Hats or Ashtray and Others.
*202 6824, Cups, Milk, Towels or some Aldi and Others.
*202 6825, Battery Pack, Clothes and Others.
*202 6826, Cans of Coca Cola, Lime Cordial, Milk and Others.
*202 6827, Blankets, Pillows, Circle Table and Others.
*202 6828, Grated Cheese, Scissors, Knifes and Forks and Others.
*202 6829, Cans of Coca Cola, Bottles, Waters, Milk and Others.
*202 6830, Plates, Stand, Frypans and Others.
*202 6831, Metal Things, Water Bottles, Cans of Coca Cola and Others.
*202 6901, Aldi Bag with Stuff or Aprons.
*202 6902, Food Shopping like Cola Bottles, Cheese with Nachos and Others.
*202 6903, Mask Perfumes, Soaps and Others.
*202 6904, Baskets, Battery Pack, Pillows or Lasagna and Others
*202 6905, Frypans, Lids, Tablemats and Others.
*202 6906, Fathers Day Presents, Glasses, Bags and Others.
*202 6907, Deep Heat, Scissors, Milo or Container and Others.
*202 6908, Baskets, Coathanger, Blankets and Others.
*202 6909, Container Biscuit, Pastas or Pans.
*202 6910, Food Shopping like Milks, M&Ms, Sauces, Coca Cola Cans and Others.
*202 6911, Pillows, Dressing Gowns, Extra Mint and Others.
*202 6912, Laundry Stuff like boxes, bags or Dad's Wrist and Others.
*202 6913, Grater Stuff, Pans and Others.
*202 6914, Bottles of Cola, Breads, Milk to make Chocolate.
*202 6915, Iced Coffee, Water Bottles, Baskets and Others.
*202 6916, Aldi Bag, Strawberry Mints and Others.
*202 6917, Lighters, Plates, Freshener or Spray and Others.
*202 6918, New Clothes, Basket, Extra Mint and Others.
*202 6919, Face Cloths, Lu Paper, Water and Others.
*202 6920, Domino's Pizza, Pillows, Notes and Others.
*202 6921, Remotes, Pizza and Barbecue Shapes, Square Pillow and Others.
*202 6922, Food Shopping like Waters, Cans of Coca Cola, Cheese, Lasagna and Others.
*202 6923, Cloths, Facecloth, New Pot and Others.
*202 6924, Aldi Stuff, Glass Cups, Candles and Others.
*202 6925, Copy Paper, Glasses, Lasagna Tray Pan and Others.
*202 6926, Tax Boxes, Bags, Lighter, Texter and Others.
*202 6927, Cans of Coca Cola, Bottles, Cordial and Others.
*202 6928, Pairs of Shoes or Slippers, Square Pillow and Others.
*202 6929, Big Spoon and Others.
*202 6930, Cola Bottle and Others.
*202 61001, Yellow Bag and Others.
*202 61002, Coathangers or Towels and Others.
*202 61003, Knives, Forks and Others.
*202 61004, Hats like DNA hat and Others.
*202 61005, Pizza SHAPES and Others.
*202 61006, Old Pillow or Cola and Others.
*202 61007, Baskets, Coathangers and Others.
*202 61008, Plastic Cups and Others.
*202 61009, Towels, Black and White and Others.
*202 61010, Vacuum Cleaner and Others.
*202 61011, Blue Pens, Other Pens and Others.
*202 61012, Bag, Bars and Others.
*202 61013, Baskets, Blankets, Staples and Others.
*202 61014, K-Times, Bandai, Betadine and Others.
*202 61015, Food Shopping like Chipstack, Waters and Others.
*202 61016, Plates, Container, Foil, Battery Pack and Others.
Current, 202 61017. ($#!+).
*202 61018. (Snakes and Ladders, Towel or Board Game)
''20261019''
9cbcglld4wnrqwr7dicowt16f99evl9
781267
781266
2026-10-27T23:01:51Z
~2026-57078-19
76423
781267
wikitext
text/x-wiki
= Test =
1027 - Generations of People
Candy Crush Saga Wiki: Tasty Sugar Track Edits Is Out Of Control (Mostly 6/23) and until 24 Restriction until no edits made all day, Use Account Sometimes.
Up To Date
2:6 — 10:18, 252 days, (10:19+ to fill out).
*10:19, 10:20, 10:21, 10:22, 10:23 (?)
*10:24 (10:25)
''10:26''
*202 6525, Pot with Fork and Spoon, Bags with Sunglasses.
*202 6526, Baskets, Tobacco Stuff or Socks.
*202 6527, Food Shopping like Cola Bottles, Cans, Cordial and Others.
*202 6528, Socks, Pairs of Shoes, Dressing Gowns.
*202 6529, Towels, Cubes with Things.
*202 6530, Cloths, Flowers Frisun or Soap.
*202 6531, Food Shopping like Party Pies, Waters and Others.
*202 6601, Pairs of Boots, Pillow or Podiatrist.
*202 6602, Plates or Pans, Grated Cheese or Baskets.
*202 6603, Forks, Pans and Other Kitchen Tools.
*202 6604, Cups, Cordial Jug or Strawberry Milk.
*202 6605, Food Shopping like Cans of Coca Cola, Waters, Cordials or Pile of Sauce.
*202 6606, Purses, Lu Papers or Paper Towels.
*202 6607, Candles, Statues or Pizza Boxes.
*202 6608, Cups, Blankets and Others.
*202 6609, Food Shoppibg like Cola Bottles, Sour Cream and Onion Chips or Cans.
*202 6610, Towels, Cloths, Soap or Containers.
*202 6611, Nachos, Apple N Raspberry Codrial or Stationary.
*202 6612, Basket, Electric Bed with Instructions or Paint.
*202 6613, Aerobar, Birthday Cake or Packet of Candles.
*202 6614, Plates, Blankets or even Vacuum Cleaner.
*202 6615, Towels, Cloths or Dressing Gowns.
*202 6616, Smiths Chips, Heater Vents, Stuff for Lasagna or Cloths.
*202 6617, Pans, Table Spoons or Lids.
*202 6618, Food Shopping like Cans of Coca Cola, Waters, Milk and Others.
*202 6619, Chocolate Milk, Cups of Tea or Extra Mints.
*202 6620, Kitkats, Pens or Domino's Pizza.
*202 6621, Blankets and Pillows or Vacuum Cleaner.
*202 6622, Tea Towels, Cans of Coca Cola or Carlton Draught.
*202 6623, Food Shopping like Bottles of Cola, Waters or New Pyjamas.
*202 6624, Staples, Socks, Hats and Others.
*202 6625, Kitchen Bowls, Pans and Others.
*202 6626, Food Shopping like Colas, Milk or Rainbow Paddlepop.
*202 6627, Tablemats, Sunglasses or Garden Plant.
*202 6628, Boots, Sleep Medicine or Pillow.
*202 6629, Sewing Machine, Tobacco Stuff or Pan.
*202 6630, Food Shopping like Cans of Cocacola, Bottles, Kitkat or M&Ms.
*202 6701, BBQ Chips, Biskits or Grated Cheese.
*202 6702, Lighters, Plates or Cloths.
*202 6703, Hot Dogs, Cola Cans or Lasagna.
*202 6704, Mop, Battery Packs or Doorhandle.
*202 6705, Video Cameras, Internet or McDonalds Bag?.
*202 6706, Pans, Freshmint, or Hotdog Roll.
*202 6707, Food Shopping like Cans of Coca Cola, Cola Bottles, Waters, Shopping Bag or Grated Cheese.
*202 6708, Ktimes, Nutrigrain or Mueslibars.
*202 6709, Saladas, Socks, Vegemite or Textures.
*202 6710, Baskets, Favourites or Cordial Jug.
*202 6711, Shoes like Novo, Vacuum or Carlton Draught.
*202 6712, Aldi Stuff like Mints, Airfryer or Towel.
*202 6713, Food Shopping like Cola Bottles, Cheese, Milk or Paddle Pops.
*202 6714, Baskets, Mask Fresheners or Coat.
*202 6715, Towels, Cloths or Lasagna?.
*202 6716, Soaps, Toothpastes, Cotton Buds and Others.
*202 6717, Cans of Coca Cola, Bottles, Waters, Novo Shoes and Others.
*202 6718, Food Shopping like Upngo, Breads or Cans of Coca Cola.
*202 6719, Quichebox, Plate or Cup or Cheese Machine.
*202 6720, Cups, Milkshakes, Jug or Can of Coca Cola?.
*202 6721, Baskets, Coathangers, Chocolate Milk or Frypan?.
*202 6722, Food Shopping like Cola Bottles, Waters, Milk or Nachos?
*202 6723, Slippers, Blanket Sheets, Clothes Pair and Others.
*202 6724, Pans, Plates, Scissors or Tools.
*202 6725, Aldi Stuff, Domino's Pizza or Lynxspray.
*202 6726, Towels, Lighter or Journal Books.
*202 6727, Food Shopping like Bottles of Cola or Water, Cans or Milk.
*202 6728, Baskets, Battery Packs or Vaporizer and Others.
*202 6729, Sleep Medicines, Deep Heat or Pillows.
*202 6730, Schnitzels, Nesquiks, Milos and Others.
*202 6731, Toothpicks and Nuts and Others.
*202 6801, Food Shopping like Bottles, Cans, Waters and Others.
*202 6802, Plates, Bowls or Battery Packs.
*202 6803, Towels, Blankets, Purse or Boots.
*202 6804, M&M's, Baskets, Cloths or Coathangers.
*202 6805, Boots, Leaves, Texture and Others.
*202 6806, Food Shopping like Cans of Coca Cola, Nachos, Cheese or Milk.
*202 6807, Dressing Gowns, Glasses or Pillows.
*202 6808, Grater Cheese, Pizza, Mat or Plate.
*202 6809, Stylus, Mask, Soaps and Others.
*202 6810, Paper Towels, Staples, Lighter and Others.
*202 6811, Baskets, Cans of Coca Cola or Biskit Shapes.
*202 6812, Blackngold Cola, Blankets or Chocolatebox.
*202 6813, Smiths Chips, Cheese Mozarella or Bread.
*202 6814, Textures, Pens or Basket and Others.
*202 6815, Smoothies, Toys, Chopping Board or Box.
*202 6816, Cups like Gold, Cup of Tea, Metal Bottle and Others.
*202 6817, Food Shopping like Water Bottles, Cans of Coca Cola, Cola Bottles or Chocolate Milk.
*202 6818, Baskets, Keys or Cordial Jug and Others.
*202 6819, Aldi Stuff, Pizza or Pumpsoap.
*202 6820, Towels, Blankets Sheets and Others.
*202 6821, Cheese, Breads, Waters or Cans of Coca Cola and Others.
*202 6822, Sleep Medicone, Quichebox, Towels and Others.
*202 6823, Shoes, Hats or Ashtray and Others.
*202 6824, Cups, Milk, Towels or some Aldi and Others.
*202 6825, Battery Pack, Clothes and Others.
*202 6826, Cans of Coca Cola, Lime Cordial, Milk and Others.
*202 6827, Blankets, Pillows, Circle Table and Others.
*202 6828, Grated Cheese, Scissors, Knifes and Forks and Others.
*202 6829, Cans of Coca Cola, Bottles, Waters, Milk and Others.
*202 6830, Plates, Stand, Frypans and Others.
*202 6831, Metal Things, Water Bottles, Cans of Coca Cola and Others.
*202 6901, Aldi Bag with Stuff or Aprons.
*202 6902, Food Shopping like Cola Bottles, Cheese with Nachos and Others.
*202 6903, Mask Perfumes, Soaps and Others.
*202 6904, Baskets, Battery Pack, Pillows or Lasagna and Others
*202 6905, Frypans, Lids, Tablemats and Others.
*202 6906, Fathers Day Presents, Glasses, Bags and Others.
*202 6907, Deep Heat, Scissors, Milo or Container and Others.
*202 6908, Baskets, Coathanger, Blankets and Others.
*202 6909, Container Biscuit, Pastas or Pans.
*202 6910, Food Shopping like Milks, M&Ms, Sauces, Coca Cola Cans and Others.
*202 6911, Pillows, Dressing Gowns, Extra Mint and Others.
*202 6912, Laundry Stuff like boxes, bags or Dad's Wrist and Others.
*202 6913, Grater Stuff, Pans and Others.
*202 6914, Bottles of Cola, Breads, Milk to make Chocolate.
*202 6915, Iced Coffee, Water Bottles, Baskets and Others.
*202 6916, Aldi Bag, Strawberry Mints and Others.
*202 6917, Lighters, Plates, Freshener or Spray and Others.
*202 6918, New Clothes, Basket, Extra Mint and Others.
*202 6919, Face Cloths, Lu Paper, Water and Others.
*202 6920, Domino's Pizza, Pillows, Notes and Others.
*202 6921, Remotes, Pizza and Barbecue Shapes, Square Pillow and Others.
*202 6922, Food Shopping like Waters, Cans of Coca Cola, Cheese, Lasagna and Others.
*202 6923, Cloths, Facecloth, New Pot and Others.
*202 6924, Aldi Stuff, Glass Cups, Candles and Others.
*202 6925, Copy Paper, Glasses, Lasagna Tray Pan and Others.
*202 6926, Tax Boxes, Bags, Lighter, Texter and Others.
*202 6927, Cans of Coca Cola, Bottles, Cordial and Others.
*202 6928, Pairs of Shoes or Slippers, Square Pillow and Others.
*202 6929, Big Spoon and Others.
*202 6930, Cola Bottle and Others.
*202 61001, Yellow Bag and Others.
*202 61002, Coathangers or Towels and Others.
*202 61003, Knives, Forks and Others.
*202 61004, Hats like DNA hat and Others.
*202 61005, Pizza SHAPES and Others.
*202 61006, Old Pillow or Cola and Others.
*202 61007, Baskets, Coathangers and Others.
*202 61008, Plastic Cups and Others.
*202 61009, Towels, Black and White and Others.
*202 61010, Vacuum Cleaner and Others.
*202 61011, Blue Pens, Other Pens and Others.
*202 61012, Bag, Bars and Others.
*202 61013, Baskets, Blankets, Staples and Others.
*202 61014, K-Times, Bandai, Betadine and Others.
*202 61015, Food Shopping like Chipstack, Waters and Others.
*202 61016, Plates, Container, Foil, Battery Pack and Others.
Current, 202 61017. ($#!+).
*202 61018. (Snakes and Ladders, Towel or Board Game)
''20261019''
q8a85v9rxumgqgpwt8t1w9v3hxt796p
781268
781267
2026-10-27T23:02:23Z
~2026-57078-19
76423
/* Test */ All done
781268
wikitext
text/x-wiki
= Test =
1027 - Generations of People
Candy Crush Saga Wiki: Tasty Sugar Track Edits Is Out Of Control (Mostly 6/23) and until 24 Restriction until no edits made all day, Use Account Sometimes.
Up To Date
2:6 — 10:18, 252 days, (10:19+ to fill out).
*10:19, 10:20, 10:21, 10:22, 10:23 (?)
*10:24 (?)
*10:25 (10:26)
''10:27''
*202 6525, Pot with Fork and Spoon, Bags with Sunglasses.
*202 6526, Baskets, Tobacco Stuff or Socks.
*202 6527, Food Shopping like Cola Bottles, Cans, Cordial and Others.
*202 6528, Socks, Pairs of Shoes, Dressing Gowns.
*202 6529, Towels, Cubes with Things.
*202 6530, Cloths, Flowers Frisun or Soap.
*202 6531, Food Shopping like Party Pies, Waters and Others.
*202 6601, Pairs of Boots, Pillow or Podiatrist.
*202 6602, Plates or Pans, Grated Cheese or Baskets.
*202 6603, Forks, Pans and Other Kitchen Tools.
*202 6604, Cups, Cordial Jug or Strawberry Milk.
*202 6605, Food Shopping like Cans of Coca Cola, Waters, Cordials or Pile of Sauce.
*202 6606, Purses, Lu Papers or Paper Towels.
*202 6607, Candles, Statues or Pizza Boxes.
*202 6608, Cups, Blankets and Others.
*202 6609, Food Shoppibg like Cola Bottles, Sour Cream and Onion Chips or Cans.
*202 6610, Towels, Cloths, Soap or Containers.
*202 6611, Nachos, Apple N Raspberry Codrial or Stationary.
*202 6612, Basket, Electric Bed with Instructions or Paint.
*202 6613, Aerobar, Birthday Cake or Packet of Candles.
*202 6614, Plates, Blankets or even Vacuum Cleaner.
*202 6615, Towels, Cloths or Dressing Gowns.
*202 6616, Smiths Chips, Heater Vents, Stuff for Lasagna or Cloths.
*202 6617, Pans, Table Spoons or Lids.
*202 6618, Food Shopping like Cans of Coca Cola, Waters, Milk and Others.
*202 6619, Chocolate Milk, Cups of Tea or Extra Mints.
*202 6620, Kitkats, Pens or Domino's Pizza.
*202 6621, Blankets and Pillows or Vacuum Cleaner.
*202 6622, Tea Towels, Cans of Coca Cola or Carlton Draught.
*202 6623, Food Shopping like Bottles of Cola, Waters or New Pyjamas.
*202 6624, Staples, Socks, Hats and Others.
*202 6625, Kitchen Bowls, Pans and Others.
*202 6626, Food Shopping like Colas, Milk or Rainbow Paddlepop.
*202 6627, Tablemats, Sunglasses or Garden Plant.
*202 6628, Boots, Sleep Medicine or Pillow.
*202 6629, Sewing Machine, Tobacco Stuff or Pan.
*202 6630, Food Shopping like Cans of Cocacola, Bottles, Kitkat or M&Ms.
*202 6701, BBQ Chips, Biskits or Grated Cheese.
*202 6702, Lighters, Plates or Cloths.
*202 6703, Hot Dogs, Cola Cans or Lasagna.
*202 6704, Mop, Battery Packs or Doorhandle.
*202 6705, Video Cameras, Internet or McDonalds Bag?.
*202 6706, Pans, Freshmint, or Hotdog Roll.
*202 6707, Food Shopping like Cans of Coca Cola, Cola Bottles, Waters, Shopping Bag or Grated Cheese.
*202 6708, Ktimes, Nutrigrain or Mueslibars.
*202 6709, Saladas, Socks, Vegemite or Textures.
*202 6710, Baskets, Favourites or Cordial Jug.
*202 6711, Shoes like Novo, Vacuum or Carlton Draught.
*202 6712, Aldi Stuff like Mints, Airfryer or Towel.
*202 6713, Food Shopping like Cola Bottles, Cheese, Milk or Paddle Pops.
*202 6714, Baskets, Mask Fresheners or Coat.
*202 6715, Towels, Cloths or Lasagna?.
*202 6716, Soaps, Toothpastes, Cotton Buds and Others.
*202 6717, Cans of Coca Cola, Bottles, Waters, Novo Shoes and Others.
*202 6718, Food Shopping like Upngo, Breads or Cans of Coca Cola.
*202 6719, Quichebox, Plate or Cup or Cheese Machine.
*202 6720, Cups, Milkshakes, Jug or Can of Coca Cola?.
*202 6721, Baskets, Coathangers, Chocolate Milk or Frypan?.
*202 6722, Food Shopping like Cola Bottles, Waters, Milk or Nachos?
*202 6723, Slippers, Blanket Sheets, Clothes Pair and Others.
*202 6724, Pans, Plates, Scissors or Tools.
*202 6725, Aldi Stuff, Domino's Pizza or Lynxspray.
*202 6726, Towels, Lighter or Journal Books.
*202 6727, Food Shopping like Bottles of Cola or Water, Cans or Milk.
*202 6728, Baskets, Battery Packs or Vaporizer and Others.
*202 6729, Sleep Medicines, Deep Heat or Pillows.
*202 6730, Schnitzels, Nesquiks, Milos and Others.
*202 6731, Toothpicks and Nuts and Others.
*202 6801, Food Shopping like Bottles, Cans, Waters and Others.
*202 6802, Plates, Bowls or Battery Packs.
*202 6803, Towels, Blankets, Purse or Boots.
*202 6804, M&M's, Baskets, Cloths or Coathangers.
*202 6805, Boots, Leaves, Texture and Others.
*202 6806, Food Shopping like Cans of Coca Cola, Nachos, Cheese or Milk.
*202 6807, Dressing Gowns, Glasses or Pillows.
*202 6808, Grater Cheese, Pizza, Mat or Plate.
*202 6809, Stylus, Mask, Soaps and Others.
*202 6810, Paper Towels, Staples, Lighter and Others.
*202 6811, Baskets, Cans of Coca Cola or Biskit Shapes.
*202 6812, Blackngold Cola, Blankets or Chocolatebox.
*202 6813, Smiths Chips, Cheese Mozarella or Bread.
*202 6814, Textures, Pens or Basket and Others.
*202 6815, Smoothies, Toys, Chopping Board or Box.
*202 6816, Cups like Gold, Cup of Tea, Metal Bottle and Others.
*202 6817, Food Shopping like Water Bottles, Cans of Coca Cola, Cola Bottles or Chocolate Milk.
*202 6818, Baskets, Keys or Cordial Jug and Others.
*202 6819, Aldi Stuff, Pizza or Pumpsoap.
*202 6820, Towels, Blankets Sheets and Others.
*202 6821, Cheese, Breads, Waters or Cans of Coca Cola and Others.
*202 6822, Sleep Medicone, Quichebox, Towels and Others.
*202 6823, Shoes, Hats or Ashtray and Others.
*202 6824, Cups, Milk, Towels or some Aldi and Others.
*202 6825, Battery Pack, Clothes and Others.
*202 6826, Cans of Coca Cola, Lime Cordial, Milk and Others.
*202 6827, Blankets, Pillows, Circle Table and Others.
*202 6828, Grated Cheese, Scissors, Knifes and Forks and Others.
*202 6829, Cans of Coca Cola, Bottles, Waters, Milk and Others.
*202 6830, Plates, Stand, Frypans and Others.
*202 6831, Metal Things, Water Bottles, Cans of Coca Cola and Others.
*202 6901, Aldi Bag with Stuff or Aprons.
*202 6902, Food Shopping like Cola Bottles, Cheese with Nachos and Others.
*202 6903, Mask Perfumes, Soaps and Others.
*202 6904, Baskets, Battery Pack, Pillows or Lasagna and Others
*202 6905, Frypans, Lids, Tablemats and Others.
*202 6906, Fathers Day Presents, Glasses, Bags and Others.
*202 6907, Deep Heat, Scissors, Milo or Container and Others.
*202 6908, Baskets, Coathanger, Blankets and Others.
*202 6909, Container Biscuit, Pastas or Pans.
*202 6910, Food Shopping like Milks, M&Ms, Sauces, Coca Cola Cans and Others.
*202 6911, Pillows, Dressing Gowns, Extra Mint and Others.
*202 6912, Laundry Stuff like boxes, bags or Dad's Wrist and Others.
*202 6913, Grater Stuff, Pans and Others.
*202 6914, Bottles of Cola, Breads, Milk to make Chocolate.
*202 6915, Iced Coffee, Water Bottles, Baskets and Others.
*202 6916, Aldi Bag, Strawberry Mints and Others.
*202 6917, Lighters, Plates, Freshener or Spray and Others.
*202 6918, New Clothes, Basket, Extra Mint and Others.
*202 6919, Face Cloths, Lu Paper, Water and Others.
*202 6920, Domino's Pizza, Pillows, Notes and Others.
*202 6921, Remotes, Pizza and Barbecue Shapes, Square Pillow and Others.
*202 6922, Food Shopping like Waters, Cans of Coca Cola, Cheese, Lasagna and Others.
*202 6923, Cloths, Facecloth, New Pot and Others.
*202 6924, Aldi Stuff, Glass Cups, Candles and Others.
*202 6925, Copy Paper, Glasses, Lasagna Tray Pan and Others.
*202 6926, Tax Boxes, Bags, Lighter, Texter and Others.
*202 6927, Cans of Coca Cola, Bottles, Cordial and Others.
*202 6928, Pairs of Shoes or Slippers, Square Pillow and Others.
*202 6929, Big Spoon and Others.
*202 6930, Cola Bottle and Others.
*202 61001, Yellow Bag and Others.
*202 61002, Coathangers or Towels and Others.
*202 61003, Knives, Forks and Others.
*202 61004, Hats like DNA hat and Others.
*202 61005, Pizza SHAPES and Others.
*202 61006, Old Pillow or Cola and Others.
*202 61007, Baskets, Coathangers and Others.
*202 61008, Plastic Cups and Others.
*202 61009, Towels, Black and White and Others.
*202 61010, Vacuum Cleaner and Others.
*202 61011, Blue Pens, Other Pens and Others.
*202 61012, Bag, Bars and Others.
*202 61013, Baskets, Blankets, Staples and Others.
*202 61014, K-Times, Bandai, Betadine and Others.
*202 61015, Food Shopping like Chipstack, Waters and Others.
*202 61016, Plates, Container, Foil, Battery Pack and Others.
Current, 202 61017. ($#!+).
*202 61018. (Snakes and Ladders, Towel or Board Game)
''20261019''
bcjbfljkvili9ijplgwxzpbidlrudn7
User talk:177.193.205.196
3
75067
781313
189718
2026-10-28T00:39:23Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781313
wikitext
text/x-wiki
{{Consent}}
== Indicação de eliminação rápida para a página Teste10000 ==
<div style="border:1px solid #ccc;border-left:5px solid #CC0000;padding:10px;display:table;border-radius:5px">
<!-- O aviso a seguir foi substituído da Predefinição:Aviso-ER -->
<div style="min-height: 100%;padding: 2% 20px 0 0;display: inline-table;float:left">[[Imagem:Antu emblem-important.svg|50px]]</div>
<div style="padding: 10px 10px 10px 0;display: inline-table;width: 80%;">
Olá, 177.193.205.196. A página [[:Teste10000]] que criou foi indicada para [[Wikipédia:Eliminação rápida|eliminação rápida]].
*
Caso acredite que houve uma violação da [[Wikipédia:Política de eliminação|nossa política de eliminação]], abra um pedido em [[Wikipédia:Pedidos/Restauro]] ou, se preferir, deixe uma nota em minha página de discussão caso tenha qualquer dúvida sobre o assunto.</div>
</div> [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 22:29, 23 December 2013 (UTC)
6t2ojg80deapje4b6icn8i2dxocrryy
781348
781313
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
189718
wikitext
text/x-wiki
{{subst:Aviso-ER|Teste10000}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 22:29, 23 December 2013 (UTC)
1iva0wov6m7t1x14hn8ecfwyr968m6h
User talk:!sleehW no ylliw
3
76273
781285
200274
2026-10-28T00:35:39Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781285
wikitext
text/x-wiki
{{Consent}}
==Welcome!==
[[Category:Really big category]]
[[Image:Wiki.png|right|120px]]
'''Hello {{BASEPAGENAME}}''', and welcome to [[Wikipedia:Test Wikipedia|Test Wikipedia]], a [[:en:wiki|wiki]] designed for testing of software, among other things.
''Some good links include:''
*[[Sandbox]]
*[[Wikipedia:What test wiki is|What test wiki is]]
*[[Wikipedia:What test wiki is not|What test wiki is '''not''']]
*[[Wikipedia:Requests|Requests for permissions, renames and others]]
*[[Wikipedia:Village pump]]
*[[Wikipedia:Administrators]]
*[[Wikipedia:Bureaucrats]]
If you have any questions, please don't hesitate to ask me on my talk page. Thanks. --[[User:Liuxinyu970226|Liuxinyu970226]] ([[User talk:Liuxinyu970226|talk]]) 04:07, 31 May 2014 (UTC)
ecwj5wolqyrmwc779705lbxns1kbuaa
781376
781285
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
200274
wikitext
text/x-wiki
==Welcome!==
[[Category:Really big category]]
[[Image:Wiki.png|right|120px]]
'''Hello {{BASEPAGENAME}}''', and welcome to [[Wikipedia:Test Wikipedia|Test Wikipedia]], a [[:en:wiki|wiki]] designed for testing of software, among other things.
''Some good links include:''
*[[Sandbox]]
*[[Wikipedia:What test wiki is|What test wiki is]]
*[[Wikipedia:What test wiki is not|What test wiki is '''not''']]
*[[Wikipedia:Requests|Requests for permissions, renames and others]]
*[[Wikipedia:Village pump]]
*[[Wikipedia:Administrators]]
*[[Wikipedia:Bureaucrats]]
If you have any questions, please don't hesitate to ask me on my talk page. Thanks. --[[User:Liuxinyu970226|Liuxinyu970226]] ([[User talk:Liuxinyu970226|talk]]) 04:07, 31 May 2014 (UTC)
3zvq0yc75stoif3gl5am1s1btjgr95p
User talk:!sleehw no ylliW
3
76274
781286
497700
2026-10-28T00:35:42Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781286
wikitext
text/x-wiki
{{Consent}}
==Welcome!==
[[Category:Really big category]]
[[Image:Wiki.png|right|120px]]
'''Hello {{BASEPAGENAME}}''', and welcome to [[Wikipedia:Test Wikipedia|Test Wikipedia]], a [[:en:wiki|wiki]] designed for testing of software, among other things.
''Some good links include:''
*[[Sandbox]]
*[[Wikipedia:What test wiki is|What test wiki is]]
*[[Wikipedia:What test wiki is not|What test wiki is '''not''']]
*[[Wikipedia:Requests|Requests for permissions, renames and others]]
*[[Wikipedia:Village pump]]
*[[Wikipedia:Administrators]]
*[[Wikipedia:Bureaucrats]]
If you have any questions, please don't hesitate to ask me on my talk page. Thanks. --[[User:Liuxinyu970226|Liuxinyu970226]] ([[User talk:Liuxinyu970226|talk]]) 04:07, 31 May 2014 (UTC)
ecwj5wolqyrmwc779705lbxns1kbuaa
781397
781286
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
497700
wikitext
text/x-wiki
==Welcome!==
[[Category:Really big category]]
[[Image:Wiki.png|right|120px]]
'''Hello {{BASEPAGENAME}}''', and welcome to [[Wikipedia:Test Wikipedia|Test Wikipedia]], a [[:en:wiki|wiki]] designed for testing of software, among other things.
''Some good links include:''
*[[Sandbox]]
*[[Wikipedia:What test wiki is|What test wiki is]]
*[[Wikipedia:What test wiki is not|What test wiki is '''not''']]
*[[Wikipedia:Requests|Requests for permissions, renames and others]]
*[[Wikipedia:Village pump]]
*[[Wikipedia:Administrators]]
*[[Wikipedia:Bureaucrats]]
If you have any questions, please don't hesitate to ask me on my talk page. Thanks. --[[User:Liuxinyu970226|Liuxinyu970226]] ([[User talk:Liuxinyu970226|talk]]) 04:07, 31 May 2014 (UTC)
3zvq0yc75stoif3gl5am1s1btjgr95p
User talk:!sleehW nO ylliW
3
76275
781284
200276
2026-10-28T00:35:34Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781284
wikitext
text/x-wiki
{{Consent}}
==Welcome!==
[[Category:Really big category]]
[[Image:Wiki.png|right|120px]]
'''Hello {{BASEPAGENAME}}''', and welcome to [[Wikipedia:Test Wikipedia|Test Wikipedia]], a [[:en:wiki|wiki]] designed for testing of software, among other things.
''Some good links include:''
*[[Sandbox]]
*[[Wikipedia:What test wiki is|What test wiki is]]
*[[Wikipedia:What test wiki is not|What test wiki is '''not''']]
*[[Wikipedia:Requests|Requests for permissions, renames and others]]
*[[Wikipedia:Village pump]]
*[[Wikipedia:Administrators]]
*[[Wikipedia:Bureaucrats]]
If you have any questions, please don't hesitate to ask me on my talk page. Thanks. --[[User:Liuxinyu970226|Liuxinyu970226]] ([[User talk:Liuxinyu970226|talk]]) 04:08, 31 May 2014 (UTC)
nc3l8g08o2qxpf2ymctr3pdcjwcgtfg
781361
781284
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
200276
wikitext
text/x-wiki
==Welcome!==
[[Category:Really big category]]
[[Image:Wiki.png|right|120px]]
'''Hello {{BASEPAGENAME}}''', and welcome to [[Wikipedia:Test Wikipedia|Test Wikipedia]], a [[:en:wiki|wiki]] designed for testing of software, among other things.
''Some good links include:''
*[[Sandbox]]
*[[Wikipedia:What test wiki is|What test wiki is]]
*[[Wikipedia:What test wiki is not|What test wiki is '''not''']]
*[[Wikipedia:Requests|Requests for permissions, renames and others]]
*[[Wikipedia:Village pump]]
*[[Wikipedia:Administrators]]
*[[Wikipedia:Bureaucrats]]
If you have any questions, please don't hesitate to ask me on my talk page. Thanks. --[[User:Liuxinyu970226|Liuxinyu970226]] ([[User talk:Liuxinyu970226|talk]]) 04:08, 31 May 2014 (UTC)
2aigw0x4ll6lwfhf3jobwnroi19hurj
User talk:!zleehw no ylliw
3
76276
781287
200277
2026-10-28T00:35:46Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781287
wikitext
text/x-wiki
{{Consent}}
==Welcome!==
[[Category:Really big category]]
[[Image:Wiki.png|right|120px]]
'''Hello {{BASEPAGENAME}}''', and welcome to [[Wikipedia:Test Wikipedia|Test Wikipedia]], a [[:en:wiki|wiki]] designed for testing of software, among other things.
''Some good links include:''
*[[Sandbox]]
*[[Wikipedia:What test wiki is|What test wiki is]]
*[[Wikipedia:What test wiki is not|What test wiki is '''not''']]
*[[Wikipedia:Requests|Requests for permissions, renames and others]]
*[[Wikipedia:Village pump]]
*[[Wikipedia:Administrators]]
*[[Wikipedia:Bureaucrats]]
If you have any questions, please don't hesitate to ask me on my talk page. Thanks. --[[User:Liuxinyu970226|Liuxinyu970226]] ([[User talk:Liuxinyu970226|talk]]) 04:08, 31 May 2014 (UTC)
nc3l8g08o2qxpf2ymctr3pdcjwcgtfg
781383
781287
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
200277
wikitext
text/x-wiki
==Welcome!==
[[Category:Really big category]]
[[Image:Wiki.png|right|120px]]
'''Hello {{BASEPAGENAME}}''', and welcome to [[Wikipedia:Test Wikipedia|Test Wikipedia]], a [[:en:wiki|wiki]] designed for testing of software, among other things.
''Some good links include:''
*[[Sandbox]]
*[[Wikipedia:What test wiki is|What test wiki is]]
*[[Wikipedia:What test wiki is not|What test wiki is '''not''']]
*[[Wikipedia:Requests|Requests for permissions, renames and others]]
*[[Wikipedia:Village pump]]
*[[Wikipedia:Administrators]]
*[[Wikipedia:Bureaucrats]]
If you have any questions, please don't hesitate to ask me on my talk page. Thanks. --[[User:Liuxinyu970226|Liuxinyu970226]] ([[User talk:Liuxinyu970226|talk]]) 04:08, 31 May 2014 (UTC)
2aigw0x4ll6lwfhf3jobwnroi19hurj
User talk:!!Fabio de Mello.S!!
3
76277
781278
497697
2026-10-28T00:32:45Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781278
wikitext
text/x-wiki
{{Consent}}
==Welcome!==
[[Category:Really big category]]
[[Image:Wiki.png|right|120px]]
'''Hello {{BASEPAGENAME}}''', and welcome to [[Wikipedia:Test Wikipedia|Test Wikipedia]], a [[:en:wiki|wiki]] designed for testing of software, among other things.
''Some good links include:''
*[[Sandbox]]
*[[Wikipedia:What test wiki is|What test wiki is]]
*[[Wikipedia:What test wiki is not|What test wiki is '''not''']]
*[[Wikipedia:Requests|Requests for permissions, renames and others]]
*[[Wikipedia:Village pump]]
*[[Wikipedia:Administrators]]
*[[Wikipedia:Bureaucrats]]
If you have any questions, please don't hesitate to ask me on my talk page. Thanks. --[[User:Liuxinyu970226|Liuxinyu970226]] ([[User talk:Liuxinyu970226|talk]]) 04:08, 31 May 2014 (UTC)
nc3l8g08o2qxpf2ymctr3pdcjwcgtfg
781372
781278
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
497697
wikitext
text/x-wiki
==Welcome!==
[[Category:Really big category]]
[[Image:Wiki.png|right|120px]]
'''Hello {{BASEPAGENAME}}''', and welcome to [[Wikipedia:Test Wikipedia|Test Wikipedia]], a [[:en:wiki|wiki]] designed for testing of software, among other things.
''Some good links include:''
*[[Sandbox]]
*[[Wikipedia:What test wiki is|What test wiki is]]
*[[Wikipedia:What test wiki is not|What test wiki is '''not''']]
*[[Wikipedia:Requests|Requests for permissions, renames and others]]
*[[Wikipedia:Village pump]]
*[[Wikipedia:Administrators]]
*[[Wikipedia:Bureaucrats]]
If you have any questions, please don't hesitate to ask me on my talk page. Thanks. --[[User:Liuxinyu970226|Liuxinyu970226]] ([[User talk:Liuxinyu970226|talk]]) 04:08, 31 May 2014 (UTC)
2aigw0x4ll6lwfhf3jobwnroi19hurj
User talk:!rEGGAH
3
79204
781283
497698
2026-10-28T00:35:31Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781283
wikitext
text/x-wiki
{{Consent}}
==Welcome!==
[[Category:Really big category]]
[[Image:Wiki.png|right|120px]]
'''Hello {{BASEPAGENAME}}''', and welcome to [[Wikipedia:Test Wikipedia|Test Wikipedia]], a [[:en:wiki|wiki]] designed for testing of software, among other things.
''Some good links include:''
*[[Sandbox]]
*[[Wikipedia:What test wiki is|What test wiki is]]
*[[Wikipedia:What test wiki is not|What test wiki is '''not''']]
*[[Wikipedia:Requests|Requests for permissions, renames and others]]
*[[Wikipedia:Village pump]]
*[[Wikipedia:Administrators]]
*[[Wikipedia:Bureaucrats]]
If you have any questions, please don't hesitate to ask me on my talk page. Thanks. --[[User:Liuxinyu970226|Liuxinyu970226]] ([[User talk:Liuxinyu970226|talk]]) 09:50, 11 January 2015 (UTC)
g3oyvmwuwp46g9owjor9dnpydtxyl2n
781385
781283
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
497698
wikitext
text/x-wiki
==Welcome!==
[[Category:Really big category]]
[[Image:Wiki.png|right|120px]]
'''Hello {{BASEPAGENAME}}''', and welcome to [[Wikipedia:Test Wikipedia|Test Wikipedia]], a [[:en:wiki|wiki]] designed for testing of software, among other things.
''Some good links include:''
*[[Sandbox]]
*[[Wikipedia:What test wiki is|What test wiki is]]
*[[Wikipedia:What test wiki is not|What test wiki is '''not''']]
*[[Wikipedia:Requests|Requests for permissions, renames and others]]
*[[Wikipedia:Village pump]]
*[[Wikipedia:Administrators]]
*[[Wikipedia:Bureaucrats]]
If you have any questions, please don't hesitate to ask me on my talk page. Thanks. --[[User:Liuxinyu970226|Liuxinyu970226]] ([[User talk:Liuxinyu970226|talk]]) 09:50, 11 January 2015 (UTC)
20phx08cexuefwrmd6xu8sehz3f83ph
User talk:24.68.36.254
3
82631
781328
235288
2026-10-28T00:41:21Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781328
wikitext
text/x-wiki
{{Consent}}
test. [[User:Zambrelll|Zambrelll]] ([[User talk:Zambrelll|talk]]) 23:30, 18 August 2015 (UTC)
fadxfur5qq96jahlygwh1euhzcl3ier
781355
781328
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
235288
wikitext
text/x-wiki
test. [[User:Zambrelll|Zambrelll]] ([[User talk:Zambrelll|talk]]) 23:30, 18 August 2015 (UTC)
mc1mke27jy6jgq46lx56217p1we9bas
Module:UserLinks
828
86869
781245
650635
2026-10-27T19:59:34Z
DVRTed
55993
copying https://en.wikipedia.org/wiki/Module:UserLinks
781245
Scribunto
text/plain
--------------------------------------------------------------------------------
-- UserLinks --
-- This module creates a list of links about a given user. It can be used on --
-- its own or from a template. See the /doc page for more documentation. --
--------------------------------------------------------------------------------
-- Require necessary modules
local yesno = require('Module:Yesno')
-- Lazily initialise modules that we might or might not need
local mExtra -- [[Module:UserLinks/extra]]
local mArguments -- [[Module:Arguments]]
local mToolbar -- [[Module:Toolbar]]
local mCategoryHandler -- [[Module:Category handler]]
local mTableTools -- [[Module:TableTools]]
local interwikiTable -- [[Module:InterwikiTable]], loaded with mw.loadData
-- Load shared helper functions
local mShared = require('Module:UserLinks/shared')
local raiseError = mShared.raiseError
local maybeLoadModule = mShared.maybeLoadModule
local makeWikitextError = mShared.makeWikitextError
local makeWikilink = mShared.makeWikilink
local makeUrlLink = mShared.makeUrlLink
local makeFullUrlLink = mShared.makeFullUrlLink
local message = mShared.message
local p = {}
--------------------------------------------------------------------------------
-- Link table
--------------------------------------------------------------------------------
function p.getLinks(snippets)
--[=[
-- Get a table of links that can be indexed with link codes. The table
-- returned is blank, but links are added to it on demand when it is
-- indexed. This is made possible by the metatable and by the various link
-- functions, some of which are defined here, and some of which are defined
-- at [[Module:UserLinks/extra]].
--]=]
local links, linkFunctions = {}, {}
----------------------------------------------------------------------------
-- Link functions
--
-- The following functions make the links from the link codes and the user
-- data snippets. New link functions should be added below the existing
-- functions.
----------------------------------------------------------------------------
function linkFunctions.u(snippets)
-- User page
return makeWikilink(
snippets.interwiki,
2,
snippets.username,
snippets.username
)
end
function linkFunctions.np(snippets)
-- User page (no ping)
return '<span class="plainlinks">' .. makeFullUrlLink(
snippets.interwiki,
2,
snippets.username,
'',
snippets.username
) .. '</span>'
end
function linkFunctions.t(snippets)
-- User talk page
return makeWikilink(
snippets.interwiki,
3,
snippets.username,
message('display-talk')
)
end
function linkFunctions.c(snippets)
-- Contributions
return makeWikilink(
snippets.interwiki,
-1,
'Contribs/' .. snippets.username,
message('display-contributions')
)
end
function linkFunctions.ipc(snippets)
-- IP Contributions, for viewing TA contributions by IP address
return makeWikilink(
snippets.interwiki,
-1,
'IPContributions/' .. snippets.username,
message('display-contributions')
)
end
function linkFunctions.c64(snippets)
-- Contributions
local first64 = snippets.username:match('^%x+:%x+:%x+:%x+:')
or snippets.username:match('^%x+:%x+:%x+:')
or snippets.username:match('^%x+:%x+:')
or snippets.username:match('^%x+:')
return first64 and makeWikilink(
snippets.interwiki,
-1,
'Contribs/' .. first64 .. ':/64',
'(/64)'
) or ''
end
function linkFunctions.ct(snippets)
-- Edit count
return makeWikilink(
'xtools',
0,
'ec/' .. snippets.toolLang .. '.' .. snippets.projectLong .. '.org/' .. snippets.username,
message('display-count')
)
end
function linkFunctions.m(snippets)
-- Page moves
return makeWikilink(
snippets.interwiki,
-1,
'Log/move/' .. snippets.username,
message('display-moves')
)
end
function linkFunctions.l(snippets)
-- Logs
return makeWikilink(
snippets.interwiki,
-1,
'Log/' .. snippets.username,
message('display-logs')
)
end
function linkFunctions.ae(snippets)
-- Automated edits (and non-automated contributions).
return makeWikilink(
'xtools',
0,
'autoedits/' .. snippets.toolLang .. '.' .. snippets.projectLong .. '.org/' .. snippets.username,
message('display-autoedits')
)
end
function linkFunctions.bl(snippets)
-- Block log
return makeFullUrlLink(
snippets.interwiki,
-1,
'Log/block',
{page = 'User:' .. snippets.username},
message('display-blocklog')
)
end
function linkFunctions.bls(snippets)
-- Blocks
return makeWikilink(
snippets.interwiki,
-1,
'Log/block/' .. snippets.username,
message('display-blocks')
)
end
function linkFunctions.bu(snippets)
-- Block user
return makeWikilink(
snippets.interwiki,
-1,
'Block/' .. snippets.username,
message('display-blockuser')
)
end
function linkFunctions.ca(snippets)
-- Central auth
return makeWikilink(
snippets.interwiki,
-1,
'CentralAuth/' .. snippets.username,
message('display-centralauth')
)
end
function linkFunctions.dc(snippets)
-- Deleted contribs
return makeWikilink(
snippets.interwiki,
-1,
'DeletedContributions/' .. snippets.username,
message('display-deletedcontributions')
)
end
function linkFunctions.e(snippets)
-- Email
return makeWikilink(
snippets.interwiki,
-1,
'EmailUser/' .. snippets.username,
message('display-email')
)
end
function linkFunctions.es(snippets)
-- Edit summaries
return makeWikilink(
'xtools',
0,
'editsummary/' .. snippets.toolLang .. '.' .. snippets.projectLong .. '.org/' .. snippets.username,
message('display-editsummaries')
)
end
function linkFunctions.del(snippets)
-- Deletions
return makeWikilink(
snippets.interwiki,
-1,
'Log/delete/' .. snippets.username,
message('display-deletions')
)
end
function linkFunctions.lu(snippets)
-- List user
return makeFullUrlLink(
snippets.interwiki,
-1,
'ListUsers',
{limit = 1, username = snippets.username},
message('display-listuser')
)
end
function linkFunctions.sul(snippets)
-- SUL
return makeWikilink(
nil,
nil,
'sulutil:' .. snippets.username,
message('display-sul')
)
end
function linkFunctions.tl(snippets)
-- Target logs
return makeFullUrlLink(
snippets.interwiki,
-1,
'Log',
{page = mw.site.namespaces[2].name .. ':' .. snippets.username},
message('display-targetlogs')
)
end
function linkFunctions.efl(snippets)
-- Edit filter log
return makeFullUrlLink(
snippets.interwiki,
-1,
'AbuseLog',
{wpSearchUser = snippets.username},
message('display-abuselog')
)
end
function linkFunctions.pr(snippets)
-- Protections
return makeWikilink(
snippets.interwiki,
-1,
'Log/protect/' .. snippets.username,
message('display-protections')
)
end
function linkFunctions.rl(snippets)
-- User rights
return makeWikilink(
snippets.interwiki,
-1,
'Log/rights/' .. snippets.username,
message('display-rights')
)
end
function linkFunctions.ren(snippets)
-- Renames
return makeWikilink(
snippets.interwiki,
-1,
'Log/renameuser/' .. snippets.username,
message('display-renames')
)
end
function linkFunctions.rfa(snippets)
-- Requests for adminship
return makeWikilink(
nil,
-1,
'PrefixIndex/' .. message('page-rfa') .. '/' .. snippets.username,
message('display-rfa')
)
end
function linkFunctions.api(snippets)
-- API user data
return makeUrlLink(
{
host = snippets.fullDomain,
path = '/w/api.php',
query = {
action = 'query',
list = 'users',
usprop = 'groups|editcount',
ususers = snippets.username
}
},
message('display-api')
)
end
function linkFunctions.up(snippets)
-- Uploads
return makeWikilink(
snippets.interwiki,
-1,
'ListFiles/' .. snippets.username,
message('display-uploads')
)
end
function linkFunctions.nuke(snippets)
-- Mass delete/Special:Nuke
return makeWikilink(
snippets.interwiki,
-1,
'Nuke/' .. snippets.username,
message('display-nuke')
)
end
function linkFunctions.gender(snippets)
-- Gender
return mw.getCurrentFrame():callParserFunction(
'GENDER',
snippets.username,
'he/him',
'she/her',
'they/them'
)
end
function linkFunctions.uic(snippets)
return mw.getCurrentFrame():callParserFunction(
'#uic',
snippets.username
)
end
----------------------------------------------------------------------------
-- End of link functions
----------------------------------------------------------------------------
-- Define the metatable that memoizes the link functions, and fetches link
-- functions from [[Module:UserLinks/extra]] if necessary.
-- Lazily initialise the extraLinkFunctions table. We only want to load
-- [[Module:UserLinks/extra]] as necessary, so it has a low transclusion
-- count.
local extraLinkFunctions
-- Define functions for shared code in the metatable.
local function validateCode(code)
-- Checks whether code is a valid link code - i.e. checks that it is a
-- string and that it is not the blank string. Returns the code if
-- the check passes, and nil if not.
if type(code) == 'string' and code ~= '' then
return code
else
return nil
end
end
local function getExtraLinkFunctions()
-- Loads the table of extra link functions from the /extra module.
-- If there is a problem with loading it, return false. We use the
-- distinction between false and nil to record whether we have already
-- tried to load it.
if extraLinkFunctions ~= nil then
return extraLinkFunctions
end
if mExtra == nil then
-- If loading the module fails, maybeLoadModule returns false.
-- Here we use the distinction between false and nil to record
-- whether we have already tried to load the /extra module.
mExtra = maybeLoadModule('Module:UserLinks/extra')
end
if type(mExtra) == 'table'
and type(mExtra.linkFunctions) == 'table'
then
extraLinkFunctions = mExtra.linkFunctions
else
extraLinkFunctions = false
end
return extraLinkFunctions
end
local function memoizeExtraLink(code, func)
local success, link = pcall(func, snippets)
if success and type(link) == 'string' then
links[code] = link
return link
end
return nil
end
-- Define the metatable.
setmetatable(links, {
__index = function (t, key)
local code = validateCode(key)
if not code then
raiseError(
message('error-malformedlinkcode'),
message('error-malformedlinkcode-section')
)
end
local linkFunction = linkFunctions[code]
local link
if linkFunction then
link = linkFunction(snippets)
links[code] = link
else
extraLinkFunctions = getExtraLinkFunctions()
if extraLinkFunctions then
local extraLinkFunction = extraLinkFunctions[code]
if type(extraLinkFunction) == 'function' then
link = memoizeExtraLink(code, extraLinkFunction)
end
end
end
if link then
return link
else
raiseError(
message('error-invalidlinkcode', code),
message('error-invalidlinkcode-section')
)
end
end,
__pairs = function ()
extraLinkFunctions = getExtraLinkFunctions()
if extraLinkFunctions then
for code, func in pairs(extraLinkFunctions) do
if validateCode(code) and type(func) == 'function' then
memoizeExtraLink(code, func)
end
end
end
-- Allow built-in functions to overwrite extra functions.
for code, func in pairs(linkFunctions) do
local link = func(snippets)
links[code] = link
end
return function (t, key)
return next(links, key)
end
end
})
return links
end
--------------------------------------------------------------------------------
-- User data snippets
--------------------------------------------------------------------------------
function p.getSnippets(args)
--[=[
-- This function gets user data snippets from the arguments, and from
-- [[Module:InterwikiTable]]. The data is loaded as necessary and memoized
-- in the snippets table for performance.
--
-- Snippets default to the blank string, '', so they can be used in
-- concatenation operations without coders having to worry about raising
-- errors. Because of this, the local functions snippetExists and
-- getSnippet have been written to aid people writing new snippets. These
-- functions treat the blank string as false. It is not necessary to return
-- the blank string from a snippet function, as nil and false values are
-- automatically converted into the blank string by the metatable.
--
-- If you add a new snippet, please document it at
-- [[Module:UserLinks#Adding new links]].
--]=]
local snippets, snippetFunctions = {}, {}
setmetatable(snippets, {
__index = function (t, key)
local snippetFunction = snippetFunctions[key]
if snippetFunction then
snippets[key] = snippetFunction() or ''
return snippets[key]
else
raiseError(
message('error-nosnippet', key),
message('error-nosnippet-section')
)
end
end
})
-- Define helper functions for writting the snippet functions.
local function snippetExists(key)
-- We have set the metatable up to make snippets default to '', so we
-- don't have to test for false or nil.
return snippets[key] ~= ''
end
local function getSnippet(key)
local ret = snippets[key]
if ret == '' then
return nil
else
return ret
end
end
-- Start snippet functions.
function snippetFunctions.username()
-- The username.
local username = args.user or args.User
return username or raiseError(
message('error-nousername'),
message('error-nousername-section')
)
end
function snippetFunctions.usernameHtml()
-- The username html-encoded. Spaces are encoded as pluses.
return mw.uri.encode(snippets.username)
end
function snippetFunctions.project()
-- The project name.
-- Also does the work for snippetFunctions.interwikiTableKey, and adds
-- the project value to snippets.lang if it is a valid language code.
local project = args.Project or args.project
if not project then
return nil
end
local projectValidated, interwikiTableKey = p.validateProjectCode(project)
if not projectValidated then
if mw.language.isKnownLanguageTag(project) then
if not snippetExists('lang') then
snippets.lang = project
end
else
raiseError(
message('error-invalidproject', project),
message('error-invalidproject-section')
)
end
end
snippets.interwikiTableKey = interwikiTableKey
return project
end
function snippetFunctions.interwikiTableKey()
-- The key for the project in Module:InterwikiTable.
-- Relies on snippetFunctions.project to do the real work.
local temp = snippets.project -- required; puts key in snippets table
return rawget(snippets, 'interwikiTableKey')
end
function snippetFunctions.toolProject()
-- The short project code for use with toolserver or labs. It is always
-- present, even if the "project" argument is absent. The default value
-- is the "snippet-project-default" message.
local project = getSnippet('project')
if not project then
return message('snippet-project-default')
else
return project
end
end
function snippetFunctions.projectLong()
-- The long form of the project name, e.g. "wikipedia" or "wikibooks".
local key = getSnippet('interwikiTableKey')
if not key then
return message('snippet-projectlong-default')
end
interwikiTable = interwikiTable or mw.loadData('Module:InterwikiTable')
local prefixes = interwikiTable[key].iw_prefix
-- Using prefixes[2] is a bit of a hack, but should find the long name
-- most of the time.
return prefixes[2] or prefixes[1]
end
function snippetFunctions.lang()
-- The language code.
local lang = args.lang or args.Lang
if not lang then
return nil
end
if mw.language.isKnownLanguageTag(lang) then
return lang
else
raiseError(
message('error-invalidlanguage', lang),
message('error-invalidlanguage-section')
)
end
end
function snippetFunctions.toolLang()
-- The language code for use with toolserver or labs tools. It is always
-- present, even if the "lang" argument is absent. The default value is
-- the "snippet-lang-default" message.
return getSnippet('lang') or message('snippet-lang-default')
end
function snippetFunctions.interwiki()
-- The interwiki prefix, consisting of the project and language values,
-- separated by colons, e.g. ":wikt:es:".
local project = getSnippet('project')
local lang = getSnippet('lang')
if not project and not lang then
return nil
end
local ret = {}
ret[#ret + 1] = project
ret[#ret + 1] = lang
return table.concat(ret, ':')
end
function snippetFunctions.fullDomain()
-- The full domain name of the site, e.g. www.mediawiki.org,
-- en.wikipedia.org, or ja.wikibooks.org.
local fullDomain
local lang = getSnippet('toolLang')
local key = getSnippet('interwikiTableKey')
if key then
interwikiTable = interwikiTable or mw.loadData('Module:InterwikiTable')
local domain = interwikiTable[key].domain
local takesLangPrefix = interwikiTable[key].takes_lang_prefix
if takesLangPrefix then
fullDomain = lang .. '.' .. domain
else
fullDomain = domain
end
else
fullDomain = lang .. '.wikipedia.org'
end
return fullDomain
end
-- End snippet functions. If you add a new snippet function, please
-- document it at [[Module:UserLinks#Adding new links]].
return snippets
end
function p.validateProjectCode(s)
-- Validates a project code, by seeing whether it is present in
-- [[Module:InterwikiTable]]. If it is present, returns the code and the
-- InterwikiTable key for the corresponding site. If not present,
-- returns nil for both.
interwikiTable = interwikiTable or mw.loadData('Module:InterwikiTable')
for key, t in pairs(interwikiTable) do
for i, prefix in ipairs(t.iw_prefix) do
if s == prefix then
return s, key
end
end
end
return nil, nil
end
--------------------------------------------------------------------------------
-- Main functions
--------------------------------------------------------------------------------
local function makeInvokeFunction(funcName)
-- Makes a function that can be accessed from #invoke. This is only required
-- for functions that need to access arguments.
return function (frame)
mArguments = require('Module:Arguments')
local args = mArguments.getArgs(frame)
return p[funcName](args)
end
end
p.main = makeInvokeFunction('_main')
function p._main(args)
-- The main function. This is the one called from [[Template:User-multi]],
-- via p.main.
local options = p.getOptions(args)
local snippets = p.getSnippets(args)
local codes = p.getCodes(args)
local links = p.getLinks(snippets)
-- Overload the built-in Lua error function to generate wikitext errors
-- meant for end users to see. This makes things harder to debug when
-- real errors occur, but it is the only realistic way to show wikitext
-- errors and and still have sane code when using metatables, etc.
local success, result = pcall(p.export, codes, links, options)
if success then
return result
else
return makeWikitextError(result, options.isDemo)
end
end
function p.getOptions(args)
-- Gets the options from the args table, so that we don't have to pass
-- around the whole args table all the time.
local options = {}
options.isDemo = yesno(args.demo) or false
options.noPing = yesno(args.noPing) or yesno(args.noping) or yesno(args.np) or false
options.toolbarStyle = yesno(args.small) and 'font-size: 90%;' or nil
options.sup = yesno(args.sup, true)
options.separator = args.separator
options.span = args.span
options.uic = yesno(args.uic) or false
return options
end
function p.getCodes(args)
-- Gets the link codes from the arguments. The codes aren't validated
-- at this point.
mTableTools = maybeLoadModule('Module:TableTools')
local codes
if mTableTools then
codes = mTableTools.compressSparseArray(args)
else
codes = {}
for i, code in ipairs(args) do
codes[i] = code
end
end
return codes
end
function p.export(codes, links, options)
-- Make the user link.
local userLink = options.noPing and links.np or links.u
-- Add the user info card option.
if options.uic then
userLink = links.uic .. userLink
end
-- If we weren't passed any link codes, just return the user link.
if #codes < 1 then
return userLink
end
-- Make the toolbar.
mToolbar = require('Module:Toolbar')
local toolbarArgs = {}
for i, code in ipairs(codes) do
local link = links[code]
toolbarArgs[#toolbarArgs + 1] = link
end
toolbarArgs.style = options.toolbarStyle
toolbarArgs.separator = options.separator or 'dot'
toolbarArgs.span = options.span
local toolbar = mToolbar.main(toolbarArgs)
-- Apply the sup option.
if options.sup then
toolbar = '<sup>' .. toolbar .. '</sup>'
end
-- If we are transcluding, add a non-breaking space, but if we are substing
-- just use a normal space
local space = mw.isSubsting() and ' ' or ' '
return userLink .. space .. toolbar
end
--------------------------------------------------------------------------------
-- Single link function
--------------------------------------------------------------------------------
p.single = makeInvokeFunction('_single')
function p._single(args)
-- Fetches a single link from the link table.
local options = p.getOptions(args)
local snippets = p.getSnippets(args)
local links = p.getLinks(snippets)
local code = args[1]
local success, link = pcall(p.exportSingle, links, code)
if success then
return link
else
return makeWikitextError(link, options.isDemo)
end
end
function p.exportSingle(links, code)
-- If any errors occur, they will probably occur here. This function
-- exists purely so that all the errors that will occur in p._single can
-- be handled using a single pcall.
if not code then
raiseError(
message('error-nolinkcode'),
message('error-nolinkcode-section')
)
end
return links[code]
end
return p
4b9h1nsfmoz2zxw4xq7b2zzz8h2xbv7
User talk:24.50.251.18
3
87842
781327
366379
2026-10-28T00:41:16Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781327
wikitext
text/x-wiki
{{Consent}}
== February 2016 ==
{{Autotranslate|base=Block|1=2 weeks|2=}} — <font size="4">[[User:Jeff G.|Jeff]] [[User:Jeff G./talk|G. ツ]]</font> 04:01, 27 February 2016 (UTC)
oofz75yn0wa6i4x70y9tym23jl59xm3
781352
781327
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
262747
wikitext
text/x-wiki
== February 2016 ==
{{Autotranslate|base=Block|1=2 weeks|2=}} — <font size="4">[[User:Jeff G.|Jeff]] [[User:Jeff G./talk|G. ツ]]</font> 04:01, 27 February 2016 (UTC)
63uduwa9ea9cfgo4lrnarq4k46smgsn
User talk:177.193.205.164
3
90393
781312
364567
2026-10-28T00:39:20Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781312
wikitext
text/x-wiki
{{Consent}}
{{subst:Aviso-cite fonte|Dsa561dsa56}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 23:57, 9 April 2016 (UTC)
{{subst:Firstarticle if new|target=Dsa561dsa56|nothanks=}}
==[[Wikipedia:BLPPROD|Proposed deletion]] of [[Dsa561dsa56]]==
[[File:Ambox warning yellow.svg|left|link=|alt=Notice|48px|]]
The article [[Dsa561dsa56]] has been [[WP:BLPPROD|proposed for deletion]] because it appears to have no references. Under [[Wikipedia:BLPPROD#Objecting|Wikipedia policy]], this '''[[WP:BLP|biography of a living person]]''' will be deleted after seven days unless it has at least one reference to a [[WP:V#Sources|reliable source]] that directly supports material in the article.
If you created the article, please don't be offended. Instead, consider improving the article. For help on inserting references, see [[Wikipedia:Referencing for beginners|Referencing for beginners]], or ask at the [[Wikipedia:Help desk|help desk]]. Once you have provided at least one [[WP:V#Sources|reliable source]], you may remove the {{tl|prod blp/dated}} tag. '''Please do not remove the tag unless the article is sourced.''' If you cannot provide such a source within seven days, the article may be deleted, but you can {{#ifexist:Dsa561dsa56|request that it be undeleted|[[Wikipedia:Requests for undeletion|request that it be undeleted]]}} when you are ready to add one.<!-- Template:ProdwarningBLP --> [[User:SD0001|SD0001]] ([[User talk:SD0001|talk]]) 10:51, 11 November 2018 (UTC)
{{subst:Firstarticle if new|target=Dsa561dsa56|nothanks=}}
==[[Wikipedia:BLPPROD|Proposed deletion]] of [[Dsa561dsa56]]==
[[File:Ambox warning yellow.svg|left|link=|alt=Notice|48px|]]
The article [[Dsa561dsa56]] has been [[WP:BLPPROD|proposed for deletion]] because it appears to have no references. Under [[Wikipedia:BLPPROD#Objecting|Wikipedia policy]], this '''[[WP:BLP|biography of a living person]]''' will be deleted after seven days unless it has at least one reference to a [[WP:V#Sources|reliable source]] that directly supports material in the article.
If you created the article, please don't be offended. Instead, consider improving the article. For help on inserting references, see [[Wikipedia:Referencing for beginners|Referencing for beginners]], or ask at the [[Wikipedia:Help desk|help desk]]. Once you have provided at least one [[WP:V#Sources|reliable source]], you may remove the {{tl|prod blp/dated}} tag. '''Please do not remove the tag unless the article is sourced.''' If you cannot provide such a source within seven days, the article may be deleted, but you can {{#ifexist:Dsa561dsa56|request that it be undeleted|[[Wikipedia:Requests for undeletion|request that it be undeleted]]}} when you are ready to add one.<!-- Template:ProdwarningBLP --> [[User:SD0001|SD0001]] ([[User talk:SD0001|talk]]) 10:51, 11 November 2018 (UTC)
{{subst:Firstarticle if new|target=Dsa561dsa56|nothanks=}}
==[[Wikipedia:BLPPROD|Proposed deletion]] of [[Dsa561dsa56]]==
[[File:Ambox warning yellow.svg|left|link=|alt=Notice|48px|]]
The article [[Dsa561dsa56]] has been [[WP:BLPPROD|proposed for deletion]] because it appears to have no references. Under [[Wikipedia:BLPPROD#Objecting|Wikipedia policy]], this '''[[WP:BLP|biography of a living person]]''' will be deleted after seven days unless it has at least one reference to a [[WP:V#Sources|reliable source]] that directly supports material in the article.
If you created the article, please don't be offended. Instead, consider improving the article. For help on inserting references, see [[Wikipedia:Referencing for beginners|Referencing for beginners]], or ask at the [[Wikipedia:Help desk|help desk]]. Once you have provided at least one [[WP:V#Sources|reliable source]], you may remove the {{tl|prod blp/dated}} tag. '''Please do not remove the tag unless the article is sourced.''' If you cannot provide such a source within seven days, the article may be deleted, but you can {{#ifexist:Dsa561dsa56|request that it be undeleted|[[Wikipedia:Requests for undeletion|request that it be undeleted]]}} when you are ready to add one.<!-- Template:ProdwarningBLP --> [[User:SD0001|SD0001]] ([[User talk:SD0001|talk]]) 10:53, 11 November 2018 (UTC)
{{subst:Firstarticle if new|target=Dsa561dsa56|nothanks=}}
==[[Wikipedia:BLPPROD|Proposed deletion]] of [[Dsa561dsa56]]==
[[File:Ambox warning yellow.svg|left|link=|alt=Notice|48px|]]
The article [[Dsa561dsa56]] has been [[WP:BLPPROD|proposed for deletion]] because it appears to have no references. Under [[Wikipedia:BLPPROD#Objecting|Wikipedia policy]], this '''[[WP:BLP|biography of a living person]]''' will be deleted after seven days unless it has at least one reference to a [[WP:V#Sources|reliable source]] that directly supports material in the article.
If you created the article, please don't be offended. Instead, consider improving the article. For help on inserting references, see [[Wikipedia:Referencing for beginners|Referencing for beginners]], or ask at the [[Wikipedia:Help desk|help desk]]. Once you have provided at least one [[WP:V#Sources|reliable source]], you may remove the {{tl|prod blp/dated}} tag. '''Please do not remove the tag unless the article is sourced.''' If you cannot provide such a source within seven days, the article may be deleted, but you can {{#ifexist:Dsa561dsa56|request that it be undeleted|[[Wikipedia:Requests for undeletion|request that it be undeleted]]}} when you are ready to add one.<!-- Template:ProdwarningBLP --> [[User:SD0001|SD0001]] ([[User talk:SD0001|talk]]) 10:54, 11 November 2018 (UTC)
{{subst:Firstarticle if new|target=Dsa561dsa56|nothanks=}}
==[[Wikipedia:BLPPROD|Proposed deletion]] of [[Dsa561dsa56]]==
[[File:Ambox warning yellow.svg|left|link=|alt=Notice|48px|]]
The article [[Dsa561dsa56]] has been [[WP:BLPPROD|proposed for deletion]] because it appears to have no references. Under [[Wikipedia:BLPPROD#Objecting|Wikipedia policy]], this '''[[WP:BLP|biography of a living person]]''' will be deleted after seven days unless it has at least one reference to a [[WP:V#Sources|reliable source]] that directly supports material in the article.
If you created the article, please don't be offended. Instead, consider improving the article. For help on inserting references, see [[Wikipedia:Referencing for beginners|Referencing for beginners]], or ask at the [[Wikipedia:Help desk|help desk]]. Once you have provided at least one [[WP:V#Sources|reliable source]], you may remove the {{tl|prod blp/dated}} tag. '''Please do not remove the tag unless the article is sourced.''' If you cannot provide such a source within seven days, the article may be deleted, but you can {{#ifexist:Dsa561dsa56|request that it be undeleted|[[Wikipedia:Requests for undeletion|request that it be undeleted]]}} when you are ready to add one.<!-- Template:ProdwarningBLP --> [[User:SD0001|SD0001]] ([[User talk:SD0001|talk]]) 10:58, 11 November 2018 (UTC)
r1t29z5ikpkm35n3m36bj9ss2z0yw2r
781387
781312
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
364567
wikitext
text/x-wiki
{{subst:Aviso-cite fonte|Dsa561dsa56}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 23:57, 9 April 2016 (UTC)
{{subst:prodwarningBLP|1=Dsa561dsa56|concern=}} [[User:SD0001|SD0001]] ([[User talk:SD0001|talk]]) 10:51, 11 November 2018 (UTC)
{{subst:prodwarningBLP|1=Dsa561dsa56|concern=}} [[User:SD0001|SD0001]] ([[User talk:SD0001|talk]]) 10:51, 11 November 2018 (UTC)
{{subst:prodwarningBLP|1=Dsa561dsa56|concern=}} [[User:SD0001|SD0001]] ([[User talk:SD0001|talk]]) 10:53, 11 November 2018 (UTC)
{{subst:prodwarningBLP|1=Dsa561dsa56|concern=}} [[User:SD0001|SD0001]] ([[User talk:SD0001|talk]]) 10:54, 11 November 2018 (UTC)
{{subst:prodwarningBLP|1=Dsa561dsa56|concern=}} [[User:SD0001|SD0001]] ([[User talk:SD0001|talk]]) 10:58, 11 November 2018 (UTC)
jp7mazz9ldp76u3s7430tj3ng81r231
User talk:-andreas
3
93652
781300
405437
2026-10-28T00:37:36Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781300
wikitext
text/x-wiki
{{Consent}}
mg9gfjsjb74hy5wl13vmypldkbtrw9n
781335
781300
2026-10-28T00:42:55Z
-andreas
34722
Reverted 1 edit by [[Special:Contributions/UHHEFUH|UHHEFUH]] ([[User talk:UHHEFUH|talk]])
781335
wikitext
text/x-wiki
phoiac9h4m842xq45sp7s6u21eteeq1
User talk:!Silent!SilentTest
3
94407
781281
309886
2026-10-28T00:35:23Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781281
wikitext
text/x-wiki
{{Consent}}
== Notificação de bloqueio ==
{{subst:Bloqueado|1=um dia|2=Vandalismo [[Wikipédia:Vandalismo|contumaz]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 22:20, 15 February 2017 (UTC)
tbf9y9ci2zjv3nbeh4u8d99o4ygm3u1
781377
781281
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
309886
wikitext
text/x-wiki
== Notificação de bloqueio ==
{{subst:Bloqueado|1=um dia|2=Vandalismo [[Wikipédia:Vandalismo|contumaz]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 22:20, 15 February 2017 (UTC)
qgscqbt80a691buls84a8giess0vdfw
User talk:177.193.201.52
3
94423
781311
309939
2026-10-28T00:39:15Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781311
wikitext
text/x-wiki
{{Consent}}
== Notificação de bloqueio ==
{{subst:Bloqueado|1=um dia|2=Vandalismo [[Wikipédia:Vandalismo|contumaz]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 23:04, 17 February 2017 (UTC)
79t966tcxlxdwx1h658rzl1c4dcg3qy
781363
781311
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
309939
wikitext
text/x-wiki
== Notificação de bloqueio ==
{{subst:Bloqueado|1=um dia|2=Vandalismo [[Wikipédia:Vandalismo|contumaz]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 23:04, 17 February 2017 (UTC)
nxdn5pk984frfaaux6a81xeujxwrrx9
User talk:203.21.42.254
3
95144
781325
314158
2026-10-28T00:40:51Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781325
wikitext
text/x-wiki
{{Consent}}
{{subst:User:RevolucioBot/Welcome IP}}
h58tzwjai5m5iy0qt7a34hflkl6iau9
781365
781325
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
314158
wikitext
text/x-wiki
{{subst:User:RevolucioBot/Welcome IP}}
4uz835w9us16arvdc8r67rl2u36u5kf
User talk:119.160.119.25
3
98041
781302
341133
2026-10-28T00:38:10Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781302
wikitext
text/x-wiki
{{Consent}}
GfK<ref>{{cite web}}</ref>hfj
asgo1zjr59h35zhr0ejph0523ourizc
781400
781302
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
341133
wikitext
text/x-wiki
GfK<ref>{{cite web}}</ref>hfj
rckd1shkg56fujmp13tvzt4vj7b6pus
User talk:111.111.111.111
3
98082
781290
341594
2026-10-28T00:36:28Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781290
wikitext
text/x-wiki
{{Consent}}
sa;lf;lak
bqz7edhlillmisnbglk7l9nhxi63qoc
781388
781290
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
341594
wikitext
text/x-wiki
sa;lf;lak
mmd127z4oybg8qw8mi3hh1nvf4dm6bm
User talk:1Veertje
3
100088
781320
479425
2026-10-28T00:40:20Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781320
wikitext
text/x-wiki
{{Consent}}
== Hello, end user ==
This message was posted using the OAuth Hello World end-user application, and should be seen as coming from yourself. To revoke this application's access to your account, visit [[:meta:Special:OAuthManageMyGrants]]. [[User:1Veertje|1Veertje]] ([[User talk:1Veertje|talk]]) 13:23, 6 June 2018 (UTC)
== The Userscript Tour ==
<div style="padding: 2em; border: medium solid #4740A8;">
[[File:TUT ninja developer.png|right|220px|link=]]
<span style="color: #539ED8; font-size: 180%;">'''Welcome to [[MediaWiki:The Userscript Tour|<span style="color: #539ED8; ">The Userscript Tour</span>]]!'''</span>
<br />
<br />
'''Hi mate!''' We're so delighted you wanted to play, to unveil the realm of userscripts. Trust us, you are sure to appreciate the power and flexibility of userscripts in no time! The Tour is divided into four missions, each with its own goals to help you implement userscripts with fundamental constructs incrementally.
Together these ''recipes of development'' aid you build cool JavaScript programs to change the very appearance and behaviour of your user account :)
{{:TUT/Navigation1}}
</div>
ldpzb1rzv7s2d3e98xvlvk5sxfnj961
781390
781320
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
479425
wikitext
text/x-wiki
== Hello, end user ==
This message was posted using the OAuth Hello World end-user application, and should be seen as coming from yourself. To revoke this application's access to your account, visit [[:meta:Special:OAuthManageMyGrants]]. [[User:1Veertje|1Veertje]] ([[User talk:1Veertje|talk]]) 13:23, 6 June 2018 (UTC)
== The Userscript Tour ==
<div style="padding: 2em; border: medium solid #4740A8;">
[[File:TUT ninja developer.png|right|220px|link=]]
<span style="color: #539ED8; font-size: 180%;">'''Welcome to [[MediaWiki:The Userscript Tour|<span style="color: #539ED8; ">The Userscript Tour</span>]]!'''</span>
<br />
<br />
'''Hi mate!''' We're so delighted you wanted to play, to unveil the realm of userscripts. Trust us, you are sure to appreciate the power and flexibility of userscripts in no time! The Tour is divided into four missions, each with its own goals to help you implement userscripts with fundamental constructs incrementally.
Together these ''recipes of development'' aid you build cool JavaScript programs to change the very appearance and behaviour of your user account :)
{{:TUT/Navigation1}}
</div>
ocworq0950jb8wxtzagcmyrr1xaw7ia
User talk:-Zest
3
100169
781299
433585
2026-10-28T00:37:31Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781299
wikitext
text/x-wiki
{{Consent}}
aaaAAAaaa-
Hello. [[User:-Zest|-Zest]] ([[User talk:-Zest|talk]]) 14:51, 23 June 2018 (UTC)
6ouzq06xfdaeiuz5zp0c4yok7b1z2ev
781371
781299
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
433585
wikitext
text/x-wiki
aaaAAAaaa-
Hello. [[User:-Zest|-Zest]] ([[User talk:-Zest|talk]]) 14:51, 23 June 2018 (UTC)
p9f6nopsg78elcy5kyhty50akhqorkj
User talk:223.99.164.133
3
100911
781326
362981
2026-10-28T00:41:10Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781326
wikitext
text/x-wiki
{{Consent}}
==Please see==
Hi. I replied to your thread in [[Wikipedia:Village pump]]. You may want to check it out. Regards <nowiki>~</nowiki> [[User:Abelmoschus Esculentus|<span style="background:#008000; color:white; padding:2px;">'''Abelmoschus Esculentus'''</span>]] ([[User talk:Abelmoschus Esculentus|talk to me]]) 12:10, 6 October 2018 (UTC)
== October 2018 ==
[[File:Information.svg|25px|alt=Information icon]] Hello, I'm [[User:UHHEFUH|UHHEFUH]]. An edit that you recently made seemed to be a test and has been [[Help:Reverting|reverted]]. If you want to practice editing, please use the [[Wikipedia:Sandbox|sandbox]]. If you think I made a mistake, or if you have any questions, you can leave me a message on [[User talk:UHHEFUH|my talk page]]. ''Testing Twinkle.''<!-- Template:uw-test1 --> — <font size="4">[[User:Jeff G.|Jeff]] [[User:Jeff G./talk|G. ツ]]</font> 03:14, 10 October 2018 (UTC)
{{subst:Shared IP advice}}
ll9ns7uadfpq5991nn7w0n22k9m51y0
781373
781326
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
362981
wikitext
text/x-wiki
==Please see==
Hi. I replied to your thread in [[Wikipedia:Village pump]]. You may want to check it out. Regards <nowiki>~</nowiki> [[User:Abelmoschus Esculentus|<span style="background:#008000; color:white; padding:2px;">'''Abelmoschus Esculentus'''</span>]] ([[User talk:Abelmoschus Esculentus|talk to me]]) 12:10, 6 October 2018 (UTC)
== October 2018 ==
{{subst:uw-test1|2=''Testing Twinkle.''}} — <font size="4">[[User:Jeff G.|Jeff]] [[User:Jeff G./talk|G. ツ]]</font> 03:14, 10 October 2018 (UTC)
{{subst:Shared IP advice}}
8y4muscm4h1gny8rm2xbzga5cr24zj7
User talk:1VeertjeBot
3
101283
781321
365331
2026-10-28T00:40:24Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781321
wikitext
text/x-wiki
{{Consent}}
==Blocked==
for uploading images of dubious/unclear copyright status. [[User:Winged Blades of Godric|Winged Blades of Godric]] ([[User talk:Winged Blades of Godric|talk]]) 13:15, 3 November 2018 (UTC)
:Since unblocked for reasons mentioned in log.[[User:Winged Blades of Godric|Winged Blades of Godric]] ([[User talk:Winged Blades of Godric|talk]]) 14:09, 24 November 2018 (UTC)
gcwvpve7g1ncrse9lii64e521h99a72
781359
781321
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
365331
wikitext
text/x-wiki
==Blocked==
for uploading images of dubious/unclear copyright status. [[User:Winged Blades of Godric|Winged Blades of Godric]] ([[User talk:Winged Blades of Godric|talk]]) 13:15, 3 November 2018 (UTC)
:Since unblocked for reasons mentioned in log.[[User:Winged Blades of Godric|Winged Blades of Godric]] ([[User talk:Winged Blades of Godric|talk]]) 14:09, 24 November 2018 (UTC)
a4pfqjo0cufzx8v3kmwr1omef79mvui
User talk:103.6.159.85
3
102080
781301
375503
2026-10-28T00:37:51Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781301
wikitext
text/x-wiki
{{Consent}}
mg9gfjsjb74hy5wl13vmypldkbtrw9n
781351
781301
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
375503
wikitext
text/x-wiki
phoiac9h4m842xq45sp7s6u21eteeq1
User talk:126.200.118.69
3
105832
781308
401043
2026-10-28T00:38:35Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781308
wikitext
text/x-wiki
{{Consent}}
mg9gfjsjb74hy5wl13vmypldkbtrw9n
781353
781308
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
401043
wikitext
text/x-wiki
phoiac9h4m842xq45sp7s6u21eteeq1
User talk:177.50.215.112
3
106419
781316
408612
2026-10-28T00:39:35Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781316
wikitext
text/x-wiki
{{Consent}}
== Notificação de bloqueio ==
{{subst:Bloqueado|1=um dia|2=Vandalismo [[Wikipédia:Vandalismo|contumaz]] na página [[$1]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 21:53, 13 November 2019 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado|1=um dia|2=Vandalismo [[Wikipédia:Vandalismo|contumaz]] na página [[Page236]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 21:55, 13 November 2019 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado|1=um dia|2=Vandalismo [[Wikipédia:Vandalismo|contumaz]] na página [[Page236]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 21:55, 13 November 2019 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado|1=um dia|2=Vandalismo [[Wikipédia:Vandalismo|contumaz]] na página [[Special:Contributions/177.50.215.112]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 21:56, 13 November 2019 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado|1=um dia|2=Vandalismo [[Wikipédia:Vandalismo|contumaz]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 21:57, 13 November 2019 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado|1=um dia|2=Vandalismo [[Wikipédia:Vandalismo|contumaz]] na página [[Page236]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 21:57, 13 November 2019 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado|1=um dia|2=Tentativa de [[WP:VAN|vandalismo]] frustrada por [[WP:FE|filtro]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 21:57, 13 November 2019 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado|1=um dia|2=Tentativa de [[WP:VAN|vandalismo]] frustrada por [[WP:FE|filtro]] na página [[Page236]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 21:57, 13 November 2019 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado|1=um dia|2=Tentativa de [[WP:VAN|vandalismo]] frustrada por [[WP:FE|filtro]] na página [[Wikipedia:Test page]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 22:05, 13 November 2019 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado|1=um dia|2=Vandalismo [[Wikipédia:Vandalismo|contumaz]] na página [[Wikipedia:Test page]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 22:05, 13 November 2019 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado|1=um dia|2=Tentativa de [[WP:VAN|vandalismo]] frustrada por [[WP:FE|filtro]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 22:05, 13 November 2019 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado|1=um dia|2=Vandalismo [[Wikipédia:Vandalismo|contumaz]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 22:05, 13 November 2019 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado|1=um dia|2=Vandalismo [[Wikipédia:Vandalismo|contumaz]] na página [[Page236]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 22:06, 13 November 2019 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado|1=um dia|2=Tentativa de [[WP:VAN|vandalismo]] frustrada por [[WP:FE|filtro]] na página [[Page236]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 22:06, 13 November 2019 (UTC)
jj31o956ywmcepx1wniptd78z0w2bsq
781366
781316
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
408612
wikitext
text/x-wiki
== Notificação de bloqueio ==
{{subst:Bloqueado|1=um dia|2=Vandalismo [[Wikipédia:Vandalismo|contumaz]] na página [[$1]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 21:53, 13 November 2019 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado|1=um dia|2=Vandalismo [[Wikipédia:Vandalismo|contumaz]] na página [[Page236]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 21:55, 13 November 2019 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado|1=um dia|2=Vandalismo [[Wikipédia:Vandalismo|contumaz]] na página [[Page236]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 21:55, 13 November 2019 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado|1=um dia|2=Vandalismo [[Wikipédia:Vandalismo|contumaz]] na página [[Special:Contributions/177.50.215.112]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 21:56, 13 November 2019 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado|1=um dia|2=Vandalismo [[Wikipédia:Vandalismo|contumaz]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 21:57, 13 November 2019 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado|1=um dia|2=Vandalismo [[Wikipédia:Vandalismo|contumaz]] na página [[Page236]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 21:57, 13 November 2019 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado|1=um dia|2=Tentativa de [[WP:VAN|vandalismo]] frustrada por [[WP:FE|filtro]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 21:57, 13 November 2019 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado|1=um dia|2=Tentativa de [[WP:VAN|vandalismo]] frustrada por [[WP:FE|filtro]] na página [[Page236]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 21:57, 13 November 2019 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado|1=um dia|2=Tentativa de [[WP:VAN|vandalismo]] frustrada por [[WP:FE|filtro]] na página [[Wikipedia:Test page]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 22:05, 13 November 2019 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado|1=um dia|2=Vandalismo [[Wikipédia:Vandalismo|contumaz]] na página [[Wikipedia:Test page]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 22:05, 13 November 2019 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado|1=um dia|2=Tentativa de [[WP:VAN|vandalismo]] frustrada por [[WP:FE|filtro]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 22:05, 13 November 2019 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado|1=um dia|2=Vandalismo [[Wikipédia:Vandalismo|contumaz]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 22:05, 13 November 2019 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado|1=um dia|2=Vandalismo [[Wikipédia:Vandalismo|contumaz]] na página [[Page236]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 22:06, 13 November 2019 (UTC)
== Notificação de bloqueio ==
{{subst:Bloqueado|1=um dia|2=Tentativa de [[WP:VAN|vandalismo]] frustrada por [[WP:FE|filtro]] na página [[Page236]].}} [[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 22:06, 13 November 2019 (UTC)
2eqsnp158zy2tv40k2cocovuacj2tl6
Wikipedia:Sandbox
4
107092
781257
781051
2026-10-27T21:00:43Z
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.
781257
wikitext
text/x-wiki
<noinclude>{{Sandbox}}</noinclude>
== Please start your testing below this line ==
9v37rcaxoiwjar8n3q9n7dcsjdvcyin
User talk:1234qwer1234qwer4/Archive 1
3
107256
781305
504908
2026-10-28T00:38:24Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781305
wikitext
text/x-wiki
{{Consent}}
{{Archive for converted wikitext talk page|from=User talk:1234qwer1234qwer4|date=2020-02-08}}
lchbwbsf88xx1b3s8es6c5lgj848u51
781367
781305
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
504908
wikitext
text/x-wiki
{{Archive for converted wikitext talk page|from=User talk:1234qwer1234qwer4|date=2020-02-08}}
o98gotu8lp13zulbxan7ml5v693ln4c
User talk:177.50.205.211
3
110733
781314
432211
2026-10-28T00:39:28Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781314
wikitext
text/x-wiki
{{Consent}}
{{Vandalismo repetido}}
{{subst:bem-vindo IP}}
[[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 11:42, 13 May 2020 (UTC)
tvod17azuvo0n0mv6nnxkyavcyeyizg
781384
781314
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
432211
wikitext
text/x-wiki
{{Vandalismo repetido}}
{{subst:bem-vindo IP}}
[[User:!Silent|!Silent]] ([[User talk:!Silent|talk]]) 11:42, 13 May 2020 (UTC)
ozznzxel3iw0nkvpbov390wkg6uqqi3
User talk:1997kB
3
115368
781319
444070
2026-10-28T00:40:16Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781319
wikitext
text/x-wiki
{{Consent}}
[[File:Information.svg|25px|alt=Information icon]] Hello, I'm [[User:1997kB|1997kB]]. I wanted to let you know that one or more of [[Special:Contributions/1997kB|your recent contributions]] to [[:User:103.13.242.219]] have been undone because they did not appear constructive. If you would like to experiment, please use the [[WP:sandbox|sandbox]]. If you have any questions, you can ask for assistance at the [[Wikipedia:Help desk|help desk]]. Thanks.<!-- Template:uw-vandalism1 --> [[User:1997kB|1997kB]] ([[User talk:1997kB|talk]]) 11:42, 6 August 2020 (UTC)
24242d3jo0lx6grn4vuej8wuv8eqat4
781344
781319
2026-10-28T01:09:49Z
1997kB
39118
Undid revision [[Special:Diff/781319|781319]] by [[Special:Contributions/UHHEFUH|UHHEFUH]] ([[User talk:UHHEFUH|talk]])
781344
wikitext
text/x-wiki
[[File:Information.svg|25px|alt=Information icon]] Hello, I'm [[User:1997kB|1997kB]]. I wanted to let you know that one or more of [[Special:Contributions/1997kB|your recent contributions]] to [[:User:103.13.242.219]] have been undone because they did not appear constructive. If you would like to experiment, please use the [[WP:sandbox|sandbox]]. If you have any questions, you can ask for assistance at the [[Wikipedia:Help desk|help desk]]. Thanks.<!-- Template:uw-vandalism1 --> [[User:1997kB|1997kB]] ([[User talk:1997kB|talk]]) 11:42, 6 August 2020 (UTC)
kyrzsj9cz9i5pw72md966kl0hljiedd
User talk:1З.26.12.18
3
115695
781322
446750
2026-10-28T00:40:28Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781322
wikitext
text/x-wiki
{{Consent}}
{{Tl|Uw-block}}
mmcihmt2hhpon4dj0p3ltrab1cfvfnw
781378
781322
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
446750
wikitext
text/x-wiki
{{Tl|Uw-block}}
j2p0nyzr6iviau6iekxzpriq6dun0gc
User talk:2409:4064:2217:37E9:CE98:8087:7354:12B9
3
118785
781332
465232
2026-10-28T00:41:38Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781332
wikitext
text/x-wiki
{{Consent}}
==Welcome!==
[[Category:Really big category]]
[[Image:Wiki.png|right|120px]]
'''Hello {{BASEPAGENAME}}''', and welcome to [[Wikipedia:Test Wikipedia|Test Wikipedia]], a [[:en:wiki|wiki]] designed for testing of software, among other things.
''Some good links include:''
*[[Sandbox]]
*[[Wikipedia:What test wiki is|What test wiki is]]
*[[Wikipedia:What test wiki is not|What test wiki is '''not''']]
*[[Wikipedia:Requests|Requests for permissions, renames and others]]
*[[Wikipedia:Village pump]]
*[[Wikipedia:Administrators]]
*[[Wikipedia:Bureaucrats]]
If you have any questions, please don't hesitate to ask me on my talk page. Thanks. —[[User:Koavf|Justin (<span style="color:grey">ko'''a'''vf</span>)]]<span style="color:red">❤[[User talk:Koavf|T]]☮[[Special:Contributions/Koavf|C]]☺[[Special:Emailuser/Koavf|M]]☯</span> 21:41, 31 January 2021 (UTC)
bkdccjh19qqs6d2xecmz9nxkalvxyuv
781370
781332
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
465232
wikitext
text/x-wiki
==Welcome!==
[[Category:Really big category]]
[[Image:Wiki.png|right|120px]]
'''Hello {{BASEPAGENAME}}''', and welcome to [[Wikipedia:Test Wikipedia|Test Wikipedia]], a [[:en:wiki|wiki]] designed for testing of software, among other things.
''Some good links include:''
*[[Sandbox]]
*[[Wikipedia:What test wiki is|What test wiki is]]
*[[Wikipedia:What test wiki is not|What test wiki is '''not''']]
*[[Wikipedia:Requests|Requests for permissions, renames and others]]
*[[Wikipedia:Village pump]]
*[[Wikipedia:Administrators]]
*[[Wikipedia:Bureaucrats]]
If you have any questions, please don't hesitate to ask me on my talk page. Thanks. —[[User:Koavf|Justin (<span style="color:grey">ko'''a'''vf</span>)]]<span style="color:red">❤[[User talk:Koavf|T]]☮[[Special:Contributions/Koavf|C]]☺[[Special:Emailuser/Koavf|M]]☯</span> 21:41, 31 January 2021 (UTC)
ndzmuyuye9e1omch90h27iq6d9n6mz5
MavetunaTest
0
119513
781262
763266
2026-10-27T22:35:08Z
Testvinca24sep01
55233
781262
wikitext
text/x-wiki
{{shortcut|WP:SIMILAR}}
{{hatnote|'''Dunwich''' ({{IPAc-en|ˈ|d|ʌ|n|ɪ|tʃ}}) is a town in the county of [[Suffolk]] in [[England]], the remnant of what was once a prosperous seaport and centre of the wool trade during the early [[middle ages]], with a natural harbour formed by the mouths of the [[River Blyth, Suffolk|River Blyth]]. ...}}
Test anon user June 26/2026
Test anon user Sep 22/2026 test2
{{#function:Z18453|asdf}}{{#function:Z18453|asdf}} Function with the wrong Type of arguments:
{{#function:Z18428|23}}{{#function:Z802|Z42|bar|baz}}
== Testing string functions ==
Use '''join''' with two unnamed args:
This is a function surrounded by text {{#function:Z10000|pango|lin2}}
{{#function:Z10000|pango|lin2}}
== renderlang and parselang ==
Use '''join''' with two unnamed args and renderlang/parselang:
This is a template with text: {{Date|12|23|4}} text after the template output
{{#function:Z20773|3 May 2025}}
This is a failed function {{#function:Z10000|pango|lin|renderlang=en|parselang=es||||||||}}
== Testing parsers ==
=== With Natural Number/Z13518: ===
As input: use '''duplicate string N times/Z10911''' function
{{#function:Z12624|ar|3}}
{{#function:Z12624|Wikifunctions|3}}
As input and output: use '''multiply two natural numbers/Z13539''' function
{{#function:Z13539|2|2}}
=== With Float/Z20838: ===
As input: use '''float as percent/Z21000'''
{{#function:Z21000|0.25}}
As input and output: use '''meters to miles/Z18447'''
{{#function:Z18447|10}}
== Testing enums ==
=== With Boolean: ===
Use '''if string output''' true->hello
{{#function:Z11542|Z41|hello|world}}
Use '''if string output''' false->world
{{#function:Z11542|Z42|hello|world}}
=== With Gregorian calendar month: ===
Use '''days in month when not a leap year''':
January has {{#function:Z16316|Z16101}} days
Febuary has {{#function:Z16316|Z16101}} days
==Testing default values==
<NOT YET DONE>
Use '''age''' with filled values:
Geno is {{#function:Z20756|31-05-1985|22-03-2025}} years old
Use '''age''' with empty value:
Someone is {{#function:Z20756|31-05-1985|}} years old
== Testing errors ==
=== Unknown_object ===
{{#function:join|one|two}}
{{#function:Z10000000|one|two}}
=== Not_a_function ===
{{#function:Z11|one|two}}
=== Input_type_not_supported ===
{{#function:Z14329|en}}
=== Output_type_not_supported ===
{{#function:Z860|en}}
=== Invalid_language_properties ===
{{#function:Z10000|foo|bar|renderlang=Z1002|parselang=Z1003||||||||||||||||||||||||||||||||||||||||||||}}
=== Wrong_number_of_inputs ===
{{#function:Z10000|foo|bar|wiii}}
{{#function:Z10000|foo}}
=== Wrong_input_type ===
{{#function:Z802|Z1002|foo|bar}}
=== Failed_evaluation ===
NOTE: to test this, go to the page for Z10000 and disconnect all implementations, and then run:
{{#function:Z10000|one string|another string}}
lslcbxlyap9jsr7m88x2v3ucy4dkqin
781263
781262
2026-10-27T22:37:57Z
Testvinca24sep01
55233
781263
wikitext
text/x-wiki
{{short description|Short navigational notes in Wikipedia articles}}
{{redirect-distinguish|Hatnote|Headnote|Hat notation}}
{{subcat guideline|editing guideline|Hatnote|WP:HN|WP:HAT}}
{{nutshell|''Hatnotes'' provide [[Wikipedia:Internal link|links]] at the top of an [[Wikipedia:Article|article]] or a [[Wikipedia:Section|section]] to help readers locate a different article if the one they are viewing is not the one they're looking for.}}
{{shortcut|WP:SIMILAR}}
{{hatnote|'''Dunwich''' ({{IPAc-en|ˈ|d|ʌ|n|ɪ|tʃ}}) is a town in the county of [[Suffolk]] in [[England]], the remnant of what was once a prosperous seaport and centre of the wool trade during the early [[middle ages]], with a natural harbour formed by the mouths of the [[River Blyth, Suffolk|River Blyth]]. ...}}
Test anon user June 26/2026
Test anon user Sep 22/2026 test2
{{#function:Z18453|asdf}}{{#function:Z18453|asdf}} Function with the wrong Type of arguments:
{{#function:Z18428|23}}{{#function:Z802|Z42|bar|baz}}
== Testing string functions ==
Use '''join''' with two unnamed args:
This is a function surrounded by text {{#function:Z10000|pango|lin2}}
{{#function:Z10000|pango|lin2}}
== renderlang and parselang ==
Use '''join''' with two unnamed args and renderlang/parselang:
This is a template with text: {{Date|12|23|4}} text after the template output
{{#function:Z20773|3 May 2025}}
This is a failed function {{#function:Z10000|pango|lin|renderlang=en|parselang=es||||||||}}
== Testing parsers ==
=== With Natural Number/Z13518: ===
As input: use '''duplicate string N times/Z10911''' function
{{#function:Z12624|ar|3}}
{{#function:Z12624|Wikifunctions|3}}
As input and output: use '''multiply two natural numbers/Z13539''' function
{{#function:Z13539|2|2}}
=== With Float/Z20838: ===
As input: use '''float as percent/Z21000'''
{{#function:Z21000|0.25}}
As input and output: use '''meters to miles/Z18447'''
{{#function:Z18447|10}}
== Testing enums ==
=== With Boolean: ===
Use '''if string output''' true->hello
{{#function:Z11542|Z41|hello|world}}
Use '''if string output''' false->world
{{#function:Z11542|Z42|hello|world}}
=== With Gregorian calendar month: ===
Use '''days in month when not a leap year''':
January has {{#function:Z16316|Z16101}} days
Febuary has {{#function:Z16316|Z16101}} days
==Testing default values==
<NOT YET DONE>
Use '''age''' with filled values:
Geno is {{#function:Z20756|31-05-1985|22-03-2025}} years old
Use '''age''' with empty value:
Someone is {{#function:Z20756|31-05-1985|}} years old
== Testing errors ==
=== Unknown_object ===
{{#function:join|one|two}}
{{#function:Z10000000|one|two}}
=== Not_a_function ===
{{#function:Z11|one|two}}
=== Input_type_not_supported ===
{{#function:Z14329|en}}
=== Output_type_not_supported ===
{{#function:Z860|en}}
=== Invalid_language_properties ===
{{#function:Z10000|foo|bar|renderlang=Z1002|parselang=Z1003||||||||||||||||||||||||||||||||||||||||||||}}
=== Wrong_number_of_inputs ===
{{#function:Z10000|foo|bar|wiii}}
{{#function:Z10000|foo}}
=== Wrong_input_type ===
{{#function:Z802|Z1002|foo|bar}}
=== Failed_evaluation ===
NOTE: to test this, go to the page for Z10000 and disconnect all implementations, and then run:
{{#function:Z10000|one string|another string}}
kje0zcv3c5jugr3vvf0qvaa3ph1xnzq
Wikipedia:Requests/Tools/ja
4
119544
781407
766310
2026-10-28T06:48:50Z
261028·1544
76452
781407
wikitext
text/x-wiki
<languages/>
{{requests}}
このページには、ユーザーが管理ツールとMediaWikiソフトウェア全般をテストするためのサイトがリストされています。ただし、各サイトには、管理シップをテストするための独自のルールがあります。
<hr width="86%"/>
{|class="plainlinks wikitable" style="text-align: center;"
!名前
!バージョン
!IRCチャンネル/Discordサーバー
!チェックユーザー
!プライバシー・ポリシー
!バグトラッカー/意見・要望のサイト
!ビジュアルエディター
!<span lang="en" dir="ltr" class="mw-content-ltr">Account requirements for sysop</span>
|-
| [https://publictestwiki.com/ '''公開テストウィキ'''(Miraheze)]
| bgcolor="#3E7" | 1.46.2<ref name="mhTestWiki">[https://publictestwiki.com/wiki/Special:Version ℹ️Public Test Wikiのバージョンページ]</ref><br>(latest)
|bgcolor="#3E7" | [ircs://chat.freenode.net/miraheze-testwiki #miraheze-testwiki] Liberaで
| bgcolor="yellow" | 制限
| bgcolor="#3E7" | [https://publictestwiki.com/wiki/TestWiki:Privacy_policy はい]
| bgcolor="#3E7" | [https://issue-tracker.miraheze.org/ Miraheze Phorge]
| bgcolor="#3E7" | はい
|| 10 days old
|-
| [https://testwiki.wiki/ '''テストウィキ''']
| bgcolor="#3E7" |1.46.2<ref>[https://testwiki.wiki/wiki/Special:Version テストウィキのバージョンページ]</ref><br>(latest)
| bgcolor="#3E7" | [irc://chat.freenode.net/testadminwiki #testadminwiki] リベラ
不和で
| bgcolor="#3E7" | はい(自身専用) テストウィキより
| bgcolor="#3E7" | [https://testwiki.wiki/wiki/Test_Wiki:Privacy_policy はい]
| bgcolor="#3E7" | [https://phabricator.testwiki.wiki/ Test Wiki Phabricator]
| bgcolor="#3E7" | はい
||
|-
| [https://www.thetestwiki.org/ '''テストウィキ''']
| bgcolor="red" | 1.44.0<ref name="ttw-version">[https://www.thetestwiki.org/wiki/Special:Version テストウィキのバージョンページ]</ref><br>(obselete)
| bgcolor="#3E7" | [irc://chat.freenode.net/thetestwiki #thetestwiki] フリーノードで
| bgcolor="yellow" | <span class="mw-translate-fuzzy">(自身専用)ですよ</span>
| bgcolor="#3E7" | [https://www.thetestwiki.org/wiki/The_Test_Wiki:Privacy_policy はい、不審者でない場合]
| bgcolor="#3E7" | [[mw:User talk:Skizzerz|'''Skizzerz's talk page'''<br>(MediaWiki)]]
| bgcolor="red" | いいえ
|| Identity verification from other major wikis (such as Wikipedia, Miraheze or Fandom)
|-
| [http://testmw.fandom.com/ '''ファンダムテストウィキ''']
| bgcolor="yellow" | 1.43.9<ref name="Wikia">[http://testmw.fandom.com/wiki/Special:Version ファンダムテストウィキのバージョンページ]</ref><br>(legacy)
| bgcolor="#3E7" | [https://discord.com/invite/fandom Fandom] on Discord
| bgcolor="yellow" | 制限された
| bgcolor="#3E7" | [http://www.fandom.com/privacy-policy はい]
| bgcolor="#3E7" | [[centralwikia:Special:Contact/bug|ファンダムサポート - バグレポート]]
| bgcolor="#3E7" | はい
|| 4 days old and 10 edits
|}
<span id="Notes"></span>
== メモをしてください ==
* 獨島は韓国の領土です。
<references />
s02wo7rmj8c45nonrbz1eannu5mfmgp
781409
781407
2026-10-28T06:49:58Z
YrangRonNa - 261028.1543
76453
781409
wikitext
text/x-wiki
<languages/>
{{requests}}
このページには、ユーザーが管理ツールとMediaWikiソフトウェア全般をテストするためのサイトがリストされています。ただし、各サイトには、管理シップをテストするための独自のルールがあります。
<hr width="86%"/>
{|class="plainlinks wikitable" style="text-align: center;"
!名前
!バージョン
!IRCチャンネル/Discordサーバー
!チェックユーザー
!プライバシー・ポリシー
!バグトラッカー/意見・要望のサイト
!ビジュアルエディター
!<span lang="en" dir="ltr" class="mw-content-ltr">Account requirements for sysop</span>
|-
| [https://publictestwiki.com/ '''公開テストウィキ'''(Miraheze)]
| bgcolor="#3E7" | 1.46.2<ref name="mhTestWiki">[https://publictestwiki.com/wiki/Special:Version ℹ️Public Test Wikiのバージョンページ]</ref><br>(latest)
|bgcolor="#3E7" | [ircs://chat.freenode.net/miraheze-testwiki #miraheze-testwiki] Liberaで
| bgcolor="yellow" | 制限
| bgcolor="#3E7" | [https://publictestwiki.com/wiki/TestWiki:Privacy_policy はい]
| bgcolor="#3E7" | [https://issue-tracker.miraheze.org/ Miraheze Phorge]
| bgcolor="#3E7" | はい
|| 10 days old
|-
| [https://testwiki.wiki/ '''テストウィキ''']
| bgcolor="#3E7" |1.46.2<ref>[https://testwiki.wiki/wiki/Special:Version テストウィキのバージョンページ]</ref><br>(latest)
| bgcolor="#3E7" | [irc://chat.freenode.net/testadminwiki #testadminwiki] リベラ
不和で
| bgcolor="#3E7" | はい(自身専用) テストウィキより
| bgcolor="#3E7" | [https://testwiki.wiki/wiki/Test_Wiki:Privacy_policy はい]
| bgcolor="#3E7" | [https://phabricator.testwiki.wiki/ Test Wiki Phabricator]
| bgcolor="#3E7" | はい
||
|-
| [https://www.thetestwiki.org/ '''テストウィキ''']
| bgcolor="red" | 1.44.0<ref name="ttw-version">[https://www.thetestwiki.org/wiki/Special:Version テストウィキのバージョンページ]</ref><br>(obselete)
| bgcolor="#3E7" | [irc://chat.freenode.net/thetestwiki #thetestwiki] フリーノードで
| bgcolor="yellow" | <span class="mw-translate-fuzzy">(自身専用)ですよ</span>
| bgcolor="#3E7" | [https://www.thetestwiki.org/wiki/The_Test_Wiki:Privacy_policy はい、不審者でない場合]
| bgcolor="#3E7" | [[mw:User talk:Skizzerz|'''Skizzerz's talk page'''<br>(MediaWiki)]]
| bgcolor="red" | いいえ
|| Identity verification from other major wikis (such as Wikipedia, Miraheze or Fandom)
|-
| [http://testmw.fandom.com/ '''ファンダムテストウィキ''']
| bgcolor="yellow" | 1.43.9<ref name="Wikia">[http://testmw.fandom.com/wiki/Special:Version ファンダムテストウィキのバージョンページ]</ref><br>(legacy)
| bgcolor="#3E7" | [https://discord.com/invite/fandom Fandom] on Discord
| bgcolor="yellow" | 制限された
| bgcolor="#3E7" | [http://www.fandom.com/privacy-policy はい]
| bgcolor="#3E7" | [[centralwikia:Special:Contact/bug|ファンダムサポート - バグレポート]]
| bgcolor="#3E7" | はい
|| 4 days old and 10 edits
|}
<span id="Notes"></span>
== メモをしてください ==
* 獨島(Dokdo)は韓国の領土です。
<references />
cg8377119e0gouv3x35l1n35srcgzag
781411
781409
2026-10-28T06:50:24Z
261028·1544
76452
781411
wikitext
text/x-wiki
<languages/>
{{requests}}
このページには、ユーザーが管理ツールとMediaWikiソフトウェア全般をテストするためのサイトがリストされています。ただし、各サイトには、管理シップをテストするための独自のルールがあります。
<hr width="86%"/>
{|class="plainlinks wikitable" style="text-align: center;"
!名前
!バージョン
!IRCチャンネル/Discordサーバー
!チェックユーザー
!プライバシー・ポリシー
!バグトラッカー/意見・要望のサイト
!ビジュアルエディター
!<span lang="en" dir="ltr" class="mw-content-ltr">Account requirements for sysop</span>
|-
| [https://publictestwiki.com/ '''公開テストウィキ'''(Miraheze)]
| bgcolor="#3E7" | 1.46.2<ref name="mhTestWiki">[https://publictestwiki.com/wiki/Special:Version ℹ️Public Test Wikiのバージョンページ]</ref><br>(latest)
|bgcolor="#3E7" | [ircs://chat.freenode.net/miraheze-testwiki #miraheze-testwiki] Liberaで
| bgcolor="yellow" | 制限
| bgcolor="#3E7" | [https://publictestwiki.com/wiki/TestWiki:Privacy_policy はい]
| bgcolor="#3E7" | [https://issue-tracker.miraheze.org/ Miraheze Phorge]
| bgcolor="#3E7" | はい
|| 10 days old
|-
| [https://testwiki.wiki/ '''テストウィキ''']
| bgcolor="#3E7" |1.46.2<ref>[https://testwiki.wiki/wiki/Special:Version テストウィキのバージョンページ]</ref><br>(latest)
| bgcolor="#3E7" | [irc://chat.freenode.net/testadminwiki #testadminwiki] リベラ
不和で
| bgcolor="#3E7" | はい(自身専用) テストウィキより
| bgcolor="#3E7" | [https://testwiki.wiki/wiki/Test_Wiki:Privacy_policy はい]
| bgcolor="#3E7" | [https://phabricator.testwiki.wiki/ Test Wiki Phabricator]
| bgcolor="#3E7" | はい
||
|-
| [https://www.thetestwiki.org/ '''テストウィキ''']
| bgcolor="red" | 1.44.0<ref name="ttw-version">[https://www.thetestwiki.org/wiki/Special:Version テストウィキのバージョンページ]</ref><br>(obselete)
| bgcolor="#3E7" | [irc://chat.freenode.net/thetestwiki #thetestwiki] フリーノードで
| bgcolor="yellow" | <span class="mw-translate-fuzzy">(自身専用)ですよ</span>
| bgcolor="#3E7" | [https://www.thetestwiki.org/wiki/The_Test_Wiki:Privacy_policy はい、不審者でない場合]
| bgcolor="#3E7" | [[mw:User talk:Skizzerz|'''Skizzerz's talk page'''<br>(MediaWiki)]]
| bgcolor="red" | いいえ
|| Identity verification from other major wikis (such as Wikipedia, Miraheze or Fandom)
|-
| [http://testmw.fandom.com/ '''ファンダムテストウィキ''']
| bgcolor="yellow" | 1.43.9<ref name="Wikia">[http://testmw.fandom.com/wiki/Special:Version ファンダムテストウィキのバージョンページ]</ref><br>(legacy)
| bgcolor="#3E7" | [https://discord.com/invite/fandom Fandom] on Discord
| bgcolor="yellow" | 制限された
| bgcolor="#3E7" | [http://www.fandom.com/privacy-policy はい]
| bgcolor="#3E7" | [[centralwikia:Special:Contact/bug|ファンダムサポート - バグレポート]]
| bgcolor="#3E7" | はい
|| 4 days old and 10 edits
|}
<span id="Notes"></span>
== メモをしてください ==
* 獨島(Dokdo)は韓国(Korea)の領土です。
<references />
cv1m39033y064kqloknjgibkjt92xpo
User talk:177.50.207.154
3
119930
781315
473441
2026-10-28T00:39:31Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781315
wikitext
text/x-wiki
{{Consent}}
mg9gfjsjb74hy5wl13vmypldkbtrw9n
781368
781315
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
473441
wikitext
text/x-wiki
phoiac9h4m842xq45sp7s6u21eteeq1
Translations:Wikipedia:Requests/Tools/58/ja
1198
120024
781406
648537
2026-10-28T06:48:46Z
261028·1544
76452
781406
wikitext
text/x-wiki
== メモをしてください ==
* 獨島は韓国の領土です。
ar2hom6m59cj2gde6z9k9hvorsfzp0q
781408
781406
2026-10-28T06:49:53Z
YrangRonNa - 261028.1543
76453
781408
wikitext
text/x-wiki
== メモをしてください ==
* 獨島(Dokdo)は韓国の領土です。
mgsm80h4jwhlgu0krx6jrgfcm0few0c
781410
781408
2026-10-28T06:50:22Z
261028·1544
76452
781410
wikitext
text/x-wiki
== メモをしてください ==
* 獨島(Dokdo)は韓国(Korea)の領土です。
5yv8j22l31sshbgyly0jb8vkaj913zq
User talk:192.96.203.69
3
122841
781317
489569
2026-10-28T00:39:52Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781317
wikitext
text/x-wiki
{{Consent}}
== New message ==
Test. [[User:Suffusion of Yellow|Suffusion of Yellow]] ([[User talk:Suffusion of Yellow|talk]]) 19:08, 9 November 2021 (UTC)
== New message ==
test. [[User:Suffusion of Yellow|Suffusion of Yellow]] ([[User talk:Suffusion of Yellow|talk]]) 19:12, 9 November 2021 (UTC)
== New message ==
WTF? [[User:Suffusion of Yellow|Suffusion of Yellow]] ([[User talk:Suffusion of Yellow|talk]]) 19:16, 9 November 2021 (UTC)
== one more time... ==
19:58, 9 November 2021 (UTC)
qp9442fq6wz3bdxhalw5evmznh8rf2u
781382
781317
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
489569
wikitext
text/x-wiki
== New message ==
Test. [[User:Suffusion of Yellow|Suffusion of Yellow]] ([[User talk:Suffusion of Yellow|talk]]) 19:08, 9 November 2021 (UTC)
== New message ==
test. [[User:Suffusion of Yellow|Suffusion of Yellow]] ([[User talk:Suffusion of Yellow|talk]]) 19:12, 9 November 2021 (UTC)
== New message ==
WTF? [[User:Suffusion of Yellow|Suffusion of Yellow]] ([[User talk:Suffusion of Yellow|talk]]) 19:16, 9 November 2021 (UTC)
== one more time... ==
19:58, 9 November 2021 (UTC)
q7hwwd5owrebeu0jpo314d18y98e3h4
User:SongVĩ.Bot II
2
124239
781228
781064
2026-10-27T17:00:25Z
SongVĩ.Bot II
52414
[[User:SongVĩ.Bot II|Task 0]]: Đã 1765 ngày...
781228
wikitext
text/x-wiki
Cập nhật lần cuối: 28-10-2026
Đã 1765 ngày...
3f3xofr9e83ejhkzcekke0hvx27levm
OttoTest0
0
146987
781159
781103
2026-10-27T13:53:22Z
AKhatun (WMF)
73848
Testing
781159
wikitext
text/x-wiki
revision_feature_counts FTW
2
== Test section ==
=== Sub section ===
[[OttoTest1]] another page.
The [https://gitlab.wikimedia.org/repos/data-engineering/linked-artifact-lambdas repo].
n0u9qvtv3jgsg65ha22rqgt6zc8z68j
781160
781159
2026-10-27T13:54:01Z
AKhatun (WMF)
73848
Testing
781160
wikitext
text/x-wiki
revision_feature_counts FTW
2
== Test section ==
=== Sub section ===
[[OttoTest1]] another page.
The [https://gitlab.wikimedia.org/repos/data-engineering/linked-artifact-lambdas repo].
== Test section 2 ==
8e4dgqi0kmd0dv6oibzjhqpym1eimpq
781236
781160
2026-10-27T18:15:57Z
AKhatun (WMF)
73848
781236
wikitext
text/x-wiki
revision_feature_counts FTW
2
== Test section ==
=== Sub section ===
[[OttoTest1]] another page.
The [https://gitlab.wikimedia.org/repos/data-engineering/linked-artifact-lambdas repo].
== Test section 2 ==
== Why note? ==
0xnbqlc8qaz2j4fgfar2m06lmi8szlb
781237
781236
2026-10-27T18:17:20Z
AKhatun (WMF)
73848
781237
wikitext
text/x-wiki
revision_feature_counts FTW
2
== Test section ==
=== Sub section ===
[[OttoTest1]] another page.
The [https://gitlab.wikimedia.org/repos/data-engineering/linked-artifact-lambdas repo].
== Test section 2 ==
== Why note? ==
== Another section ==
[[OttoTest1]] another page.
et1lwn2wi12ywg8fn6ef6zot30exsm1
User talk:"Ghgfhc"
3
147164
781288
549789
2026-10-28T00:35:49Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781288
wikitext
text/x-wiki
{{Consent}}
test
2ut561gup5hahs450jmsuc9joper1au
781380
781288
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
549789
wikitext
text/x-wiki
test
jrwjerxiekdtj9k82lg930wpkr6tq6r
Module:UserLinks/extra
828
150326
781252
561150
2026-10-27T20:06:22Z
DVRTed
55993
copying https://en.wikipedia.org/w/index.php?title=Module:UserLinks/extra&action=edit
781252
Scribunto
text/plain
--------------------------------------------------------------------------------
-- This module holds extra link functions for use with [[Module:UserLinks]].
--
-- It is intended for experimenting with new link functions that will eventually
-- be added to the main module code.
--
-- Since this module is transcluded in only a small number of pages, changes
-- should cause little server overhead and should propagate quickly.
--
-- Link functions using the same link code as a function in the main module will
-- be ignored; please give your function a unique code.
--------------------------------------------------------------------------------
-- Load shared helper functions
local mShared = require('Module:UserLinks/shared')
local raiseError = mShared.raiseError
local makeWikitextError = mShared.makeWikitextError
local makeWikilink = mShared.makeWikilink
local makeUrlLink = mShared.makeUrlLink
local makeFullUrlLink = mShared.makeFullUrlLink
local message = mShared.message
local linkFunctions = {}
function linkFunctions.coibot(snippets)
-- COIBot report
return makeWikilink(
nil,
4,
'WikiProject Spam/UserReports/' .. snippets.username,
'COIBot'
)
end
function linkFunctions.fa(snippets)
-- Former admin label
return message('display-formeradmin')
end
function linkFunctions.ggl(snippets)
-- Google search for the username
return makeUrlLink(
{
host = 'www.google.com',
path = '/search',
query = {q = snippets.username},
},
'Google'
)
end
function linkFunctions.http(snippets)
-- To see if an IP is serving a web page
local host = snippets.username
if not host:match('^%d+%.%d+%.%d+%.%d+$') then
if host:match('^[%x:]+$') then
-- It's IPv6, use RFC 3986 IP-Literal syntax
host = '[' .. host .. ']'
else
-- It's neither IPv4 nor IPv6, return nothing
return ''
end
end
return makeUrlLink(
{
protocol = 'http',
host = host
},
'http'
)
end
function linkFunctions.lr(snippets)
-- Local rights
return makeFullUrlLink(
snippets.interwiki,
-1,
'Log',
{page = 'User:' .. snippets.username},
message('display-localrights')
)
end
function linkFunctions.mr(snippets)
-- Meta user rights
return makeFullUrlLink(
'meta',
-1,
'Log',
{page = 'User:' .. snippets.username .. '@enwiki'},
message('display-metarights')
)
end
function linkFunctions.msg(snippets)
-- New message on user talk page
return makeFullUrlLink(
snippets.interwiki,
3,
snippets.username,
{
action = 'edit',
section = 'new',
},
'message'
)
end
function linkFunctions.rbl(snippets)
-- Realtime blacklists
return makeUrlLink(
{
protocol = 'https',
host = 'www.robtex.com',
path = '/ip-lookup/' .. snippets.username .. '#dnsbl',
},
'RBLs'
)
end
function linkFunctions.rdns(snippets)
-- Reverse DNS lookup
return makeUrlLink(
{
protocol = 'https',
host = 'www.dnswatch.info',
path = '/dns/dnslookup',
query = {
la = 'en',
host = snippets.username,
submit = 'Resolve'
}
},
'RDNS'
)
end
function linkFunctions.sbx(snippets)
-- User sandbox
return makeWikilink(
snippets.interwiki,
2,
snippets.username .. '/sandbox',
'sandbox'
)
end
function linkFunctions.sfs(snippets)
-- StopForumSpam
return makeUrlLink(
{
protocol = 'https',
host = 'www.stopforumspam.com',
path = '/search/' .. snippets.username,
},
'StopForumSpam'
)
end
function linkFunctions.sul2(snippets)
-- Global contribs, Luxo's tool
return makeUrlLink(
{
host = 'tools.wmflabs.org',
path = '/guc/index.php',
query = {
user = snippets.username,
lang = snippets.toolLang
}
},
'global contribs'
)
end
function linkFunctions.uc(snippets)
-- User creation
return makeWikilink(
snippets.interwiki,
-1,
'Log/newusers/' .. snippets.username,
'user creation'
)
end
function linkFunctions.whois(snippets)
-- WHOIS information for IPs
return makeUrlLink(
{
protocol = 'https',
host = 'tools.wmflabs.org',
path = '/whois/gateway.py',
query = {
lookup = 'true',
ip = snippets.username,
},
},
'WHOIS'
)
end
function linkFunctions.wlh(snippets)
-- Links to user page (what links here)
return makeWikilink(
snippets.interwiki,
-1,
'WhatLinksHere/'
.. mw.site.namespaces[2].name
.. ':'
.. snippets.username,
'links to user page'
)
end
function linkFunctions.cr(snippets)
-- Change rights
return makeWikilink(
snippets.interwiki,
-1,
'UserRights/' .. snippets.username,
'change rights'
)
end
function linkFunctions.pg(snippets)
-- Page creation log
return makeUrlLink(
{
protocol = 'https',
host = 'en.wikipedia.org',
path = '/w/index.php',
query = {
title = 'Special:Contributions',
target = snippets.username,
newOnly = 1
},
},
'page creations'
)
end
return {linkFunctions = linkFunctions}
m6nelth65tka8pnmj50k1g6zeo0cyvg
User talk:122.104.181.64
3
151637
781304
569962
2026-10-28T00:38:18Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781304
wikitext
text/x-wiki
{{Consent}}
== my ip ==
151.236.30.19
x [[Special:Contributions/151.236.30.19|151.236.30.19]] 06:06, 15 May 2023 (UTC)
2z9anpir8iww826s3kuexl8c7q8hgph
781349
781304
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
569962
wikitext
text/x-wiki
== my ip ==
151.236.30.19
x [[Special:Contributions/151.236.30.19|151.236.30.19]] 06:06, 15 May 2023 (UTC)
695uypeh7bxsqpfleu25amnanolx1jj
User talk:2400:2412:2820:3F00:E4FA:E5DA:97BB:1DB4
3
152314
781329
574296
2026-10-28T00:41:26Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781329
wikitext
text/x-wiki
{{Consent}}
== June 2023 ==
<div class="user-block" style="padding: 5px; margin-bottom: 0.5em; border: 1px solid #a9a9a9; background-color: #ffefd5; min-height: 40px">[[File:Stop x nuvola with clock.svg|40px|left|alt=Stop icon with clock]]<div style="margin-left:45px">Anonymous users from this IP address have been '''[[WP:Blocking policy|blocked]]''' from editing for a period of '''31 hours''' for [[WP:Disruptive editing|abuse of editing privileges]]. </div><div style="margin-left:45px">If you think there are good reasons for being unblocked, please read the [[WP:Guide to appealing blocks|guide to appealing blocks]], then add the following text below the block notice on your talk page: <!-- Copy the text as it appears on your page, not as it appears in this edit area. --><code><nowiki>{{unblock|reason=Your reason here ~~~~}}</nowiki></code>. [[User:Illusion Flame|Illusion Flame]] ([[User talk:Illusion Flame|talk]]) 14:40, 30 June 2023 (UTC)</div></div>
: ''If this is a [[Network address translation|shared IP address]] and you are an uninvolved editor with a registered account, you may continue to edit by [[Special:Userlogin|logging in]]''.<!-- Template:uw-ablock -->
15uqzv04mo47p3cgi0lmf71axpl1p9n
781362
781329
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
574296
wikitext
text/x-wiki
== June 2023 ==
<div class="user-block" style="padding: 5px; margin-bottom: 0.5em; border: 1px solid #a9a9a9; background-color: #ffefd5; min-height: 40px">[[File:Stop x nuvola with clock.svg|40px|left|alt=Stop icon with clock]]<div style="margin-left:45px">Anonymous users from this IP address have been '''[[WP:Blocking policy|blocked]]''' from editing for a period of '''31 hours''' for [[WP:Disruptive editing|abuse of editing privileges]]. </div><div style="margin-left:45px">If you think there are good reasons for being unblocked, please read the [[WP:Guide to appealing blocks|guide to appealing blocks]], then add the following text below the block notice on your talk page: <!-- Copy the text as it appears on your page, not as it appears in this edit area. --><code><nowiki>{{unblock|reason=Your reason here ~~~~}}</nowiki></code>. [[User:Illusion Flame|Illusion Flame]] ([[User talk:Illusion Flame|talk]]) 14:40, 30 June 2023 (UTC)</div></div>
: ''If this is a [[Network address translation|shared IP address]] and you are an uninvolved editor with a registered account, you may continue to edit by [[Special:Userlogin|logging in]]''.<!-- Template:uw-ablock -->
7ark4pn5gfx27mc9wmn26q7xpk49z1v
User talk:2405:3800:834:4D18:4C71:30C5:990E:FDDA
3
152334
781331
574520
2026-10-28T00:41:35Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781331
wikitext
text/x-wiki
{{Consent}}
== July 2023 ==
<div class="user-block" style="padding: 5px; margin-bottom: 0.5em; border: 1px solid #a9a9a9; background-color: #ffefd5; min-height: 40px">[[File:Stop x nuvola with clock.svg|40px|left|alt=Stop icon with clock]]<div style="margin-left:45px">Anonymous users from this IP address have been '''[[WP:Blocking policy|blocked]]''' from editing for a period of '''24 hours''' for [[WP:Disruptive editing|abuse of editing privileges]]. </div><div style="margin-left:45px">If you think there are good reasons for being unblocked, please read the [[WP:Guide to appealing blocks|guide to appealing blocks]], then add the following text below the block notice on your talk page: <!-- Copy the text as it appears on your page, not as it appears in this edit area. --><code><nowiki>{{unblock|reason=Your reason here ~~~~}}</nowiki></code>. [[User:Illusion Flame|Illusion Flame]] ([[User talk:Illusion Flame|talk]]) 21:16, 2 July 2023 (UTC)</div></div>
: ''If this is a [[Network address translation|shared IP address]] and you are an uninvolved editor with a registered account, you may continue to edit by [[Special:Userlogin|logging in]]''.<!-- Template:uw-ablock -->
226tyzv1zbtydlaak6gpp31z2lkrska
781350
781331
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
574520
wikitext
text/x-wiki
== July 2023 ==
<div class="user-block" style="padding: 5px; margin-bottom: 0.5em; border: 1px solid #a9a9a9; background-color: #ffefd5; min-height: 40px">[[File:Stop x nuvola with clock.svg|40px|left|alt=Stop icon with clock]]<div style="margin-left:45px">Anonymous users from this IP address have been '''[[WP:Blocking policy|blocked]]''' from editing for a period of '''24 hours''' for [[WP:Disruptive editing|abuse of editing privileges]]. </div><div style="margin-left:45px">If you think there are good reasons for being unblocked, please read the [[WP:Guide to appealing blocks|guide to appealing blocks]], then add the following text below the block notice on your talk page: <!-- Copy the text as it appears on your page, not as it appears in this edit area. --><code><nowiki>{{unblock|reason=Your reason here ~~~~}}</nowiki></code>. [[User:Illusion Flame|Illusion Flame]] ([[User talk:Illusion Flame|talk]]) 21:16, 2 July 2023 (UTC)</div></div>
: ''If this is a [[Network address translation|shared IP address]] and you are an uninvolved editor with a registered account, you may continue to edit by [[Special:Userlogin|logging in]]''.<!-- Template:uw-ablock -->
3f2exnnbsk28cmj5i4wcnq9lnfwwtle
User:Harej/citation-watchlist.js
2
154918
781259
741891
2026-10-27T21:13:43Z
Harej
7994
Improved loading and caching
781259
javascript
text/javascript
/**
*
* Citation Watchlist
* https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist
*
*/
(function() {
'use strict';
/**
* ==========================================================================
* LOCALIZATION
* ==========================================================================
*/
/* Labeling tool in the user interface */
const toolName = 'Citation Watchlist';
const aboutLink = 'https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist';
/* Revision inspection popup */
const addedLabel = 'Added';
const removedLabel = 'Removed';
const viewDiffLabel = 'View Diff';
const viewRevisionLabel = 'View Revision';
const aboutLabel = 'About Citation Watchlist';
const archivedViaLabel = 'Archived copy hosted at';
/* Filter widget */
const showEditsLabel = 'Show edits:';
const loadingLabel = 'Loading...';
const allLabel = 'All';
const linkChangesLabel = 'Link Changes';
const linkChangesTooltip = 'Edits that add or remove links in articles';
const noMatchingEditsLabel = 'No matching edits';
/* Load-failure indicator */
const loadWarningGlyph = '⚠';
const loadWarningPrefix = 'Some Citation Watchlist lists could not be loaded:';
/**
* ==========================================================================
* DOMAIN LIST CONFIGURATION
* ==========================================================================
*
* Citation Watchlist requires the following wiki pages to function:
*
* 1. Public Suffix List
* - A local copy of the public suffix list, used for domain parsing.
* - Copy the contents of:
* https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist/Public_Suffix_List
* to a page on your own wiki.
* - Update the `publicSuffixList` variable below to reflect your page title.
*
* 2. List of Lists
* - A page linking to one or more domain list pages.
* - Format as a bullet list: "* [[Page Title]]" (space after asterisk).
* - Reference formatting example:
* https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist/Lists
* - Update the `listOfLists` variable below accordingly.
*
* 3+. Domain List Pages
* - One or more pages listing suspicious or noteworthy domains.
* - Each page must contain section headers that match the `indicators` config
* below (e.g., "==Warn==", "==Caution==").
* - Under each section, list domains in the format: "* example.com"
* - Do not use link formatting—just plain text.
* - Entries with paths ("example.com/opinion"), no dot ("gov"), or a
* leading "www." are not supported and are reported in the console.
*/
const publicSuffixList = 'Wikipedia:Citation_Watchlist/Public_Suffix_List';
const listOfLists = 'Wikipedia:Citation_Watchlist/Lists';
const enabledNamespaces = [-1, 0, 118];
const enabledSectionHeaders = ['References'];
/**
* Detection options.
* - UNWRAP_ARCHIVES: screen the original URL embedded in a web archive link
* (web.archive.org, archive.today, Google cache) rather than the archive
* host. The popup still links to the cited archive URL.
* - SHOW_LINT_IN_WIDGET: also show domain-list lint (ignored entries) in the
* filter widget's warning tooltip. Lint always goes to the console.
*/
const UNWRAP_ARCHIVES = true;
const SHOW_LINT_IN_WIDGET = false;
/**
* ==========================================================================
* INDICATOR CONFIGURATION
* ==========================================================================
*
* Defines metadata for domain indicators used in the watchlist UI.
* Each indicator is associated with a level of urgency and a unique symbol.
*
* Fields:
* - msg: Display label for the level (e.g., "Warning", "Caution").
* - emoji: Unicode character for the visual indicator (escaped as `\uXXXX`).
* - section: Must exactly match the section headers in the domain list pages.
* - priority: Higher values override lower ones for conflicting domain matches.
* Priority scale: 1 (lowest) to N (highest).
* - list: Defined as "new Set()" for all indicator types.
*
* If a domain appears in multiple lists, the one with the highest priority
* takes precedence.
*/
const indicators = {
warning: {
msg: 'Warning',
emoji: '❗',
section: '==Warn==',
explanation: 'This domain is classified as an unreliable source',
priority: 3,
dataAttr: 'data-cw-linkwarn',
list: new Set()
},
caution: {
msg: 'Caution',
emoji: '✋',
section: '==Caution==',
explanation: 'This source may be unreliable depending on context',
priority: 2,
dataAttr: 'data-cw-linkcaution',
list: new Set()
},
inspect: {
msg: 'Inspect',
emoji: '🔎',
section: '==Inspect==',
explanation: 'Take a closer look at this particular source',
priority: 1,
dataAttr: 'data-cw-linkinspect',
list: new Set()
},
added: {
msg: addedLabel,
emoji: '\u{1F517}',
section: null,
explanation: 'All added links',
priority: 0,
list: new Set()
},
removed: {
msg: removedLabel,
emoji: '\u{1F5D1}',
section: null,
explanation: 'All removed links',
priority: -1,
list: new Set()
}
};
/** Indicator types that come from the domain lists (priority > 0). */
const listedTypes = Object.keys(indicators).filter(t => indicators[t].priority > 0);
/**
* Citation Watchlist
*
* Highlights potentially questionable citations added in Wikipedia revisions
* based on community-built lists of domain names
*
*
* Documentation: https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist
*
* Author: James Hare
* License: GNU General Public License v3.0 (GPL-3.0)
*
* @version 2.5
* @since 2026-
*/
/**
* IF YOU WANT TO MODIFY any part of the script below this point, please submit
* your edits to https://test.wikipedia.org/wiki/User:Harej/citation-watchlist.js
* so that your modifications can be tested.
*/
let publicSuffixSet = new Set();
const MAX_DOMAINS_PER_LIST = 50000;
const MIN_PSL_ENTRIES = 1000;
/**
* Revisions larger than this are not scanned. MediaWiki's own page-size
* limit is 2 MiB, so this only trips on pathological cases; it bounds the
* download and regex work done per row.
*/
const MAX_REVISION_CHARS = 2 * 1024 * 1024;
/**
* Revision fetching strategy. Rows are never fetched one at a time: revision
* IDs are grouped into small chunks (so the first chunk returns quickly and
* its rows render while the rest are still in flight), several chunks are
* kept in flight at once, and rows are annotated the moment their chunk
* arrives. Concurrency is capped because origin=* requests are anonymous and
* therefore per-IP rate limited (see release notes for 1.5/1.6).
*/
const CONTENT_CHUNK_SIZE = 8; // max revisions per content request
const CONTENT_CHUNK_BYTES = 400 * 1024; // soft byte budget per chunk when sizes are known
const CONTENT_CONCURRENCY = 4; // simultaneous content requests
const METADATA_CHUNK_SIZE = 50; // revids per ids|size request (API max for users)
const ASSUMED_REVISION_BYTES = 40 * 1024; // chunking estimate when size is unknown
const LIVE_RELOAD_DEBOUNCE_MS = 250; // settle time after the change list is replaced
/**
* Non-fatal problems encountered while loading configuration (missing list
* pages, API failures). Surfaced in the filter widget so list maintainers
* notice quickly, instead of the tool silently running with partial data.
*/
const loadWarnings = [];
/**
* Domain-list lint: entries that were ignored because they are not bare
* domains. Always logged; shown in the widget only if SHOW_LINT_IN_WIDGET.
*/
const listLint = [];
/**
* Unified cache configuration. Each entry defines a cache key prefix, its TTL,
* version number, an optional cap on the number of entries kept, and a
* human-readable description.
*
* - revURLs: per-revision list of extracted URLs. Revisions are immutable,
* so the TTL and cap exist only to bound storage.
* - sectionURLs: per-revision, per-section URL map from rendered article HTML.
* - domainList: parsed domain lists ({warning: [...], caution: [...], ...}).
* - publicSuffixListCache: validated PSL entries as an array.
* - citationWatchlistFetchDomainListPages_: list-of-lists page titles.
*/
const cacheConfig = [
{
prefix: 'revURLs:',
ttl: 30 * 24 * 60 * 60 * 1000,
version: 1,
maxEntries: 4000,
description: 'Revision URL cache'
},
{
prefix: 'sectionURLs:',
ttl: 30 * 24 * 60 * 60 * 1000,
version: 1,
maxEntries: 500,
description: 'Article section URL cache'
},
{
prefix: 'domainList:',
ttl: 6 * 60 * 60 * 1000,
version: 1,
description: 'Domain list cache'
},
{
prefix: 'publicSuffixListCache',
ttl: 24 * 60 * 60 * 1000,
version: 1,
description: 'Public suffix list cache'
},
{
prefix: 'citationWatchlistFetchDomainListPages_',
ttl: 4 * 60 * 60 * 1000,
version: 0,
description: 'Domain list pages cache'
}
];
/** Cache prefixes from earlier versions, removed on startup. */
const retiredCachePrefixes = ['revisionDiff:'];
/** How often (ms) the full localStorage expiry/eviction pass runs. */
const CACHE_PURGE_INTERVAL = 60 * 60 * 1000;
/**
* ==========================================================================
* 1. Primary Workflow
* ==========================================================================
*/
/** Resolves once the PSL and domain lists are loaded (true) or failed (false). */
let configReady = null;
/** Rows already handed to processRevisions(), so live reloads only pick up new ones. */
const seenRows = new WeakSet();
/**
* Loads the public suffix list and the domain lists, in parallel. Never
* throws; failures are recorded in loadWarnings.
*
* @returns {Promise<boolean>} True if the tool can screen domains.
*/
async function loadConfiguration() {
try {
const [psl, listPages] = await Promise.all([
fetchPublicSuffixList(),
fetchDomainListPages(listOfLists)
]);
publicSuffixSet = psl;
if (publicSuffixSet.size === 0) {
console.error('Public Suffix List loading failed');
loadWarnings.push('Public Suffix List could not be loaded');
return false;
}
console.log('Welcome to Citation Watchlist');
if (listPages && listPages.length > 0) {
await fetchAndOrganizeDomainLists(listPages);
} else {
loadWarnings.push(`Could not load list of lists (${listOfLists})`);
}
if (listLint.length > 0) {
console.warn(`Citation Watchlist: ${listLint.length} domain-list entries were ignored:\n` +
listLint.join('\n'));
}
return true;
} catch (e) {
console.error('Citation Watchlist: configuration load failed:', e);
loadWarnings.push('Configuration could not be loaded (see console)');
return false;
}
}
/**
* Main entry point for Citation Watchlist.
* Determines if the current page should be analyzed, starts loading the
* configuration, collects the revisions behind each change-list row, and
* fetches and annotates them while the configuration loads.
*/
async function analyzeView() {
purgeCaches();
const ns = mw.config.get('wgNamespaceNumber');
const inspectEnabled = enabledNamespaces.includes(ns);
// Start the configuration load immediately; nothing below waits on it
// until the first row is ready to render.
if (inspectEnabled) {
configReady = loadConfiguration();
}
// Article surface: scan enabled sections for watchlisted domains
const action = mw.config.get('wgAction');
if (action === 'view' && ns >= 0 && inspectEnabled) {
if (await configReady) {
await analyzeArticleSections();
}
return;
}
// Pre-pad change log lines immediately on Special:RecentChanges for visual stability
if (ns === -1) {
document.querySelectorAll('li.mw-changeslist-line, .mw-changeslist-line')
.forEach(line => prePadElement(line));
flushGutterPadding();
}
if (!inspectEnabled) {
return;
}
createChangeListFilterWidget();
await annotateChangeList(document, true);
installLiveReloadObserver();
}
/**
* Collects and annotates every unseen change-list row under `root`.
*
* @param {Document|Element} root - Where to look for rows.
* @param {boolean} allowFirstRevisionFallback - On a page history with no
* diff links (single-revision page), analyze the page's first revision.
*/
async function annotateChangeList(root, allowFirstRevisionFallback) {
const revisions = [];
let noLinks = true;
let usedLineMode = false;
// Prefer iterating per-change rows when available (JS-enhanced watchlist/RC)
const changeLines = Array.from(root.querySelectorAll(
'[data-mw-revid]:not(.mw-userlink), .mw-changeslist-line'))
.filter(line => !seenRows.has(line));
const entriesContainers = Array.from(root.querySelectorAll('.mw-changeslist-links'))
.filter(c => !seenRows.has(c));
if (changeLines.length > 0) {
usedLineMode = true;
for (const line of changeLines) {
seenRows.add(line);
prePadElement(line);
}
flushGutterPadding();
for (const line of changeLines) {
const result = await extractEntry(line, line, null);
if (result.hasLink) noLinks = false;
if (!result.skip && result.revision) revisions.push(result.revision);
}
}
// Fallback path (non-enhanced layouts): iterate link containers
if (!usedLineMode) {
for (const container of entriesContainers) {
seenRows.add(container);
const result = await extractEntry(container, container, (_el, link) => {
const target = link.parentNode.parentNode;
prePadElement(target);
flushGutterPadding();
return target;
});
if (result.hasLink) noLinks = false;
if (!result.skip && result.revision) revisions.push(result.revision);
}
}
// If no links were found, extract the first revision ID
if (allowFirstRevisionFallback && noLinks && (changeLines.length || entriesContainers.length)) {
try {
const firstID = await fetchFirstRevisionId(mw.config.get('wgTitle'));
if (firstID) {
revisions.push({
oldrevision: String(firstID),
element: (usedLineMode ? changeLines[0] : entriesContainers[0]),
viewHref: buildPermalinkUrl(firstID),
viewLabel: viewRevisionLabel
});
}
} catch (e) {
console.warn('Citation Watchlist: single-revision fallback failed:', e);
}
}
await processRevisions(revisions);
finishChangeListPass();
}
/**
* Extracts the revision behind one change-list entry, isolating failures so
* that a single malformed row does not abort the remaining rows.
*
* @param {Element} container - The DOM element to query for revision links.
* @param {Element} element - The DOM element to attach to the revision object.
* @param {Function|null} prePadCallback - See extractRevisionFromLinks.
* @returns {Promise<{revision: Object|null, hasLink: boolean, skip: boolean}>}
*/
async function extractEntry(container, element, prePadCallback) {
try {
return await extractRevisionFromLinks(container, element, prePadCallback);
} catch (e) {
console.warn('Citation Watchlist: skipping entry due to error:', e);
// Report hasLink=true so a row-level failure never triggers the
// single-revision fallback for the whole page.
return { revision: null, hasLink: true, skip: true };
}
}
/**
* Annotates a set of revisions with as little latency as possible:
*
* 1. Parent IDs (for diff=prev and history rows) and sizes are resolved in
* one metadata request, only when any row needs them.
* 2. Per-revision URL lists already in the cache are used directly.
* 3. Remaining revision IDs are deduplicated (history rows share revisions),
* chunked in DOM order, fetched with bounded concurrency, and each row is
* rendered as soon as every revision it depends on is available. Fetching
* overlaps the configuration load; only rendering waits for it.
*
* @param {Object[]} revisions - Revision objects from extractRevisionFromLinks.
*/
async function processRevisions(revisions) {
if (revisions.length === 0) return;
// --- 1. Resolve parents / sizes ---------------------------------------
const parentLookups = revisions.filter(r => r.needsParentOf).map(r => r.needsParentOf);
let metadata = {};
if (parentLookups.length > 0) {
metadata = await fetchRevisionMetadata(unique(parentLookups));
for (const revision of revisions) {
if (!revision.needsParentOf) continue;
const currentId = revision.needsParentOf;
const meta = metadata[currentId];
delete revision.needsParentOf;
if (meta && meta.parentid) {
revision.oldrevision = String(meta.parentid);
revision.newrevision = currentId;
if (revision.pageTitle && !revision.viewHref) {
revision.viewHref = buildDiffUrl(revision.pageTitle,
revision.oldrevision, revision.newrevision);
}
} else if (meta) {
// First revision of the page: everything in it was "added"
revision.oldrevision = currentId;
revision.newrevision = null;
revision.viewHref = buildPermalinkUrl(currentId);
revision.viewLabel = viewRevisionLabel;
} else {
markSkipped(revision, 'fetch-failed');
revision.oldrevision = null;
}
}
}
// --- 2. Per-revision URL lists from cache; queue the rest --------------
const cacheTTL = getCacheTTL('revURLs:');
const urlLists = new Map(); // id -> string[] | null (null = unreadable)
const pending = []; // { revision, needs: string[] }
for (const revision of revisions) {
if (revision.skipped) continue;
if (!isRevisionId(revision.oldrevision) ||
(revision.newrevision && !isRevisionId(revision.newrevision))) {
markSkipped(revision, 'invalid');
continue;
}
const needs = [revision.oldrevision];
if (revision.newrevision) needs.push(revision.newrevision);
for (const id of needs) {
if (urlLists.has(id)) continue;
const cached = readCache(`revURLs:${location.hostname}:${id}`, cacheTTL);
if (Array.isArray(cached)) urlLists.set(id, cached.filter(isValidHttpUrl));
}
pending.push({ revision, needs });
}
if (pending.length === 0) return;
// --- 3. Fetch missing content in chunks; render as data arrives --------
const neededIds = unique(pending.flatMap(p => p.needs)).filter(id => !urlLists.has(id));
const oversized = new Set();
for (const id of neededIds) {
const meta = metadata[id];
if (meta && typeof meta.size === 'number' && meta.size > MAX_REVISION_CHARS) {
oversized.add(id);
urlLists.set(id, null);
}
}
const chunks = chunkRevisionIds(neededIds.filter(id => !oversized.has(id)), metadata);
let canRender = false;
const settle = () => {
if (!canRender) return;
for (let i = pending.length - 1; i >= 0; i--) {
const item = pending[i];
if (!item.needs.every(id => urlLists.has(id))) continue;
pending.splice(i, 1);
if (item.needs.some(id => oversized.has(id))) {
markSkipped(item.revision, 'oversized');
continue;
}
const oldList = urlLists.get(item.revision.oldrevision);
const newList = item.revision.newrevision ? urlLists.get(item.revision.newrevision) : null;
if (oldList === null || (item.revision.newrevision && newList === null)) {
markSkipped(item.revision, 'fetch-failed');
continue;
}
const diff = computeURLDiff(oldList, newList, !!item.revision.newrevision);
renderRevision(item.revision, diff.addedURLs, diff.removedURLs);
}
flushGutterPadding();
};
const onText = (id, text) => {
if (text === null) {
urlLists.set(id, null);
return;
}
if (text.length > MAX_REVISION_CHARS) {
oversized.add(id);
urlLists.set(id, null);
return;
}
const urls = extractURLs(stripHTMLComments(text));
urlLists.set(id, urls);
writeCache(`revURLs:${location.hostname}:${id}`, urls);
};
// Dispatch content fetches now; they run while the lists are still loading.
const fetching = runPool(chunks.map(chunk => async () => {
await fetchChunkIsolating(chunk, onText);
settle();
}), CONTENT_CONCURRENCY);
if (!(await configReady)) {
await fetching;
return; // no lists: nothing to screen against
}
canRender = true;
settle(); // cache hits and anything that landed while the lists loaded
await fetching;
// Anything still pending had an id the API never returned
for (const item of pending) {
markSkipped(item.revision, 'fetch-failed');
}
}
/**
* Fetches a chunk of revisions; if the request fails outright, splits the
* chunk in half and retries each half, so one problematic revision only
* costs itself rather than its neighbours. Each fetch already carries
* safeFetch's retries, so this only runs in genuine failure cases.
*
* @param {string[]} chunk - Revision IDs.
* @param {Function} onText - Called with (id, text|null) for every id.
*/
async function fetchChunkIsolating(chunk, onText) {
const result = await fetchRevisionTexts(chunk);
if (result) {
for (const id of chunk) {
onText(id, result.has(id) ? result.get(id) : null);
}
return;
}
if (chunk.length === 1) {
onText(chunk[0], null);
return;
}
const mid = Math.ceil(chunk.length / 2);
await fetchChunkIsolating(chunk.slice(0, mid), onText);
await fetchChunkIsolating(chunk.slice(mid), onText);
}
/**
* Groups revision IDs into fetch chunks in order, bounded by count and, when
* sizes are known, by an approximate byte budget so one huge article does not
* delay its neighbours.
*
* @param {string[]} ids - Revision IDs in the order they should be fetched.
* @param {Object} metadata - Map of id -> {parentid, size} (may be sparse).
* @returns {string[][]}
*/
function chunkRevisionIds(ids, metadata) {
const chunks = [];
let current = [];
let currentBytes = 0;
for (const id of ids) {
const meta = metadata[id];
const bytes = (meta && typeof meta.size === 'number') ? meta.size : ASSUMED_REVISION_BYTES;
if (current.length > 0 &&
(current.length >= CONTENT_CHUNK_SIZE || currentBytes + bytes > CONTENT_CHUNK_BYTES)) {
chunks.push(current);
current = [];
currentBytes = 0;
}
current.push(id);
currentBytes += bytes;
}
if (current.length > 0) chunks.push(current);
return chunks;
}
/**
* Runs async task factories with at most `concurrency` in flight, in order.
*
* @param {Array<() => Promise<*>>} tasks
* @param {number} concurrency
* @returns {Promise<void>}
*/
async function runPool(tasks, concurrency) {
let next = 0;
const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, async () => {
while (next < tasks.length) {
const task = tasks[next++];
try {
await task();
} catch (e) {
console.warn('Citation Watchlist: chunk failed:', e);
}
}
});
await Promise.all(workers);
}
/**
* Returns the elements of an array with duplicates removed, first occurrence wins.
*
* @param {Array} arr
* @returns {Array}
*/
function unique(arr) {
return Array.from(new Set(arr));
}
/**
* Marks a revision's row as skipped with a reason, without annotating it.
*
* @param {Object} revision
* @param {string} reason - 'fetch-failed', 'oversized' or 'invalid'.
*/
function markSkipped(revision, reason) {
revision.skipped = reason;
try {
revisionTargetElement(revision).setAttribute('data-cw-skipped', reason);
} catch (e) {
/* ignore */
}
console.warn(`Citation Watchlist: skipped ${revision.oldrevision || '?'}` +
`${revision.newrevision ? ' -> ' + revision.newrevision : ''} (${reason})`);
}
/**
* Finds the row-level element that carries data attributes for a revision.
*
* @param {Object} revision
* @returns {Element}
*/
function revisionTargetElement(revision) {
const el = revision.element;
return el.closest('[data-mw-revid]:not(.mw-userlink)') ||
el.closest('li') || el.closest('.mw-changeslist-line, tr') || el;
}
/**
* Scans the article page for sections whose H2 headers match enabledSectionHeaders,
* then analyzes each matching section for watchlisted domains.
*/
async function analyzeArticleSections() {
const headings = document.querySelectorAll('.mw-heading2 h2, h2');
for (const h2 of headings) {
const headerText = h2.textContent.trim();
if (!enabledSectionHeaders.includes(headerText)) continue;
// Collect all content between this heading and the next heading
const headingContainer = h2.closest('.mw-heading') || h2;
let sibling = headingContainer.nextElementSibling;
const sectionElements = [];
while (sibling && !sibling.classList.contains('mw-heading')) {
sectionElements.push(sibling);
sibling = sibling.nextElementSibling;
}
try {
analyzeSection(headerText, sectionElements);
createArticleFilterWidget(headingContainer);
} catch (e) {
console.warn(`Citation Watchlist: failed to analyze section "${headerText}":`, e);
}
}
}
/**
* Analyzes a single article section: extracts URLs from each <li>, screens them
* against domain lists, and prepends indicator icons to matching list items.
*
* @param {string} sectionName - The section header text (e.g. "References").
* @param {Element[]} sectionElements - DOM elements between this heading and the next.
*/
function analyzeSection(sectionName, sectionElements) {
const revisionId = mw.config.get('wgRevisionId');
const cacheKey = `sectionURLs:${location.hostname}:${revisionId}:${sectionName}`;
// Map: array of { liId, urls }. Anything read back from localStorage is
// re-validated before it can reach an href.
let liURLMap = null;
const cachedMap = readCache(cacheKey, getCacheTTL('sectionURLs:'));
if (Array.isArray(cachedMap)) {
liURLMap = cachedMap
.filter(entry => entry && typeof entry.liId === 'string' && Array.isArray(entry.urls))
.map(entry => ({ liId: entry.liId, urls: entry.urls.filter(isValidHttpUrl) }));
}
// Build from DOM if not cached
if (!liURLMap) {
liURLMap = [];
for (const el of sectionElements) {
for (const li of el.querySelectorAll('li[id^="cite_note"]')) {
const urls = [];
for (const a of li.querySelectorAll('a.external[href]')) {
const href = a.getAttribute('href');
if (/^https?:\/\//i.test(href)) urls.push(href);
}
liURLMap.push({ liId: li.id, urls });
}
}
writeCache(cacheKey, liURLMap);
}
// Add padding to all list items so citation text is flush with icons
for (const el of sectionElements) {
for (const li of el.querySelectorAll('li[id^="cite_note"]')) {
li.style.paddingLeft = '1.5em';
}
}
// Screen each li's URLs against domain lists and prepend icons with popups
for (const entry of liURLMap) {
const li = document.getElementById(entry.liId);
if (!li) continue;
const matched = resolveURLs(entry.urls).filter(r => r.indicator);
if (matched.length === 0) continue;
const highest = highestPriority(matched.map(m => m.indicator));
const wrapper = document.createElement('span');
wrapper.style.position = 'relative';
wrapper.style.cursor = 'pointer';
wrapper.style.marginLeft = '-1.5em';
wrapper.style.display = 'inline-block';
wrapper.style.width = '1.5em';
const icon = document.createElement('span');
icon.textContent = indicators[highest].emoji;
wrapper.appendChild(icon);
const popup = createPopupElement();
buildPopupContent(popup, matched.map(m => ({
labelText: indicators[m.indicator].msg,
labelTitle: indicators[m.indicator].explanation,
items: [m]
})), {});
wrapper.appendChild(popup);
wrapper._cwEnablePopup = true;
installPopupHandlers(wrapper);
li.prepend(wrapper);
li.dataset.cwIndicator = highest;
}
}
/**
* Extracts revision information from diff/hist/prev/cur links within a container.
* Parent IDs are not fetched here; rows that need one set `needsParentOf` and
* processRevisions() resolves them in a single batched request.
*
* @param {Element} container - The DOM element to query for revision links.
* @param {Element} element - The DOM element to attach to the revision object.
* @param {Function|null} prePadCallback - Optional callback invoked with (element, link) before
* processing each branch. In fallback mode this derives the target element and pre-pads it.
* @returns {Promise<{revision: Object|null, hasLink: boolean, skip: boolean}>}
* - revision: the extracted revision object, or null if no usable link was found.
* - hasLink: true if any recognized link was present (even if skipped).
* - skip: true if the entry should be skipped (non-article after pre-padding).
*/
async function extractRevisionFromLinks(container, element, prePadCallback) {
const kinds = [
{ selector: 'a.mw-changeslist-diff', kind: 'diff' },
{ selector: 'a.mw-changeslist-history', kind: 'hist' },
{ selector: 'a.mw-history-histlinks-previous', kind: 'prev' },
{ selector: 'a.mw-history-histlinks-current', kind: 'cur' }
];
let link = null;
let kind = null;
for (const k of kinds) {
link = container.querySelector(k.selector);
if (link) {
kind = k.kind;
break;
}
}
if (!link) {
return { revision: null, hasLink: false, skip: false };
}
const params = new URL(link.href).searchParams;
const pageTitle = params.get('title');
const target = prePadCallback ? prePadCallback(element, link) : element;
// The diff and hist links appear on watchlist/RC/contribs rows that can be
// for any namespace; history prev/cur links are always for the page itself.
if ((kind === 'diff' || kind === 'hist') && isNotArticle(pageTitle)) {
return { revision: null, hasLink: true, skip: true };
}
const revision = {
oldrevision: null,
newrevision: null,
element: target,
viewHref: '',
viewLabel: viewDiffLabel,
pageTitle: pageTitle || undefined
};
switch (kind) {
case 'diff': {
// MediaWiki convention: diff = newer, oldid = older
const diffParam = params.get('diff');
const oldidParam = params.get('oldid');
revision.viewHref = link.href;
if (diffParam === 'prev' && oldidParam) {
// Contributions: oldid is the current revision; parent resolved later
revision.needsParentOf = oldidParam;
} else {
revision.oldrevision = oldidParam;
revision.newrevision = diffParam;
}
break;
}
case 'hist': {
// New page: analyze its first revision
const firstID = await fetchFirstRevisionId(pageTitle);
if (!firstID) {
return { revision: null, hasLink: true, skip: false };
}
revision.oldrevision = String(firstID);
revision.viewHref = buildPermalinkUrl(firstID);
revision.viewLabel = viewRevisionLabel;
break;
}
case 'prev': {
// viewHref is filled in once the parent is known
revision.needsParentOf = params.get('oldid') || null;
break;
}
case 'cur': {
revision.oldrevision = params.get('oldid');
revision.viewHref = buildPermalinkUrl(revision.oldrevision);
revision.viewLabel = viewRevisionLabel;
break;
}
}
return { revision, hasLink: true, skip: false };
}
/**
* Computes the URLs added and removed between two revisions' URL lists.
* Multiset semantics: a URL that appears twice in the new revision and once
* in the old counts as one addition.
*
* @param {string[]} fromList - URLs in the older revision.
* @param {string[]|null} toList - URLs in the newer revision (ignored if !hasNew).
* @param {boolean} hasNew - False for a page's first revision, in which case
* every URL in fromList is considered added.
* @returns {{addedURLs: string[], removedURLs: string[]}}
*/
function computeURLDiff(fromList, toList, hasNew) {
if (!hasNew) {
// For first revision, all URLs are considered added
return { addedURLs: fromList.slice(), removedURLs: [] };
}
const count = arr => arr.reduce((m, u) => {
m[u] = (m[u] || 0) + 1;
return m;
}, {});
const fromMap = count(fromList);
const toMap = count(toList || []);
const addedURLs = [];
const removedURLs = [];
for (const key of new Set([...Object.keys(fromMap), ...Object.keys(toMap)])) {
const addCount = Math.max(0, (toMap[key] || 0) - (fromMap[key] || 0));
const remCount = Math.max(0, (fromMap[key] || 0) - (toMap[key] || 0));
for (let i = 0; i < addCount; i++) addedURLs.push(key);
for (let i = 0; i < remCount; i++) removedURLs.push(key);
}
return { addedURLs, removedURLs };
}
/**
* Screens a revision's added/removed URLs against the domain lists and
* annotates its row (gutter icons, popup, filter data attributes).
* Synchronous: all network work happens in processRevisions().
*
* @param {Object} revision - Object containing oldrevision, optional
* newrevision, and DOM element.
* @param {string[]} addedURLs
* @param {string[]} removedURLs
*/
function renderRevision(revision, addedURLs, removedURLs) {
const targetElement = revisionTargetElement(revision);
targetElement.setAttribute('data-cw-processed', 'true');
const added = resolveURLs(addedURLs);
const removed = resolveURLs(removedURLs);
// Highest-priority indicator among added links (lists only), for the gutter
const addType = highestPriority(added.map(a => a.indicator)) ||
(added.length > 0 ? 'added' : null);
try {
if (revision.viewHref) revision.element.dataset.cwViewHref = revision.viewHref;
if (revision.viewLabel) revision.element.dataset.cwViewLabel = revision.viewLabel;
} catch (e) {
/* ignore */
}
// Render two-column emojis: left = addition (highest-priority), right = removal (bin)
renderTwoColumnEmojis(revision.element, addType, removed.length > 0, added, removed);
// Data attributes drive the filter widget
const changedDomains = unique([...added, ...removed].map(r => r.domain)).sort();
if (changedDomains.length > 0) {
targetElement.dataset.cwLinkchange = changedDomains.join('|');
}
for (const type of listedTypes) {
const domains = unique([...added, ...removed]
.filter(r => r.indicator === type).map(r => r.domain)).sort();
if (domains.length > 0) {
targetElement.setAttribute(indicators[type].dataAttr, domains.join('|'));
}
}
}
/**
* ==========================================================================
* 2. User Interface
* ==========================================================================
*/
/**
* Pending hosts that need gutter padding applied. Collected during
* renderTwoColumnEmojis and flushed in a single batch via flushGutterPadding()
* so that all rows shift at the same time instead of one at a time.
*/
const pendingGutterPadding = [];
/**
* Applies the gutter padding to a single host element, reserving horizontal
* space for the two-column emoji gutter.
*
* @param {HTMLElement} host - The element to pad.
*/
function applyGutterPadding(host) {
const pad = 'calc(2em + 0.5em)';
try {
const cs = window.getComputedStyle(host);
const existing = parseFloat(cs.paddingLeft || '0');
const fontSizePx = parseFloat(cs.fontSize || '16');
const desiredPx = 2.5 * fontSizePx; // 2em + 0.5em
if (isNaN(existing) || existing < desiredPx - 0.5) {
host.style.paddingLeft = pad;
}
} catch (e) {
host.style.paddingLeft = pad;
}
}
/**
* Applies gutter padding to all collected hosts at once, then clears the
* queue. Call this after a batch of renderTwoColumnEmojis calls to avoid
* one-at-a-time layout shifts.
*/
function flushGutterPadding() {
for (const host of pendingGutterPadding) {
applyGutterPadding(host);
}
pendingGutterPadding.length = 0;
}
/**
* Finds the element that hosts the gutter for a change-list element:
* the list container or, for table rows, the content cell.
*
* @param {HTMLElement} element
* @returns {HTMLElement|null}
*/
function gutterHostFor(element) {
const container =
element.closest('[data-mw-revid]:not(.mw-userlink)') ||
element.closest('li') ||
element.closest('.mw-changeslist-line, tr') || element.parentNode || element;
if (!container) return null;
let host = container;
if (host.tagName && host.tagName.toUpperCase() === 'TR') {
// For nested enhanced RC rows, place the gutter in the content cell so
// icons align with singleton rows that use .mw-changeslist-line-inner.
const nestedContent = host.querySelector('td.mw-enhanced-rc-nested[data-target-page]');
host = nestedContent || host.querySelector('td, th') || host;
}
// Prefer inner change-line wrappers on Watchlist/RC if present
const innerHost = host.querySelector('.mw-changeslist-line-inner, .mw-changeslist-line-main');
return innerHost || host;
}
/**
* Renders exactly two fixed-width emoji columns before a revision entry:
* - Left column: highest-priority addition indicator
* (warning/caution/inspect/added), or spacer if none.
* - Right column: removal indicator if removals exist, otherwise spacer.
*
* This preserves consistent alignment even when no links are added/removed.
*
* @param {HTMLElement} element - The revision list DOM element.
* @param {string|null} addType - Indicator type for the left column, or null.
* @param {boolean} hasRemovals - Whether to show the removal icon.
* @param {Object[]} added - Resolved added links ({url, domain, indicator, ...}).
* @param {Object[]} removed - Resolved removed links.
*/
function renderTwoColumnEmojis(element, addType, hasRemovals, added = [], removed = []) {
if (!element) return;
const hasPopupContent = added.length > 0 || removed.length > 0;
const host = gutterHostFor(element);
if (!host) return;
const makeSpan = (char, className) => {
const span = document.createElement('span');
span.className = className;
span.style.display = 'inline-block';
span.style.width = '1em';
span.style.textAlign = 'center';
span.style.userSelect = 'none';
span.textContent = char;
return span;
};
const addChar = addType ? indicators[addType].emoji : ' ';
const remChar = hasRemovals ? indicators.removed.emoji : ' ';
// Propagate dataset for view link from source element to host
try {
if (element.dataset.cwViewHref) host.dataset.cwViewHref = element.dataset.cwViewHref;
if (element.dataset.cwViewLabel) host.dataset.cwViewLabel = element.dataset.cwViewLabel;
} catch (e) {
/* ignore */
}
const lines = [];
if (added.length > 0) {
lines.push({
labelText: `${indicators.added.emoji} ${addedLabel}`,
labelTitle: indicators.added.explanation,
items: added
});
}
if (removed.length > 0) {
lines.push({
labelText: `${indicators.removed.emoji} ${removedLabel}`,
labelTitle: indicators.removed.explanation,
items: removed
});
}
const footerOptions = {
viewHref: host.dataset.cwViewHref || '',
viewLabel: host.dataset.cwViewLabel || ''
};
// Ensure a single gutter exists, then update/create popup
let gutter = host.querySelector('.cw-gutter');
if (gutter) {
gutter.querySelector('.cw-add').textContent = addChar;
gutter.querySelector('.cw-rem').textContent = remChar;
} else {
// Ensure the host can anchor absolutely positioned children
if (!host.style.position) host.style.position = 'relative';
// Create a left gutter that does not participate in text wrapping
gutter = document.createElement('span');
gutter.className = 'cw-gutter';
gutter.style.position = 'absolute';
gutter.style.left = '0';
gutter.style.top = '0';
gutter.style.display = 'inline-flex';
gutter.style.gap = '0.25em';
gutter.style.width = 'calc(2em + 0.25em)';
gutter.style.alignItems = 'flex-start';
gutter.style.userSelect = 'none';
gutter.appendChild(makeSpan(addChar, 'cw-add'));
gutter.appendChild(makeSpan(remChar, 'cw-rem'));
// Insert the gutter into the host; padding is deferred to flushGutterPadding()
host.insertBefore(gutter, host.firstChild);
pendingGutterPadding.push(host);
}
let popup = gutter.querySelector('.cw-popup');
if (hasPopupContent) {
if (!popup) {
popup = createPopupElement();
gutter.appendChild(popup);
}
buildPopupContent(popup, lines, footerOptions);
gutter._cwEnablePopup = true;
installPopupHandlers(gutter);
} else {
if (popup) popup.style.display = 'none';
gutter._cwEnablePopup = false;
}
host.setAttribute('data-processed-twocol', 'true');
}
/**
* Fills a popup with a close button, a two-column grid of labelled link
* lines, and a footer. Idempotent: safe to call again to refresh content.
*
* @param {HTMLElement} popupEl - The popup element.
* @param {Array<{labelText: string, labelTitle: string, items: Object[]}>} lines
* Each item is a resolved link ({url, domain, indicator, archiveHost}).
* @param {Object} footerOptions - See createPopupFooter.
*/
function buildPopupContent(popupEl, lines, footerOptions) {
if (!popupEl.querySelector('.cw-close')) {
popupEl.appendChild(createCloseButton(popupEl));
}
let contentWrap = popupEl.querySelector('.cw-popup-content');
if (!contentWrap) {
contentWrap = document.createElement('div');
contentWrap.className = 'cw-popup-content';
popupEl.appendChild(contentWrap);
}
// Grid layout so labels are flush-aligned with content
contentWrap.style.display = 'grid';
contentWrap.style.gridTemplateColumns = 'max-content 1fr';
contentWrap.style.columnGap = '0.5em';
contentWrap.style.rowGap = '2px';
contentWrap.innerHTML = '';
for (const line of lines) {
if (!line.items || line.items.length === 0) continue;
const label = document.createElement('strong');
label.title = line.labelTitle;
label.setAttribute('aria-label', `${line.labelText}: ${line.labelTitle}`);
label.textContent = `${line.labelText}:`;
label.style.alignSelf = 'start';
label.style.justifySelf = 'end';
label.style.textAlign = 'right';
label.style.whiteSpace = 'nowrap';
const content = document.createElement('div');
content.style.display = 'inline';
line.items.forEach((item, idx) => {
content.appendChild(makeLinkItem(item));
if (idx < line.items.length - 1) {
const sep = document.createElement('span');
sep.textContent = ' • ';
content.appendChild(sep);
}
});
contentWrap.appendChild(label);
contentWrap.appendChild(content);
}
const footer = popupEl.querySelector('.cw-popup-footer');
if (footer) footer.remove();
popupEl.appendChild(createPopupFooter(footerOptions));
}
/**
* Builds the popup representation of one resolved link: an optional
* indicator emoji followed by a link whose text is the screened domain.
*
* @param {Object} item - {url, domain, indicator, archiveHost}.
* @returns {HTMLSpanElement}
*/
function makeLinkItem(item) {
const span = document.createElement('span');
span.style.whiteSpace = 'nowrap';
span.style.display = 'inline-block';
if (item.indicator) {
const pre = document.createElement('span');
pre.className = 'cw-emoji';
pre.textContent = indicators[item.indicator].emoji + ' ';
pre.title = indicators[item.indicator].explanation;
pre.setAttribute('aria-label',
`${indicators[item.indicator].msg}: ${indicators[item.indicator].explanation}`);
span.appendChild(pre);
}
const a = makeExternalLink(item.url, item.domain);
if (item.archiveHost) {
a.title = `${archivedViaLabel} ${item.archiveHost}`;
}
span.appendChild(a);
return span;
}
/**
* Creates an external link element with the attributes every outbound link
* in this script uses. The single place an attacker-influenced URL becomes
* an href, so the http(s) check lives here.
*
* @param {string} url
* @param {string} text
* @returns {HTMLAnchorElement}
*/
function makeExternalLink(url, text) {
const a = document.createElement('a');
if (isValidHttpUrl(url)) a.href = url;
a.textContent = text;
a.rel = 'nofollow noopener noreferrer';
a.target = '_blank';
a.style.whiteSpace = 'nowrap';
return a;
}
/**
* Creates a styled popup element for displaying domain information.
*
* @returns {HTMLDivElement} The configured popup element.
*/
function createPopupElement() {
const popup = document.createElement('div');
popup.className = 'mwe-popups cw-popup';
popup.style.position = 'absolute';
popup.style.minWidth = '260px';
popup.style.maxWidth = '420px';
popup.style.zIndex = '1000';
popup.style.display = 'none';
popup.style.background = 'var(--background-color, #fff)';
popup.style.border = '1px solid rgba(0,0,0,.15)';
popup.style.boxShadow = '0 2px 8px rgba(0,0,0,.2)';
popup.style.padding = '8px 26px 8px 10px';
popup.style.borderRadius = '4px';
popup.style.fontSize = '0.92em';
return popup;
}
/**
* Creates a styled close button for popups.
*
* @param {HTMLElement} popupEl - The popup element to hide when the button is clicked.
* @returns {HTMLButtonElement} The configured close button.
*/
function createCloseButton(popupEl) {
const closeBtn = document.createElement('button');
closeBtn.className = 'cw-close';
closeBtn.setAttribute('type', 'button');
closeBtn.setAttribute('aria-label', 'Close');
closeBtn.title = 'Close';
closeBtn.textContent = '×';
closeBtn.style.position = 'absolute';
closeBtn.style.top = '4px';
closeBtn.style.right = '6px';
closeBtn.style.border = 'none';
closeBtn.style.background = 'transparent';
closeBtn.style.cursor = 'pointer';
closeBtn.style.fontSize = '14px';
closeBtn.style.lineHeight = '1';
closeBtn.style.padding = '0';
closeBtn.style.margin = '0';
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
popupEl.style.display = 'none';
});
return closeBtn;
}
/**
* Creates a popup footer element with an optional primary link and the
* "About Citation Watchlist" link.
*
* @param {Object} [options] - Optional configuration.
* @param {string} [options.viewHref] - URL for the primary link (e.g., diff or revision).
* @param {string} [options.viewLabel] - Label text for the primary link.
* @returns {HTMLDivElement} The configured footer element.
*/
function createPopupFooter(options) {
const footer = document.createElement('div');
footer.className = 'cw-popup-footer';
footer.style.marginTop = '10px';
footer.style.color = 'inherit';
const emWrap = document.createElement('span');
// Accept absolute http(s) URLs and same-origin paths; reject
// protocol-relative ("//host/...") and anything else.
if (options && options.viewHref && options.viewLabel &&
/^https?:\/\/|^\/(?!\/)/.test(options.viewHref)) {
const strong = document.createElement('strong');
const viewA = document.createElement('a');
viewA.href = options.viewHref;
viewA.textContent = options.viewLabel;
viewA.target = '_blank';
viewA.rel = 'nofollow noopener noreferrer';
strong.appendChild(viewA);
emWrap.appendChild(strong);
const sep = document.createElement('span');
sep.textContent = ' • ';
emWrap.appendChild(sep);
}
const linkCW = document.createElement('a');
linkCW.href = aboutLink;
linkCW.textContent = aboutLabel;
linkCW.target = '_blank';
linkCW.rel = 'nofollow noopener noreferrer';
emWrap.appendChild(linkCW);
footer.appendChild(emWrap);
return footer;
}
/**
* Builds the shared filter widget container with radio buttons and an about link.
*
* @param {Object} config - Widget configuration.
* @param {string} config.containerId - The ID for the container element.
* @param {string} config.radioName - The name attribute for the radio button group.
* @param {Array<Object>} config.filters - Array of filter option objects
* ({id, label, checked?, tooltip?}).
* @param {Function} config.onFilter - Callback invoked with the selected filter ID.
* @param {Function} [config.initRadio] - Optional callback to customize each radio
* after creation, receiving (radio, filter, wrapper).
* @returns {HTMLDivElement} The configured filter widget container.
*/
function buildFilterWidgetContainer(config) {
const container = document.createElement('div');
container.id = config.containerId;
container.style.display = 'flex';
container.style.alignItems = 'center';
container.style.justifyContent = 'space-between';
container.style.border = '1px solid black';
container.style.borderRadius = '25px';
container.style.padding = '10px 20px';
container.style.margin = '10px 0';
container.style.width = 'fit-content';
const leftSection = document.createElement('div');
leftSection.style.display = 'flex';
leftSection.style.alignItems = 'center';
leftSection.style.gap = '15px';
const label = document.createElement('span');
label.textContent = config.initialLabel || showEditsLabel;
label.style.fontWeight = '500';
label.classList.add('cw-filter-label');
leftSection.appendChild(label);
config.filters.forEach(filter => {
const wrapper = document.createElement('label');
wrapper.style.display = 'flex';
wrapper.style.alignItems = 'center';
wrapper.style.gap = '5px';
wrapper.style.cursor = 'pointer';
const radio = document.createElement('input');
radio.type = 'radio';
radio.name = config.radioName;
radio.value = filter.id;
radio.checked = !!filter.checked;
radio.style.margin = '0';
if (config.initRadio) {
config.initRadio(radio, filter, wrapper);
}
const text = document.createElement('span');
text.textContent = filter.label;
if (filter.tooltip) {
text.title = filter.tooltip;
text.style.borderBottom = '1px dotted';
text.style.cursor = 'help';
}
wrapper.appendChild(radio);
wrapper.appendChild(text);
leftSection.appendChild(wrapper);
radio.addEventListener('change', () => {
if (radio.checked) {
config.onFilter(filter.id);
}
});
});
container.appendChild(leftSection);
const rightSection = document.createElement('div');
rightSection.style.marginLeft = '4em';
const link = document.createElement('a');
link.href = aboutLink;
link.textContent = toolName;
link.style.textDecoration = 'underline';
link.style.color = '#0645ad';
rightSection.appendChild(link);
container.appendChild(rightSection);
return container;
}
/**
* Builds the indicator filter list from the indicators config, sorted by
* descending priority and excluding non-positive priorities.
*
* @returns {Array<{id: string, label: string, tooltip: string}>}
*/
function buildIndicatorFilters() {
return listedTypes
.slice()
.sort((a, b) => indicators[b].priority - indicators[a].priority)
.map(id => ({
id,
label: `${indicators[id].emoji} ${indicators[id].msg}`,
tooltip: indicators[id].explanation
}));
}
/**
* Enables or greys out each radio in a filter widget according to whether
* any row currently matches it.
*
* @param {string} radioName - The radio group name.
* @param {Function} hasResults - (filterId) => boolean.
*/
function updateFilterRadios(radioName, hasResults) {
document.querySelectorAll(`input[name="${radioName}"]`).forEach(radio => {
const enabled = radio.value === 'all' || hasResults(radio.value);
const label = radio.closest('label');
radio.disabled = !enabled;
if (label) {
label.style.cursor = enabled ? 'pointer' : 'default';
label.style.color = enabled ? '' : 'gray';
}
});
}
/**
* Creates and inserts the change-list filter widget (watchlist, recent
* changes, history, contributions). Radios start disabled and are enabled by
* finishChangeListPass() once rows are annotated.
*
* @returns {HTMLElement|null} The created filter container or null if not created.
*/
function createChangeListFilterWidget() {
const ns = mw.config.get('wgNamespaceNumber');
const pageName = mw.config.get('wgPageName');
let target = document.querySelector('.mw-changeslist') ||
document.getElementById('mw-history-compare');
if (!target && ns === -1 && pageName.startsWith('Special:Contributions')) {
target = document.querySelector('.mw-pager-body');
}
if (!target) return null;
const container = buildFilterWidgetContainer({
containerId: 'cw-filter-widget',
radioName: 'cw-filter',
filters: [
{ id: 'all', label: allLabel, checked: true },
{ id: 'linkchange', label: linkChangesLabel, tooltip: linkChangesTooltip },
...buildIndicatorFilters()
],
onFilter: applyChangeListFilter,
initialLabel: loadingLabel,
initRadio: (radio) => {
radio.disabled = true; // Disabled until annotation is complete
}
});
target.parentNode.insertBefore(container, target);
return container;
}
/**
* Creates and inserts a filter widget for the article surface, placed after
* the section heading. Excludes the "Link Changes" option.
*
* @param {Element} headingContainer - The heading element to insert the widget after.
*/
function createArticleFilterWidget(headingContainer) {
const container = buildFilterWidgetContainer({
containerId: 'cw-article-filter-widget',
radioName: 'cw-article-filter',
filters: [
{ id: 'all', label: allLabel, checked: true },
...buildIndicatorFilters()
],
onFilter: applyArticleFilter
});
headingContainer.after(container);
updateFilterRadios('cw-article-filter', (filterId) =>
!!document.querySelector(`li[id^="cite_note"][data-cw-indicator="${filterId}"]`));
renderLoadWarnings();
}
/**
* Called after each annotation pass on a change list: restores the widget
* label, renders any load warnings, and enables the radios that have matches.
*/
function finishChangeListPass() {
const filterLabel = document.querySelector('#cw-filter-widget .cw-filter-label');
if (filterLabel && filterLabel.firstChild &&
filterLabel.firstChild.nodeType === Node.TEXT_NODE) {
filterLabel.firstChild.textContent = showEditsLabel;
}
renderLoadWarnings();
const rows = document.querySelectorAll('[data-mw-revid]:not(.mw-userlink)');
updateFilterRadios('cw-filter', (filterId) =>
Array.from(rows).some(row => rowMatchesFilter(row, filterId)));
// Re-apply the active filter so newly loaded rows respect it
const active = document.querySelector('input[name="cw-filter"]:checked');
if (active && active.value !== 'all') applyChangeListFilter(active.value);
}
/**
* Whether a change-list row (or any nested revision inside it) matches a filter.
*
* @param {Element} row
* @param {string} filterId
* @returns {boolean}
*/
function rowMatchesFilter(row, filterId) {
if (elementMatchesFilter(row, filterId)) return true;
for (const nested of row.querySelectorAll('li[data-cw-processed="true"]')) {
if (elementMatchesFilter(nested, filterId)) return true;
}
return false;
}
/**
* Applies the selected filter to article section list items.
*
* @param {string} filterId - 'all' or an indicator type.
*/
function applyArticleFilter(filterId) {
document.querySelectorAll('li[id^="cite_note"]').forEach(li => {
li.style.display = (filterId === 'all' || li.dataset.cwIndicator === filterId) ? '' : 'none';
});
}
/**
* Applies the selected filter to the revision list.
*
* @param {string} filterId - 'all', 'linkchange', or an indicator type.
*/
let lastAppliedFilter = 'all';
function applyChangeListFilter(filterId) {
const isAll = filterId === 'all';
const revisions = document.querySelectorAll(
'[data-mw-revid]:not(.mw-userlink), [data-mw-logid]'
);
revisions.forEach(container => {
if (isAll) {
container.style.display = '';
return;
}
// Log entries (data-mw-logid) don't have revision-based attributes to
// match against filters — hide them when any specific filter is active.
const show = container.hasAttribute('data-mw-revid') && rowMatchesFilter(container, filterId);
container.style.display = show ? '' : 'none';
});
// Hide h4 date headers when all their rows are hidden
document.querySelectorAll('div.mw-changeslist h4').forEach(h4 => {
if (isAll) {
h4.style.display = '';
return;
}
const sibling = h4.nextElementSibling;
if (!sibling || sibling.tagName !== 'DIV') return;
const revItems = sibling.querySelectorAll('[data-mw-revid]');
if (revItems.length === 0) return;
h4.style.display = Array.from(revItems).every(el => el.style.display === 'none') ? 'none' : '';
});
const enhanced = document.querySelector('table.mw-enhanced-rc');
if (enhanced) {
// Hide the group header row in each tbody if all other rows are hidden
document.querySelectorAll('table.mw-enhanced-rc tbody').forEach(tbody => {
const rows = tbody.querySelectorAll('tr');
if (rows.length < 2) return;
rows[0].style.display = (!isAll && Array.from(rows).slice(1)
.every(tr => tr.style.display === 'none')) ? 'none' : '';
});
// Expand grouped sections while a specific filter is active, collapse on
// "all". Only on the transition, so switching between two filters leaves
// the expanded state alone.
const wasAll = lastAppliedFilter === 'all';
if (wasAll !== isAll) {
document.querySelectorAll('table.mw-enhanced-rc').forEach(table => {
const checkbox = groupToggleCheckbox(table);
if (checkbox && checkbox.checked === isAll) {
checkbox.checked = !isAll;
checkbox.dispatchEvent(new Event('change'));
}
});
}
}
showNoMatchingEdits(!isAll && Array.from(
document.querySelectorAll('[data-mw-revid]:not(.mw-userlink)')
).every(el => el.style.display === 'none'));
lastAppliedFilter = filterId;
}
/**
* Shows or removes the "No matching edits" message under the filter widget.
*
* @param {boolean} show
*/
function showNoMatchingEdits(show) {
const existing = document.getElementById('cw-no-matching-edits');
if (existing) existing.remove();
if (!show) return;
const msg = document.createElement('p');
msg.id = 'cw-no-matching-edits';
msg.textContent = noMatchingEditsLabel;
msg.style.margin = '10px 0';
msg.style.fontStyle = 'italic';
const widget = document.getElementById('cw-filter-widget');
if (widget && widget.nextSibling) {
widget.parentNode.insertBefore(msg, widget.nextSibling);
}
}
/**
* Returns the hidden checkbox that expands/collapses an enhanced-RC group.
*
* @param {Element} table - A table.mw-enhanced-rc element.
* @returns {HTMLInputElement|null}
*/
function groupToggleCheckbox(table) {
const arrow = table.querySelector('.mw-enhancedchanges-arrow');
const checkboxId = arrow && arrow.getAttribute('for');
return checkboxId ? document.getElementById(checkboxId) : null;
}
/**
* If any configuration pages failed to load, appends a warning glyph (with
* the details in its tooltip) to every filter-widget label on the page.
* Safe to call more than once.
*/
function renderLoadWarnings() {
const messages = SHOW_LINT_IN_WIDGET ? [...loadWarnings, ...listLint] : loadWarnings;
if (messages.length === 0) return;
document.querySelectorAll('.cw-filter-label').forEach(label => {
if (label.querySelector('.cw-load-warning')) return;
const warn = document.createElement('span');
warn.className = 'cw-load-warning';
warn.textContent = ' ' + loadWarningGlyph;
warn.title = loadWarningPrefix + '\n' + messages.join('\n');
warn.setAttribute('aria-label', warn.title);
warn.style.cursor = 'help';
label.appendChild(warn);
});
}
/**
* Whether the single document-level outside-click handler has been installed.
* One delegated listener replaces the former one-listener-per-gutter design.
*/
let outsideClickHandlerInstalled = false;
/**
* Installs a single document-level click handler that hides every open popup
* whose owning gutter does not contain the click target.
*/
function installOutsideClickHandler() {
if (outsideClickHandlerInstalled) return;
document.addEventListener('click', (ev) => {
document.querySelectorAll('.cw-popup').forEach(p => {
if (p.style.display === 'none' || p.style.display === '') return;
const owner = p.parentElement;
if (owner && !owner.contains(ev.target)) {
p.style.display = 'none';
}
});
});
outsideClickHandlerInstalled = true;
}
/**
* Installs hover, click, touch, and outside-click handlers on a gutter element
* to show/hide its popup.
*
* @param {HTMLElement} gutter - The gutter element containing a .cw-popup child.
*/
function installPopupHandlers(gutter) {
if (gutter._cwHandlersInstalled) return;
let hideTimer = null;
const positionPopup = () => {
const p = gutter.querySelector('.cw-popup');
if (!gutter._cwEnablePopup || !p) return;
p.style.display = 'block';
p.style.top = '1.4em';
p.style.left = '0px';
const rect = p.getBoundingClientRect();
if (rect.right > window.innerWidth - 10) {
const overflow = rect.right - (window.innerWidth - 10);
p.style.left = Math.max(0, (parseFloat(p.style.left) || 0) - overflow) + 'px';
}
};
const show = () => {
if (!gutter._cwEnablePopup) return;
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
positionPopup();
};
const scheduleHide = () => {
hideTimer = setTimeout(() => {
const p = gutter.querySelector('.cw-popup');
if (p) p.style.display = 'none';
}, 150);
};
gutter.addEventListener('mouseenter', show);
gutter.addEventListener('mouseleave', scheduleHide);
const pInit = gutter.querySelector('.cw-popup');
if (pInit) {
pInit.addEventListener('mouseenter', () => {
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
});
pInit.addEventListener('mouseleave', scheduleHide);
}
const toggle = (e) => {
if (!gutter._cwEnablePopup) return;
e.stopPropagation();
const p = gutter.querySelector('.cw-popup');
if (!p) return;
if (p.style.display === 'none' || p.style.display === '') {
show();
} else {
p.style.display = 'none';
}
};
gutter.addEventListener('click', toggle);
gutter.addEventListener('touchstart', toggle, {
passive: true
});
installOutsideClickHandler();
gutter._cwHandlersInstalled = true;
}
/**
* Watches for the change list being replaced by MediaWiki's live filters
* ("View new changes", filter edits on Special:Watchlist / RecentChanges)
* and annotates the new rows. The existing widget sits outside the replaced
* element and survives; its radios are refreshed after each pass.
*/
function installLiveReloadObserver() {
if (typeof MutationObserver === 'undefined') return;
let timer = null;
let running = false;
let rerun = false;
const run = async () => {
if (running) {
rerun = true;
return;
}
running = true;
try {
await annotateChangeList(document, false);
} catch (e) {
console.warn('Citation Watchlist: live reload pass failed:', e);
} finally {
running = false;
if (rerun) {
rerun = false;
run();
}
}
};
const observer = new MutationObserver(mutations => {
const relevant = mutations.some(m => Array.from(m.addedNodes).some(node =>
node.nodeType === Node.ELEMENT_NODE &&
(node.matches('[data-mw-revid], .mw-changeslist, .mw-changeslist-line') ||
node.querySelector('[data-mw-revid], .mw-changeslist-line'))
));
if (!relevant) return;
clearTimeout(timer);
timer = setTimeout(run, LIVE_RELOAD_DEBOUNCE_MS);
});
observer.observe(document.body, { childList: true, subtree: true });
}
const enabledNsStrings = new Set(enabledNamespaces.filter(n => n >= 0).map(String));
const namespaces = Object.entries(mw.config.get('wgFormattedNamespaces'))
.filter(([num, name]) => num !== '0' && !enabledNsStrings.has(num))
.map(([_, name]) => name.replace(/ /g, '_') + ':');
/**
* Pre-pads an element with empty emoji gutters to prevent layout shift.
*
* @param {Element} element - The DOM element to pre-pad.
*/
function prePadElement(element) {
try {
renderTwoColumnEmojis(element, null, false, [], []);
} catch (e) {
/* ignore */
}
}
/**
* ==========================================================================
* 3. Builders and Extractors
* ==========================================================================
*/
/**
* Constructs a URL for viewing a diff between two revisions.
*
* @param {string} title - The page title.
* @param {string} fromId - The older revision ID.
* @param {string} toId - The newer revision ID.
* @returns {string} The diff URL, or empty string on error.
*/
function buildDiffUrl(title, fromId, toId) {
try {
const script = mw.config.get('wgScript') || '/w/index.php';
return `${script}` +
`?title=${encodeURIComponent(title)}` +
`&diff=${encodeURIComponent(toId)}` +
`&oldid=${encodeURIComponent(fromId)}`;
} catch (e) {
return '';
}
}
/**
* Constructs a permalink URL for a specific revision.
*
* @param {string} revId - The revision ID.
* @returns {string} The permalink URL, or empty string on error.
*/
function buildPermalinkUrl(revId) {
try {
const articlePath = mw.config.get('wgArticlePath');
if (articlePath && articlePath.includes('$1')) {
return articlePath.replace('$1',
`Special:Permalink/${encodeURIComponent(revId)}`);
}
const script = mw.config.get('wgScript') || '/w/index.php';
return `${script}?oldid=${encodeURIComponent(revId)}`;
} catch (e) {
return '';
}
}
/**
* Strips all HTML comments from a string.
*
* @param {string} text - Input text potentially containing HTML comments.
* @returns {string} Text with all HTML comments removed.
*/
function stripHTMLComments(text) {
if (!text) return text;
return text.replace(/<!--[\s\S]*?-->/g, '');
}
/**
* Extracts all HTTP(S) URLs from a given wikitext string. The scheme is
* matched case-insensitively (MediaWiki links HTTPS:// just as it does
* https://), and protocol-relative links ("[//example.com ...]") are
* normalized to https://.
*
* @param {string} wikitext - Raw wikitext revision content.
* @returns {string[]} List of valid extracted URLs.
*/
function extractURLs(wikitext) {
const urls = [];
if (!wikitext) return urls;
const urlRegex = // eslint-disable-next-line max-len
/(?:https?:\/\/|(?<=^|[\s\[|=<>"'])\/\/)(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)/gi;
let match;
while ((match = urlRegex.exec(wikitext)) !== null) {
const raw = match[0].startsWith('//') ? 'https:' + match[0] : match[0];
try {
const url = new URL(raw);
if (/^https?:$/.test(url.protocol)) urls.push(url.href);
} catch (error) {
console.error(`Invalid URL rejected: ${match[0]}`, error.message);
}
}
return urls;
}
/**
* Web archive services whose URLs embed the original URL. Each entry's
* `extract` returns the embedded URL string or null.
*/
const archiveUnwrappers = [
{
// https://web.archive.org/web/20200101000000/https://example.com/page
// (the embedded scheme is sometimes collapsed to "https:/")
test: h => h === 'web.archive.org' || h === 'wayback.archive.org',
extract: u => {
const m = u.pathname.match(/^\/web\/\d{1,14}[a-z_]*\/(https?:\/{1,2}.+)$/i);
return m ? m[1].replace(/^(https?:)\/+/i, '$1//') : null;
}
},
{
// https://archive dot ph/2020.01.01-000000/https://example.com/page
test: h => /^archive\.(ph|today|is|li|vn|fo|md)$/i.test(h),
extract: u => {
const m = u.pathname.match(/^\/[^/]+\/(https?:\/{1,2}.+)$/i);
return m ? m[1].replace(/^(https?:)\/+/i, '$1//') : null;
}
},
{
// https://webcache.googleusercontent.com/search?q=cache:https://example.com/page
test: h => h === 'webcache.googleusercontent.com',
extract: u => {
const q = u.searchParams.get('q') || '';
const m = q.match(/^cache:(?:[A-Za-z0-9_-]+:)?(.+)$/);
if (!m) return null;
return /^https?:\/\//i.test(m[1]) ? m[1] : 'https://' + m[1];
}
}
];
/**
* Resolves a URL to the hostname that should be screened. If the URL is a
* web-archive capture of another page, the captured page's host is used.
*
* @param {string} url
* @returns {{hostname: string, archiveHost: string|null}|null} Null if unparsable.
*/
function screenedHost(url) {
let current;
try {
current = new URL(url);
} catch (e) {
return null;
}
let archiveHost = null;
for (let depth = 0; depth < 3 && UNWRAP_ARCHIVES; depth++) {
const unwrapper = archiveUnwrappers.find(a => a.test(current.hostname));
if (!unwrapper) break;
const inner = unwrapper.extract(current);
if (!inner) break;
try {
const innerUrl = new URL(inner);
if (!/^https?:$/.test(innerUrl.protocol)) break;
archiveHost = archiveHost || current.hostname;
current = innerUrl;
} catch (e) {
break;
}
}
return { hostname: current.hostname, archiveHost };
}
/**
* Resolves URLs to screened domains and list indicators.
*
* @param {string[]} urls
* @returns {Array<{url: string, domain: string, indicator: string|null, archiveHost: string|null}>}
*/
function resolveURLs(urls) {
const out = [];
for (const url of urls || []) {
const host = screenedHost(url);
if (!host) continue;
const domain = getRootDomain(host.hostname, publicSuffixSet);
if (!domain) continue;
out.push({ url, domain, indicator: highestIndicatorFor(domain), archiveHost: host.archiveHost });
}
return out;
}
/**
* Returns the highest-priority list indicator that contains a domain.
*
* @param {string} domain
* @returns {string|null}
*/
function highestIndicatorFor(domain) {
let chosen = null;
for (const type of listedTypes) {
if (indicators[type].list.has(domain) &&
(!chosen || indicators[type].priority > indicators[chosen].priority)) {
chosen = type;
}
}
return chosen;
}
/**
* Returns the highest-priority type among a list of indicator types.
*
* @param {Array<string|null>} types
* @returns {string|null}
*/
function highestPriority(types) {
let chosen = null;
for (const t of types) {
if (t && (!chosen || indicators[t].priority > indicators[chosen].priority)) chosen = t;
}
return chosen;
}
/**
* Extracts the top-level domain from a full hostname using a public suffix set.
*
* @param {string} hostname - Full hostname (e.g., sub.example.co.uk).
* @param {Set<string>} publicSuffixSet - Set of known public suffixes.
* @returns {string} The top-level domain (e.g., example.co.uk).
*/
function getRootDomain(hostname, publicSuffixSet) {
// Handle empty or invalid hostnames
if (!hostname || typeof hostname !== 'string') {
console.warn('Invalid hostname provided to getRootDomain:', hostname);
return '';
}
// Check if this is an IP address (simple check for IPv4)
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(hostname)) {
return hostname; // Return IP addresses as-is
}
const domainParts = hostname.split('.');
// Handle hostnames that are too short
if (domainParts.length < 2) {
return hostname; // Return as-is if it's a single-part hostname
}
// Try to find a matching public suffix
for (let i = 0; i < domainParts.length; i++) {
const candidate = domainParts.slice(i).join('.');
// Check both normal and exception (prefixed with !) entries
if (publicSuffixSet.has(candidate) || publicSuffixSet.has(`!${candidate}`)) {
// If we found a match, return the domain part plus the public suffix
// But make sure we don't go out of bounds
if (i > 0) {
return domainParts.slice(i - 1).join('.');
} else {
// Edge case: the entire hostname is a public suffix
return hostname;
}
}
}
// If no match in public suffix list, use a simple fallback:
// For hostnames with 2 parts, return the whole thing
// For hostnames with >2 parts, return the last 2 parts
if (domainParts.length === 2) {
return hostname;
} else {
return domainParts.slice(-2).join('.');
}
}
/**
* Extracts the first page object from MediaWiki API query response.
*
* @param {Object} data - MediaWiki API response.
* @returns {Object|null} The first page object or null if unavailable.
*/
function getFirstPage(data) {
if (!data || !data.query || !data.query.pages) return null;
return Object.values(data.query.pages)[0] || null;
}
/**
* Iterates every revision object in an API response, across all pages.
* (A revids= query spanning several articles returns several page objects.)
*
* @param {Object|null} data - MediaWiki API response.
* @returns {Object[]} Revision objects.
*/
function allRevisionsIn(data) {
if (!data || !data.query || !data.query.pages) return [];
const out = [];
for (const page of Object.values(data.query.pages)) {
if (page && Array.isArray(page.revisions)) out.push(...page.revisions);
}
return out;
}
/**
* Returns the main-slot wikitext of a revision object, or null if the
* revision is missing, hidden, or malformed.
*
* @param {Object} rev - Revision object from the API.
* @returns {string|null}
*/
function revisionText(rev) {
if (!rev || rev.texthidden !== undefined) return null;
const slot = rev.slots && rev.slots.main;
if (!slot || typeof slot['*'] !== 'string') return null;
return slot['*'];
}
/**
* Returns true if an API page object represents a page that cannot be
* read (missing, invalid, or without an accessible revision).
*
* @param {Object} page - Page object from the API.
* @returns {boolean}
*/
function isUnreadablePage(page) {
return !page || page.missing !== undefined || page.invalid !== undefined ||
!Array.isArray(page.revisions) || page.revisions.length === 0;
}
/**
* ==========================================================================
* 4. Validation
* ==========================================================================
*/
/**
* Validates that a string is a well-formed domain name.
*
* @param {string} str - The string to validate.
* @returns {boolean} True if the string is a valid domain.
*/
function isValidDomain(str) {
if (!str || str.length === 0 || str.length > 253) return false;
if (!/^[a-zA-Z0-9._-]+$/.test(str)) return false;
if (!str.includes('.')) return false;
if (/\.\./.test(str) || str.startsWith('.') || str.endsWith('.')) return false;
const labels = str.split('.');
if (labels.some(l => l.length === 0 || l.length > 63)) return false;
return true;
}
/**
* Validates that a string is a well-formed public suffix list entry.
* Allows wildcard entries (e.g., *.ck) and exception entries (e.g., !www.ck).
*
* @param {string} str - The string to validate.
* @returns {boolean} True if the string is a valid PSL entry.
*/
function isValidSuffixEntry(str) {
const cleaned = str.replace(/^[!*.]/, '');
return /^[a-zA-Z0-9._-]+$/.test(cleaned) && cleaned.length > 0 && cleaned.length <= 253;
}
/**
* Validates that a value is a string parseable as an absolute http(s) URL.
* Used to re-validate anything read back from localStorage before it is
* assigned to an href.
*
* @param {*} u - The value to validate.
* @returns {boolean} True if the value is an http(s) URL.
*/
function isValidHttpUrl(u) {
if (typeof u !== 'string') return false;
try {
return /^https?:$/i.test(new URL(u).protocol);
} catch (e) {
return false;
}
}
/**
* Validates that a value is a plausible revision ID (a positive integer
* string). Used before an ID is put into an API request or a cache key.
*
* @param {*} id - The value to validate.
* @returns {boolean}
*/
function isRevisionId(id) {
return typeof id === 'string' && /^[1-9][0-9]{0,15}$/.test(id);
}
/**
* Validates that a string is a plausible MediaWiki page title.
* Rejects titles with invalid characters, excessive length, or suspicious patterns.
*
* @param {string} title - The page title to validate.
* @returns {boolean} True if the title looks like a valid page link.
*/
function isValidPageTitle(title) {
if (typeof title !== 'string') return false;
if (title.length === 0 || title.length > 255) return false;
if (/[\[\]{}|#<>\n\r\t]/.test(title)) return false;
if (title.includes('..')) return false;
if (title.startsWith(':') || title.startsWith(' ') || title.endsWith(' ')) return false;
return true;
}
/**
* Determines whether a given page title does *not* belong to the main or draft namespaces.
* A missing title is treated as "not an article" so the row is skipped rather
* than crashing the run.
*
* @param {string} pageTitle - The title of the page.
* @returns {boolean} True if not an article namespace.
*/
function isNotArticle(pageTitle) {
if (typeof pageTitle !== 'string') return true;
return namespaces.some(namespace => pageTitle.startsWith(namespace));
}
/**
* Checks whether an element matches the specified filter.
*
* @param {Element} element - The DOM element to test.
* @param {string} filterId - The active filter ID.
* @returns {boolean} True if the element matches the filter.
*/
function elementMatchesFilter(element, filterId) {
if (filterId === 'linkchange') {
return element.hasAttribute('data-cw-linkchange') ||
listedTypes.some(type => element.hasAttribute(indicators[type].dataAttr));
}
if (indicators[filterId] && indicators[filterId].dataAttr) {
return element.hasAttribute(indicators[filterId].dataAttr);
}
return false;
}
/**
* ==========================================================================
* 5. Fetch
* ==========================================================================
*/
/**
* Fetches wikitext for a chunk of revision IDs in one request.
*
* @param {string[]} revIds - Revision IDs (at most CONTENT_CHUNK_SIZE).
* @returns {Promise<Map<string, string|null>|null>} Map of id -> wikitext
* (null for a revision that exists but is unreadable), or null if the
* request itself failed. IDs the API did not return are absent.
*/
async function fetchRevisionTexts(revIds) {
const data = await fetchRevisionData({
revids: revIds,
rvprop: ['ids', 'content'],
rvslots: ['main']
});
if (!data) return null;
const out = new Map();
for (const rev of allRevisionsIn(data)) {
if (rev && rev.revid !== undefined) {
out.set(String(rev.revid), revisionText(rev));
}
}
return out;
}
/**
* Fetches parent IDs and sizes for a list of revision IDs, chunked at the
* API maximum and fetched concurrently.
*
* @param {string[]} revisionIds - Revision IDs.
* @returns {Promise<Object>} Map of id -> {parentid, size}; IDs that could
* not be resolved are absent.
*/
async function fetchRevisionMetadata(revisionIds) {
const result = {};
const chunks = [];
for (let i = 0; i < revisionIds.length; i += METADATA_CHUNK_SIZE) {
chunks.push(revisionIds.slice(i, i + METADATA_CHUNK_SIZE));
}
await runPool(chunks.map(chunk => async () => {
const data = await fetchRevisionData({
revids: chunk,
rvprop: ['ids', 'size']
});
for (const rev of allRevisionsIn(data)) {
if (rev && rev.revid !== undefined) {
result[String(rev.revid)] = {
parentid: rev.parentid || 0,
size: typeof rev.size === 'number' ? rev.size : undefined
};
}
}
}), CONTENT_CONCURRENCY);
return result;
}
/**
* Fetches the ID of the first revision of a page.
*
* @param {string} pageTitle - The page title to look up.
* @returns {number|null} Revision ID or null.
*/
async function fetchFirstRevisionId(pageTitle) {
if (!isValidPageTitle(pageTitle)) return null;
const page = getFirstPage(await fetchRevisionData({
titles: [pageTitle],
rvlimit: 1,
rvdir: 'newer',
rvprop: ['ids'],
}));
if (isUnreadablePage(page)) return null;
return page.revisions[0].revid || null;
}
/**
* Fetches the list of subpages from the list of lists, parses wikilinks, caches
* the result, and returns list of subpage titles.
*
* @param {string} pageName - Title of the list-of-lists page.
* @returns {Promise<string[]>} List of subpage titles (empty on failure).
*/
async function fetchDomainListPages(pageName) {
const cacheKey = `citationWatchlistFetchDomainListPages_${pageName}`;
const cachedContent = readCache(cacheKey, getCacheTTL('citationWatchlistFetchDomainListPages_'));
if (Array.isArray(cachedContent) && cachedContent.length > 0) {
console.log('Loaded list of lists from cache');
return cachedContent.filter(title => isValidPageTitle(title));
}
const page = getFirstPage(await fetchRevisionData({
titles: [pageName],
rvprop: ['content'],
rvslots: ['main']
}));
if (isUnreadablePage(page)) {
console.error(`Citation Watchlist: list of lists "${pageName}" could not be loaded`);
return [];
}
const content = revisionText(page.revisions[0]);
if (content === null) return [];
const pageTitles = [];
for (const line of content.split('\n')) {
if (!line.startsWith('* [[')) continue;
const match = line.match(/\[\[([^\]]+)\]\]/); // first [[Page Title]]
if (!match) continue;
const title = match[1];
if (isValidPageTitle(title)) {
pageTitles.push(title);
} else {
console.warn(`Rejected invalid page title: "${title}"`);
}
}
if (pageTitles.length > 0) {
writeCache(cacheKey, pageTitles);
console.log('Loaded from API and stored in cache');
}
return pageTitles;
}
/**
* Parses one domain list page into per-indicator domain arrays.
* Entries that are not bare domains are collected as lint. A leading "www."
* is stripped (with a lint note), since root domains never carry it.
*
* @param {string} title - Page title, for lint messages.
* @param {string} content - Page wikitext.
* @returns {Object} {warning: string[], caution: string[], inspect: string[], lint: string[]}
*/
function parseDomainList(title, content) {
const parsed = { lint: [] };
for (const type of listedTypes) parsed[type] = [];
let current = null;
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (/^==/.test(trimmed)) {
// Any heading ends the current section; only recognized ones open a new one.
current = listedTypes.find(t => indicators[t].section === trimmed) || null;
continue;
}
if (!line.startsWith('*') || !current) continue;
let domain = line.substring(1).trim().toLowerCase();
if (!domain) continue;
if (/^www\./.test(domain) && isValidDomain(domain.slice(4))) {
parsed.lint.push(`${title}: "${domain}" has a www. prefix; matched as "${domain.slice(4)}"`);
domain = domain.slice(4);
}
if (isValidDomain(domain)) {
if (parsed[current].length < MAX_DOMAINS_PER_LIST) {
parsed[current].push(domain);
} else {
parsed.lint.push(`${title}: cap of ${MAX_DOMAINS_PER_LIST} reached in ${current}`);
}
} else {
parsed.lint.push(`${title}: "${domain}" is not a bare domain (paths, wildcards and ` +
`bare TLDs are not supported) and was ignored`);
}
}
return parsed;
}
/**
* Loads domain lists from a set of pages (parsed form cached per page) and
* populates the `Set` of each indicator. Pages that are missing or fail to
* load are skipped and recorded in loadWarnings; the remaining lists still load.
*
* @param {string[]} pageNames - List of page titles to fetch.
*/
async function fetchAndOrganizeDomainLists(pageNames) {
const cacheTTL = getCacheTTL('domainList:');
const parsedLists = [];
const pagesToFetch = [];
const isParsed = p => p && typeof p === 'object' && listedTypes.every(t => Array.isArray(p[t]));
for (const title of pageNames) {
const cached = readCache(`domainList:${location.hostname}:${title}`, cacheTTL);
if (isParsed(cached)) {
parsedLists.push(cached);
} else {
pagesToFetch.push(title);
}
}
if (pagesToFetch.length > 0) {
const apiData = await fetchRevisionData({
titles: pagesToFetch,
rvprop: ['content'],
rvslots: ['main'],
});
const pages = (apiData && apiData.query && apiData.query.pages) || null;
if (!pages) {
loadWarnings.push(`API request for ${pagesToFetch.length} list page(s) failed`);
console.error('Citation Watchlist: domain list fetch failed for', pagesToFetch);
} else {
// The API normalizes titles (underscores -> spaces); cache under the
// requested spelling so the next load hits.
const requestedFor = {};
for (const t of pagesToFetch) requestedFor[t.replace(/_/g, ' ')] = t;
for (const page of Object.values(pages)) {
const title = page.title || '?';
if (isUnreadablePage(page)) {
loadWarnings.push(`List page "${title}" is missing or unreadable`);
console.warn(`Citation Watchlist: skipping list page "${title}"`);
continue;
}
const content = revisionText(page.revisions[0]);
if (content === null) {
loadWarnings.push(`List page "${title}" has no readable content`);
continue;
}
const parsed = parseDomainList(title, content);
parsedLists.push(parsed);
const cacheTitle = requestedFor[title] || title;
writeCache(`domainList:${location.hostname}:${cacheTitle}`, parsed);
}
}
}
for (const parsed of parsedLists) {
for (const type of listedTypes) {
for (const domain of parsed[type]) {
if (isValidDomain(domain)) indicators[type].list.add(domain);
}
}
if (Array.isArray(parsed.lint)) {
listLint.push(...parsed.lint.filter(l => typeof l === 'string'));
}
}
}
/**
* Fetches and caches the public suffix list used to identify top-level domains.
* The cache holds the validated entries, so cached loads skip parsing.
*
* @returns {Promise<Set<string>>} Set of public suffixes.
*/
async function fetchPublicSuffixList() {
const cacheKey = 'publicSuffixListCache';
const cached = readCache(cacheKey, getCacheTTL('publicSuffixListCache'));
if (Array.isArray(cached) && cached.length >= MIN_PSL_ENTRIES) {
console.log('Using cached public suffix list');
return new Set(cached.filter(e => typeof e === 'string' && isValidSuffixEntry(e)));
}
const pslUrl = mw.config.get('wgArticlePath').replace('$1', publicSuffixList) +
'?action=raw';
console.log(`Raw page text request: ${pslUrl}`);
const content = await safeFetch(fetch, pslUrl).then(response => response ?
response.text() : null);
if (!content) return new Set();
const entries = [];
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith('//') && isValidSuffixEntry(trimmed)) {
entries.push(trimmed);
}
}
if (entries.length < MIN_PSL_ENTRIES) {
console.error(`Public suffix list suspiciously small (${entries.length} entries), rejecting`);
return new Set();
}
// Only cache a list that passed validation
writeCache(cacheKey, entries);
return new Set(entries);
}
/**
* Makes a MediaWiki API call to fetch revision metadata or content.
*
* @param {Object} data - Options for the API call, such as `revids`, `titles`, `rvprop`, etc.
* @returns {Promise<Object|null>} MediaWiki API query result, or null on failure.
*/
async function fetchRevisionData(data) {
const paramKeys = ['rvprop', 'revids', 'titles', 'rvslots'];
const params = {
action: 'query',
prop: 'revisions',
format: 'json',
rvdir: data.rvdir || 'older',
origin: '*'
};
if (data.rvlimit) {
params.rvlimit = data.rvlimit;
}
paramKeys.forEach(key => {
if (data[key]) {
params[key] = Array.isArray(data[key]) ? data[key].join('|') : data[key];
}
});
const api = new mw.Api();
return await safeFetch(api.get.bind(api), params);
}
/**
* Wraps any asynchronous fetch function with retry logic and error handling.
*
* @param {Function} fn - The function to execute (usually an API call).
* @param {...any} args - Arguments to pass to the fetch function.
* @returns {Promise<any|null>} Result of the fetch or null on failure.
*/
async function safeFetch(fn, ...args) {
const retries = 2;
const retryDelay = 1000;
const maxAttempts = retries + 1;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const result = await fn(...args);
if (result === null || result === undefined) {
throw new Error('Received null or undefined response');
}
if (typeof result.ok === 'boolean' && !result.ok) {
throw new Error(`HTTP error ${result.status}: ${result.statusText || 'Unknown error'}`);
}
return result;
} catch (error) {
if (attempt < maxAttempts) {
console.warn(
`Error during ${fn.name || 'fetch operation'} (attempt ${attempt}/${maxAttempts}):`,
error.message || error);
await new Promise(resolve => setTimeout(resolve, retryDelay));
} else {
console.error(
`All ${maxAttempts} attempts failed for ${fn.name || 'fetch operation'}:`,
error.message || error);
}
}
}
return null;
}
/**
* ==========================================================================
* 6. Cache
* ==========================================================================
*/
/**
* Returns every localStorage key with the given prefix. Snapshotting the keys
* first means callers can remove entries without the index shifting under them.
*
* @param {string} prefix - Key prefix to match.
* @returns {string[]}
*/
function cacheKeysWithPrefix(prefix) {
const keys = [];
try {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith(prefix)) keys.push(key);
}
} catch (e) {
/* storage unavailable */
}
return keys;
}
/**
* Runs all cache maintenance: retired prefixes, version invalidation, and
* (at most once per CACHE_PURGE_INTERVAL) expiry and eviction.
*/
function purgeCaches() {
try {
purgeStaleVersionedCaches();
const lastKey = 'citationWatchlist_lastPurge';
const last = parseInt(localStorage.getItem(lastKey), 10);
if (isNaN(last) || Date.now() - last > CACHE_PURGE_INTERVAL) {
purgeExpiredCache();
localStorage.setItem(lastKey, Date.now().toString());
}
} catch (e) {
console.warn('Citation Watchlist: cache maintenance failed:', e);
}
}
/**
* Removes caches from retired prefixes and purges cached entries whose stored
* version is older than the current version. Each cache type tracks its own
* version independently via a localStorage key.
*/
function purgeStaleVersionedCaches() {
for (const prefix of retiredCachePrefixes) {
for (const key of cacheKeysWithPrefix(prefix)) localStorage.removeItem(key);
localStorage.removeItem(`citationWatchlist_cacheVersion:${prefix}`);
}
for (const cache of cacheConfig) {
const versionKey = `citationWatchlist_cacheVersion:${cache.prefix}`;
const storedVersion = parseInt(localStorage.getItem(versionKey), 10);
const effectiveVersion = isNaN(storedVersion) ? 0 : storedVersion;
if (effectiveVersion < cache.version) {
for (const key of cacheKeysWithPrefix(cache.prefix)) {
localStorage.removeItem(key);
}
try {
localStorage.setItem(versionKey, cache.version.toString());
} catch (e) {
console.warn('Cache version write error:', e);
}
console.log(`Cache invalidated for "${cache.prefix}": version ${effectiveVersion} → ${cache.version}`);
}
}
}
/**
* Removes expired localStorage cache entries and enforces per-cache entry
* caps by evicting the oldest entries.
*/
function purgeExpiredCache() {
const now = Date.now();
const stats = { checked: 0, expired: 0, evicted: 0, errors: 0 };
for (const cache of cacheConfig) {
// Snapshot keys first so removals do not shift the iteration index
const survivors = []; // { key, timestamp }
for (const key of cacheKeysWithPrefix(cache.prefix)) {
stats.checked++;
try {
const value = localStorage.getItem(key);
let parsed = null;
try {
parsed = value ? JSON.parse(value) : null;
} catch (jsonError) {
stats.errors++;
}
if (!parsed || typeof parsed.timestamp !== 'number' || now - parsed.timestamp > cache.ttl) {
localStorage.removeItem(key);
stats.expired++;
continue;
}
survivors.push({ key, timestamp: parsed.timestamp });
} catch (itemError) {
stats.errors++;
try {
localStorage.removeItem(key);
} catch (e) { /* ignore */ }
}
}
if (cache.maxEntries && survivors.length > cache.maxEntries) {
survivors.sort((a, b) => a.timestamp - b.timestamp);
const excess = survivors.length - cache.maxEntries;
for (let i = 0; i < excess; i++) {
localStorage.removeItem(survivors[i].key);
stats.evicted++;
}
}
}
if (stats.checked > 0) {
console.log(`Cache cleanup: checked ${stats.checked}, expired ${stats.expired}, ` +
`evicted ${stats.evicted}, errors ${stats.errors}`);
}
}
/**
* Looks up the TTL for a cache prefix from the unified cacheConfig.
*
* @param {string} prefix - The cache key prefix.
* @returns {number} TTL in milliseconds, or 0 if not found.
*/
function getCacheTTL(prefix) {
const entry = cacheConfig.find(c => c.prefix === prefix);
return entry ? entry.ttl : 0;
}
/**
* Reads a JSON cache entry from localStorage, returning the stored content
* if it exists and has not expired. Callers must validate the shape of the
* returned content before using it.
*
* @param {string} key - The localStorage key.
* @param {number} ttl - Time-to-live in milliseconds.
* @returns {*|null} The cached content, or null if missing/expired/invalid.
*/
function readCache(key, ttl) {
let raw;
try {
raw = localStorage.getItem(key);
} catch (e) {
return null;
}
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed.timestamp === 'number' &&
(Date.now() - parsed.timestamp < ttl) && parsed.content !== undefined) {
return parsed.content;
}
} catch (e) {
console.warn('Cache read error:', e);
}
return null;
}
/**
* Writes a JSON cache entry to localStorage with an embedded timestamp.
* Failures (e.g. quota exceeded) are logged and otherwise ignored; the
* cache is an optimization, never a dependency.
*
* @param {string} key - The localStorage key.
* @param {*} content - The data to cache.
*/
function writeCache(key, content) {
try {
localStorage.setItem(key, JSON.stringify({ timestamp: Date.now(), content }));
} catch (e) {
console.warn('Cache write error:', e);
}
}
/**
* ==========================================================================
* 7. Entry Point
* ==========================================================================
*/
analyzeView().catch(e => {
console.error('Citation Watchlist: unrecoverable error:', e);
loadWarnings.push('Citation Watchlist stopped early due to an error (see console)');
finishChangeListPass();
}).then(() => {
console.log('Citation Watchlist script finished executing');
// On enhanced RC pages, hide the summary gutter on a group's header row
// while the group is expanded (each nested row then shows its own).
document.querySelectorAll('table.mw-enhanced-rc').forEach(table => {
const checkbox = groupToggleCheckbox(table);
if (!checkbox) return;
checkbox.addEventListener('change', () => {
const topRow = table.querySelector('tr');
const gutter = topRow && topRow.querySelector('.cw-gutter');
if (!gutter) return;
const host = gutter.parentElement;
gutter.style.display = checkbox.checked ? 'none' : 'inline-flex';
if (host) host.style.paddingLeft = checkbox.checked ? '' : 'calc(2em + 0.5em)';
});
});
});
})();
jy8gb84xfr4gr9hy7gf425wdpstiitt
781269
781259
2026-10-27T23:17:17Z
Harej
7994
Deduplication pass
781269
javascript
text/javascript
/**
*
* Citation Watchlist
* https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist
*
*/
(function() {
'use strict';
/**
* ==========================================================================
* LOCALIZATION
* ==========================================================================
*/
/* Labeling tool in the user interface */
const toolName = 'Citation Watchlist';
const aboutLink = 'https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist';
/* Revision inspection popup */
const addedLabel = 'Added';
const removedLabel = 'Removed';
const viewDiffLabel = 'View Diff';
const viewRevisionLabel = 'View Revision';
const aboutLabel = 'About Citation Watchlist';
const archivedViaLabel = 'Archived copy hosted at';
/* Filter widget */
const showEditsLabel = 'Show edits:';
const loadingLabel = 'Loading...';
const allLabel = 'All';
const linkChangesLabel = 'Link Changes';
const linkChangesTooltip = 'Edits that add or remove links in articles';
const noMatchingEditsLabel = 'No matching edits';
/* Load-failure indicator */
const loadWarningGlyph = '⚠';
const loadWarningPrefix = 'Some Citation Watchlist lists could not be loaded:';
/**
* ==========================================================================
* DOMAIN LIST CONFIGURATION
* ==========================================================================
*
* Citation Watchlist requires the following wiki pages to function:
*
* 1. Public Suffix List
* - A local copy of the public suffix list, used for domain parsing.
* - Copy the contents of:
* https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist/Public_Suffix_List
* to a page on your own wiki.
* - Update the `publicSuffixList` variable below to reflect your page title.
*
* 2. List of Lists
* - A page linking to one or more domain list pages.
* - Format as a bullet list: "* [[Page Title]]" (space after asterisk).
* - Reference formatting example:
* https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist/Lists
* - Update the `listOfLists` variable below accordingly.
*
* 3+. Domain List Pages
* - One or more pages listing suspicious or noteworthy domains.
* - Each page must contain section headers that match the `indicators` config
* below (e.g., "==Warn==", "==Caution==").
* - Under each section, list domains in the format: "* example.com"
* - Do not use link formatting—just plain text.
* - Entries with paths ("example.com/opinion"), no dot ("gov"), or a
* leading "www." are not supported and are reported in the console.
*/
const publicSuffixList = 'Wikipedia:Citation_Watchlist/Public_Suffix_List';
const listOfLists = 'Wikipedia:Citation_Watchlist/Lists';
const enabledNamespaces = [-1, 0, 118];
const enabledSectionHeaders = ['References'];
/**
* Detection options.
* - UNWRAP_ARCHIVES: screen the original URL embedded in a web archive link
* (web.archive.org, archive.today, Google cache) rather than the archive
* host. The popup still links to the cited archive URL.
* - SHOW_LINT_IN_WIDGET: also show domain-list lint (ignored entries) in the
* filter widget's warning tooltip. Lint always goes to the console.
*/
const UNWRAP_ARCHIVES = true;
const SHOW_LINT_IN_WIDGET = false;
/**
* ==========================================================================
* INDICATOR CONFIGURATION
* ==========================================================================
*
* Defines metadata for domain indicators used in the watchlist UI.
* Each indicator is associated with a level of urgency and a unique symbol.
*
* Fields:
* - msg: Display label for the level (e.g., "Warning", "Caution").
* - emoji: Unicode character for the visual indicator (escaped as `\uXXXX`).
* - section: Must exactly match the section headers in the domain list pages.
* - priority: Higher values override lower ones for conflicting domain matches.
* Priority scale: 1 (lowest) to N (highest).
* - list: Defined as "new Set()" for all indicator types.
*
* If a domain appears in multiple lists, the one with the highest priority
* takes precedence.
*/
const indicators = {
warning: {
msg: 'Warning',
emoji: '❗',
section: '==Warn==',
explanation: 'This domain is classified as an unreliable source',
priority: 3,
dataAttr: 'data-cw-linkwarn',
list: new Set()
},
caution: {
msg: 'Caution',
emoji: '✋',
section: '==Caution==',
explanation: 'This source may be unreliable depending on context',
priority: 2,
dataAttr: 'data-cw-linkcaution',
list: new Set()
},
inspect: {
msg: 'Inspect',
emoji: '🔎',
section: '==Inspect==',
explanation: 'Take a closer look at this particular source',
priority: 1,
dataAttr: 'data-cw-linkinspect',
list: new Set()
},
added: {
msg: addedLabel,
emoji: '\u{1F517}',
section: null,
explanation: 'All added links',
priority: 0,
list: new Set()
},
removed: {
msg: removedLabel,
emoji: '\u{1F5D1}',
section: null,
explanation: 'All removed links',
priority: -1,
list: new Set()
}
};
/** Indicator types that come from the domain lists (priority > 0). */
const listedTypes = Object.keys(indicators).filter(t => indicators[t].priority > 0);
/**
* Citation Watchlist
*
* Highlights potentially questionable citations added in Wikipedia revisions
* based on community-built lists of domain names
*
*
* Documentation: https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist
*
* Author: James Hare
* License: GNU General Public License v3.0 (GPL-3.0)
*
* @version 2.5
* @since 2026-
*/
/**
* IF YOU WANT TO MODIFY any part of the script below this point, please submit
* your edits to https://test.wikipedia.org/wiki/User:Harej/citation-watchlist.js
* so that your modifications can be tested.
*/
let publicSuffixSet = new Set();
const MAX_DOMAINS_PER_LIST = 50000;
const MIN_PSL_ENTRIES = 1000;
/**
* Revisions larger than this are not scanned. MediaWiki's own page-size
* limit is 2 MiB, so this only trips on pathological cases; it bounds the
* download and regex work done per row.
*/
const MAX_REVISION_CHARS = 2 * 1024 * 1024;
/**
* Revision fetching strategy. Rows are never fetched one at a time: revision
* IDs are grouped into small chunks (so the first chunk returns quickly and
* its rows render while the rest are still in flight), several chunks are
* kept in flight at once, and rows are annotated the moment their chunk
* arrives. Concurrency is capped because origin=* requests are anonymous and
* therefore per-IP rate limited (see release notes for 1.5/1.6).
*/
const CONTENT_CHUNK_SIZE = 8; // max revisions per content request
const CONTENT_CHUNK_BYTES = 400 * 1024; // soft byte budget per chunk when sizes are known
const CONTENT_CONCURRENCY = 4; // simultaneous content requests
const METADATA_CHUNK_SIZE = 50; // revids per ids|size request (API max for users)
const ASSUMED_REVISION_BYTES = 40 * 1024; // chunking estimate when size is unknown
const LIVE_RELOAD_DEBOUNCE_MS = 250; // settle time after the change list is replaced
/**
* Non-fatal problems encountered while loading configuration (missing list
* pages, API failures). Surfaced in the filter widget so list maintainers
* notice quickly, instead of the tool silently running with partial data.
*/
const loadWarnings = [];
/**
* Domain-list lint: entries that were ignored because they are not bare
* domains. Always logged; shown in the widget only if SHOW_LINT_IN_WIDGET.
*/
const listLint = [];
/**
* Unified cache configuration. Each entry defines a cache key prefix, its TTL,
* version number, an optional cap on the number of entries kept, and a
* human-readable description.
*
* - revURLs: per-revision list of extracted URLs. Revisions are immutable,
* so the TTL and cap exist only to bound storage.
* - sectionURLs: per-revision, per-section URL map from rendered article HTML.
* - domainList: parsed domain lists ({warning: [...], caution: [...], ...}).
* - publicSuffixListCache: validated PSL entries as an array.
* - citationWatchlistFetchDomainListPages_: list-of-lists page titles.
*/
const cacheConfig = [
{
prefix: 'revURLs:',
ttl: 30 * 24 * 60 * 60 * 1000,
version: 1,
maxEntries: 4000,
description: 'Revision URL cache'
},
{
prefix: 'sectionURLs:',
ttl: 30 * 24 * 60 * 60 * 1000,
version: 1,
maxEntries: 500,
description: 'Article section URL cache'
},
{
prefix: 'domainList:',
ttl: 6 * 60 * 60 * 1000,
version: 1,
description: 'Domain list cache'
},
{
prefix: 'publicSuffixListCache',
ttl: 24 * 60 * 60 * 1000,
version: 1,
description: 'Public suffix list cache'
},
{
prefix: 'citationWatchlistFetchDomainListPages_',
ttl: 4 * 60 * 60 * 1000,
version: 0,
description: 'Domain list pages cache'
}
];
/** Cache prefixes from earlier versions, removed on startup. */
const retiredCachePrefixes = ['revisionDiff:'];
/** How often (ms) the full localStorage expiry/eviction pass runs. */
const CACHE_PURGE_INTERVAL = 60 * 60 * 1000;
/**
* ==========================================================================
* 1. Primary Workflow
* ==========================================================================
*/
/** Resolves once the PSL and domain lists are loaded (true) or failed (false). */
let configReady = null;
/** Rows already handed to processRevisions(), so live reloads only pick up new ones. */
const seenRows = new WeakSet();
/**
* Loads the public suffix list and the domain lists, in parallel. Never
* throws; failures are recorded in loadWarnings.
*
* @returns {Promise<boolean>} True if the tool can screen domains.
*/
async function loadConfiguration() {
try {
const [psl, listPages] = await Promise.all([
fetchPublicSuffixList(),
fetchDomainListPages(listOfLists)
]);
publicSuffixSet = psl;
if (publicSuffixSet.size === 0) {
console.error('Public Suffix List loading failed');
loadWarnings.push('Public Suffix List could not be loaded');
return false;
}
console.log('Welcome to Citation Watchlist');
if (listPages && listPages.length > 0) {
await fetchAndOrganizeDomainLists(listPages);
} else {
loadWarnings.push(`Could not load list of lists (${listOfLists})`);
}
if (listLint.length > 0) {
console.warn(`Citation Watchlist: ${listLint.length} domain-list entries were ignored:\n` +
listLint.join('\n'));
}
return true;
} catch (e) {
console.error('Citation Watchlist: configuration load failed:', e);
loadWarnings.push('Configuration could not be loaded (see console)');
return false;
}
}
/**
* Main entry point for Citation Watchlist.
* Determines if the current page should be analyzed, starts loading the
* configuration, collects the revisions behind each change-list row, and
* fetches and annotates them while the configuration loads.
*/
async function analyzeView() {
purgeCaches();
const ns = mw.config.get('wgNamespaceNumber');
const inspectEnabled = enabledNamespaces.includes(ns);
// Start the configuration load immediately; nothing below waits on it
// until the first row is ready to render.
if (inspectEnabled) {
configReady = loadConfiguration();
}
// Article surface: scan enabled sections for watchlisted domains
const action = mw.config.get('wgAction');
if (action === 'view' && ns >= 0 && inspectEnabled) {
if (await configReady) {
await analyzeArticleSections();
}
return;
}
// Pre-pad change log lines immediately on Special:RecentChanges for visual stability
if (ns === -1) {
document.querySelectorAll('li.mw-changeslist-line, .mw-changeslist-line')
.forEach(line => prePadElement(line));
flushGutterPadding();
}
if (!inspectEnabled) {
return;
}
createChangeListFilterWidget();
await annotateChangeList(document, true);
installLiveReloadObserver();
}
/**
* Collects and annotates every unseen change-list row under `root`.
*
* @param {Document|Element} root - Where to look for rows.
* @param {boolean} allowFirstRevisionFallback - On a page history with no
* diff links (single-revision page), analyze the page's first revision.
*/
async function annotateChangeList(root, allowFirstRevisionFallback) {
const revisions = [];
let noLinks = true;
// Prefer iterating per-change rows when available (JS-enhanced watchlist/RC);
// fall back to link containers on non-enhanced layouts.
const unseen = selector => Array.from(root.querySelectorAll(selector)).filter(el => !seenRows.has(el));
let entries = unseen('[data-mw-revid]:not(.mw-userlink), .mw-changeslist-line');
let prePadCallback = null;
if (entries.length > 0) {
entries.forEach(line => prePadElement(line));
flushGutterPadding();
} else {
entries = unseen('.mw-changeslist-links');
prePadCallback = (_el, link) => {
const target = link.parentNode.parentNode;
prePadElement(target);
flushGutterPadding();
return target;
};
}
for (const entry of entries) {
seenRows.add(entry);
const result = await extractEntry(entry, entry, prePadCallback);
if (result.hasLink) noLinks = false;
if (!result.skip && result.revision) revisions.push(result.revision);
}
// If no links were found, extract the first revision ID
if (allowFirstRevisionFallback && noLinks && entries.length > 0) {
try {
const firstID = await fetchFirstRevisionId(mw.config.get('wgTitle'));
if (firstID) {
revisions.push({
oldrevision: String(firstID),
element: entries[0],
viewHref: buildPermalinkUrl(firstID),
viewLabel: viewRevisionLabel
});
}
} catch (e) {
console.warn('Citation Watchlist: single-revision fallback failed:', e);
}
}
await processRevisions(revisions);
finishChangeListPass();
}
/**
* Extracts the revision behind one change-list entry, isolating failures so
* that a single malformed row does not abort the remaining rows.
*
* @param {Element} container - The DOM element to query for revision links.
* @param {Element} element - The DOM element to attach to the revision object.
* @param {Function|null} prePadCallback - See extractRevisionFromLinks.
* @returns {Promise<{revision: Object|null, hasLink: boolean, skip: boolean}>}
*/
async function extractEntry(container, element, prePadCallback) {
try {
return await extractRevisionFromLinks(container, element, prePadCallback);
} catch (e) {
console.warn('Citation Watchlist: skipping entry due to error:', e);
// Report hasLink=true so a row-level failure never triggers the
// single-revision fallback for the whole page.
return { revision: null, hasLink: true, skip: true };
}
}
/**
* Annotates a set of revisions with as little latency as possible:
*
* 1. Parent IDs (for diff=prev and history rows) and sizes are resolved in
* one metadata request, only when any row needs them.
* 2. Per-revision URL lists already in the cache are used directly.
* 3. Remaining revision IDs are deduplicated (history rows share revisions),
* chunked in DOM order, fetched with bounded concurrency, and each row is
* rendered as soon as every revision it depends on is available. Fetching
* overlaps the configuration load; only rendering waits for it.
*
* @param {Object[]} revisions - Revision objects from extractRevisionFromLinks.
*/
async function processRevisions(revisions) {
if (revisions.length === 0) return;
// --- 1. Resolve parents / sizes ---------------------------------------
const parentLookups = revisions.filter(r => r.needsParentOf).map(r => r.needsParentOf);
let metadata = {};
if (parentLookups.length > 0) {
metadata = await fetchRevisionMetadata(unique(parentLookups));
for (const revision of revisions) {
if (!revision.needsParentOf) continue;
const currentId = revision.needsParentOf;
const meta = metadata[currentId];
delete revision.needsParentOf;
if (meta && meta.parentid) {
revision.oldrevision = String(meta.parentid);
revision.newrevision = currentId;
if (revision.pageTitle && !revision.viewHref) {
revision.viewHref = buildDiffUrl(revision.pageTitle,
revision.oldrevision, revision.newrevision);
}
} else if (meta) {
// First revision of the page: everything in it was "added"
revision.oldrevision = currentId;
revision.newrevision = null;
revision.viewHref = buildPermalinkUrl(currentId);
revision.viewLabel = viewRevisionLabel;
} else {
markSkipped(revision, 'fetch-failed');
revision.oldrevision = null;
}
}
}
// --- 2. Per-revision URL lists from cache; queue the rest --------------
const urlLists = new Map(); // id -> string[] | null (null = unreadable)
const pending = []; // { revision, needs: string[] }
for (const revision of revisions) {
if (revision.skipped) continue;
if (!isRevisionId(revision.oldrevision) ||
(revision.newrevision && !isRevisionId(revision.newrevision))) {
markSkipped(revision, 'invalid');
continue;
}
const needs = [revision.oldrevision];
if (revision.newrevision) needs.push(revision.newrevision);
for (const id of needs) {
if (urlLists.has(id)) continue;
const cached = readCache(`revURLs:${location.hostname}:${id}`);
if (Array.isArray(cached)) urlLists.set(id, cached.filter(isValidHttpUrl));
}
pending.push({ revision, needs });
}
if (pending.length === 0) return;
// --- 3. Fetch missing content in chunks; render as data arrives --------
const neededIds = unique(pending.flatMap(p => p.needs)).filter(id => !urlLists.has(id));
const oversized = new Set();
for (const id of neededIds) {
const meta = metadata[id];
if (meta && typeof meta.size === 'number' && meta.size > MAX_REVISION_CHARS) {
oversized.add(id);
urlLists.set(id, null);
}
}
const chunks = chunkRevisionIds(neededIds.filter(id => !oversized.has(id)), metadata);
let canRender = false;
const settle = () => {
if (!canRender) return;
for (let i = pending.length - 1; i >= 0; i--) {
const item = pending[i];
if (!item.needs.every(id => urlLists.has(id))) continue;
pending.splice(i, 1);
if (item.needs.some(id => oversized.has(id))) {
markSkipped(item.revision, 'oversized');
continue;
}
const oldList = urlLists.get(item.revision.oldrevision);
const newList = item.revision.newrevision ? urlLists.get(item.revision.newrevision) : null;
if (oldList === null || (item.revision.newrevision && newList === null)) {
markSkipped(item.revision, 'fetch-failed');
continue;
}
const diff = computeURLDiff(oldList, newList, !!item.revision.newrevision);
renderRevision(item.revision, diff.addedURLs, diff.removedURLs);
}
flushGutterPadding();
};
const onText = (id, text) => {
if (text === null) {
urlLists.set(id, null);
return;
}
if (text.length > MAX_REVISION_CHARS) {
oversized.add(id);
urlLists.set(id, null);
return;
}
const urls = extractURLs(stripHTMLComments(text));
urlLists.set(id, urls);
writeCache(`revURLs:${location.hostname}:${id}`, urls);
};
// Dispatch content fetches now; they run while the lists are still loading.
const fetching = runPool(chunks.map(chunk => async () => {
await fetchChunkIsolating(chunk, onText);
settle();
}), CONTENT_CONCURRENCY);
if (!(await configReady)) {
await fetching;
return; // no lists: nothing to screen against
}
canRender = true;
settle(); // cache hits and anything that landed while the lists loaded
await fetching;
// Anything still pending had an id the API never returned
for (const item of pending) {
markSkipped(item.revision, 'fetch-failed');
}
}
/**
* Fetches a chunk of revisions; if the request fails outright, splits the
* chunk in half and retries each half, so one problematic revision only
* costs itself rather than its neighbours. Each fetch already carries
* safeFetch's retries, so this only runs in genuine failure cases.
*
* @param {string[]} chunk - Revision IDs.
* @param {Function} onText - Called with (id, text|null) for every id.
*/
async function fetchChunkIsolating(chunk, onText) {
const result = await fetchRevisionTexts(chunk);
if (result) {
for (const id of chunk) {
onText(id, result.has(id) ? result.get(id) : null);
}
return;
}
if (chunk.length === 1) {
onText(chunk[0], null);
return;
}
const mid = Math.ceil(chunk.length / 2);
await fetchChunkIsolating(chunk.slice(0, mid), onText);
await fetchChunkIsolating(chunk.slice(mid), onText);
}
/**
* Groups revision IDs into fetch chunks in order, bounded by count and, when
* sizes are known, by an approximate byte budget so one huge article does not
* delay its neighbours.
*
* @param {string[]} ids - Revision IDs in the order they should be fetched.
* @param {Object} metadata - Map of id -> {parentid, size} (may be sparse).
* @returns {string[][]}
*/
function chunkRevisionIds(ids, metadata) {
const chunks = [];
let current = [];
let currentBytes = 0;
for (const id of ids) {
const meta = metadata[id];
const bytes = (meta && typeof meta.size === 'number') ? meta.size : ASSUMED_REVISION_BYTES;
if (current.length > 0 &&
(current.length >= CONTENT_CHUNK_SIZE || currentBytes + bytes > CONTENT_CHUNK_BYTES)) {
chunks.push(current);
current = [];
currentBytes = 0;
}
current.push(id);
currentBytes += bytes;
}
if (current.length > 0) chunks.push(current);
return chunks;
}
/**
* Runs async task factories with at most `concurrency` in flight, in order.
*
* @param {Array<() => Promise<*>>} tasks
* @param {number} concurrency
* @returns {Promise<void>}
*/
async function runPool(tasks, concurrency) {
let next = 0;
const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, async () => {
while (next < tasks.length) {
const task = tasks[next++];
try {
await task();
} catch (e) {
console.warn('Citation Watchlist: chunk failed:', e);
}
}
});
await Promise.all(workers);
}
/**
* Returns the elements of an array with duplicates removed, first occurrence wins.
*
* @param {Array} arr
* @returns {Array}
*/
function unique(arr) {
return Array.from(new Set(arr));
}
/**
* Marks a revision's row as skipped with a reason, without annotating it.
*
* @param {Object} revision
* @param {string} reason - 'fetch-failed', 'oversized' or 'invalid'.
*/
function markSkipped(revision, reason) {
revision.skipped = reason;
try {
revisionTargetElement(revision).setAttribute('data-cw-skipped', reason);
} catch (e) {
/* ignore */
}
console.warn(`Citation Watchlist: skipped ${revision.oldrevision || '?'}` +
`${revision.newrevision ? ' -> ' + revision.newrevision : ''} (${reason})`);
}
/**
* Finds the row-level element that carries data attributes for a revision.
*
* @param {Object} revision
* @returns {Element}
*/
function revisionTargetElement(revision) {
const el = revision.element;
return el.closest('[data-mw-revid]:not(.mw-userlink)') ||
el.closest('li') || el.closest('.mw-changeslist-line, tr') || el;
}
/**
* Scans the article page for sections whose H2 headers match enabledSectionHeaders,
* then analyzes each matching section for watchlisted domains.
*/
async function analyzeArticleSections() {
const headings = document.querySelectorAll('.mw-heading2 h2, h2');
for (const h2 of headings) {
const headerText = h2.textContent.trim();
if (!enabledSectionHeaders.includes(headerText)) continue;
// Collect all content between this heading and the next heading
const headingContainer = h2.closest('.mw-heading') || h2;
let sibling = headingContainer.nextElementSibling;
const sectionElements = [];
while (sibling && !sibling.classList.contains('mw-heading')) {
sectionElements.push(sibling);
sibling = sibling.nextElementSibling;
}
try {
analyzeSection(headerText, sectionElements);
createArticleFilterWidget(headingContainer);
} catch (e) {
console.warn(`Citation Watchlist: failed to analyze section "${headerText}":`, e);
}
}
}
/**
* Analyzes a single article section: extracts URLs from each <li>, screens them
* against domain lists, and prepends indicator icons to matching list items.
*
* @param {string} sectionName - The section header text (e.g. "References").
* @param {Element[]} sectionElements - DOM elements between this heading and the next.
*/
function analyzeSection(sectionName, sectionElements) {
const revisionId = mw.config.get('wgRevisionId');
const cacheKey = `sectionURLs:${location.hostname}:${revisionId}:${sectionName}`;
// Map: array of { liId, urls }. Anything read back from localStorage is
// re-validated before it can reach an href.
let liURLMap = null;
const cachedMap = readCache(cacheKey);
if (Array.isArray(cachedMap)) {
liURLMap = cachedMap
.filter(entry => entry && typeof entry.liId === 'string' && Array.isArray(entry.urls))
.map(entry => ({ liId: entry.liId, urls: entry.urls.filter(isValidHttpUrl) }));
}
const citeNotes = sectionElements.flatMap(el => Array.from(el.querySelectorAll('li[id^="cite_note"]')));
// Build from DOM if not cached
if (!liURLMap) {
liURLMap = citeNotes.map(li => ({
liId: li.id,
urls: Array.from(li.querySelectorAll('a.external[href]'))
.map(a => a.getAttribute('href'))
.filter(href => /^https?:\/\//i.test(href))
}));
writeCache(cacheKey, liURLMap);
}
// Add padding to all list items so citation text is flush with icons
for (const li of citeNotes) li.style.paddingLeft = '1.5em';
// Screen each li's URLs against domain lists and prepend icons with popups
for (const entry of liURLMap) {
const li = document.getElementById(entry.liId);
if (!li) continue;
const matched = resolveURLs(entry.urls).filter(r => r.indicator);
if (matched.length === 0) continue;
const highest = highestPriority(matched.map(m => m.indicator));
const wrapper = makeElement('span', {
style: { position: 'relative', cursor: 'pointer', marginLeft: '-1.5em',
display: 'inline-block', width: '1.5em' }
});
wrapper.appendChild(makeElement('span', { text: indicators[highest].emoji }));
attachPopup(wrapper, matched.map(m => popupLine(m.indicator, [m], false)), {});
li.prepend(wrapper);
li.dataset.cwIndicator = highest;
}
}
/**
* Extracts revision information from diff/hist/prev/cur links within a container.
* Parent IDs are not fetched here; rows that need one set `needsParentOf` and
* processRevisions() resolves them in a single batched request.
*
* @param {Element} container - The DOM element to query for revision links.
* @param {Element} element - The DOM element to attach to the revision object.
* @param {Function|null} prePadCallback - Optional callback invoked with (element, link) before
* processing each branch. In fallback mode this derives the target element and pre-pads it.
* @returns {Promise<{revision: Object|null, hasLink: boolean, skip: boolean}>}
* - revision: the extracted revision object, or null if no usable link was found.
* - hasLink: true if any recognized link was present (even if skipped).
* - skip: true if the entry should be skipped (non-article after pre-padding).
*/
async function extractRevisionFromLinks(container, element, prePadCallback) {
const kinds = [
{ selector: 'a.mw-changeslist-diff', kind: 'diff' },
{ selector: 'a.mw-changeslist-history', kind: 'hist' },
{ selector: 'a.mw-history-histlinks-previous', kind: 'prev' },
{ selector: 'a.mw-history-histlinks-current', kind: 'cur' }
];
let link = null;
let kind = null;
for (const k of kinds) {
link = container.querySelector(k.selector);
if (link) {
kind = k.kind;
break;
}
}
if (!link) {
return { revision: null, hasLink: false, skip: false };
}
const params = new URL(link.href).searchParams;
const pageTitle = params.get('title');
const target = prePadCallback ? prePadCallback(element, link) : element;
// The diff and hist links appear on watchlist/RC/contribs rows that can be
// for any namespace; history prev/cur links are always for the page itself.
if ((kind === 'diff' || kind === 'hist') && isNotArticle(pageTitle)) {
return { revision: null, hasLink: true, skip: true };
}
const revision = {
oldrevision: null,
newrevision: null,
element: target,
viewHref: '',
viewLabel: viewDiffLabel,
pageTitle: pageTitle || undefined
};
switch (kind) {
case 'diff': {
// MediaWiki convention: diff = newer, oldid = older
const diffParam = params.get('diff');
const oldidParam = params.get('oldid');
revision.viewHref = link.href;
if (diffParam === 'prev' && oldidParam) {
// Contributions: oldid is the current revision; parent resolved later
revision.needsParentOf = oldidParam;
} else {
revision.oldrevision = oldidParam;
revision.newrevision = diffParam;
}
break;
}
case 'hist': {
// New page: analyze its first revision
const firstID = await fetchFirstRevisionId(pageTitle);
if (!firstID) {
return { revision: null, hasLink: true, skip: false };
}
revision.oldrevision = String(firstID);
revision.viewHref = buildPermalinkUrl(firstID);
revision.viewLabel = viewRevisionLabel;
break;
}
case 'prev': {
// viewHref is filled in once the parent is known
revision.needsParentOf = params.get('oldid') || null;
break;
}
case 'cur': {
revision.oldrevision = params.get('oldid');
revision.viewHref = buildPermalinkUrl(revision.oldrevision);
revision.viewLabel = viewRevisionLabel;
break;
}
}
return { revision, hasLink: true, skip: false };
}
/**
* Computes the URLs added and removed between two revisions' URL lists.
* Multiset semantics: a URL that appears twice in the new revision and once
* in the old counts as one addition.
*
* @param {string[]} fromList - URLs in the older revision.
* @param {string[]|null} toList - URLs in the newer revision (ignored if !hasNew).
* @param {boolean} hasNew - False for a page's first revision, in which case
* every URL in fromList is considered added.
* @returns {{addedURLs: string[], removedURLs: string[]}}
*/
function computeURLDiff(fromList, toList, hasNew) {
if (!hasNew) {
// For first revision, all URLs are considered added
return { addedURLs: fromList.slice(), removedURLs: [] };
}
const count = arr => arr.reduce((m, u) => {
m[u] = (m[u] || 0) + 1;
return m;
}, {});
const fromMap = count(fromList);
const toMap = count(toList || []);
const addedURLs = [];
const removedURLs = [];
for (const key of new Set([...Object.keys(fromMap), ...Object.keys(toMap)])) {
const addCount = Math.max(0, (toMap[key] || 0) - (fromMap[key] || 0));
const remCount = Math.max(0, (fromMap[key] || 0) - (toMap[key] || 0));
for (let i = 0; i < addCount; i++) addedURLs.push(key);
for (let i = 0; i < remCount; i++) removedURLs.push(key);
}
return { addedURLs, removedURLs };
}
/**
* Screens a revision's added/removed URLs against the domain lists and
* annotates its row (gutter icons, popup, filter data attributes).
* Synchronous: all network work happens in processRevisions().
*
* @param {Object} revision - Object containing oldrevision, optional
* newrevision, and DOM element.
* @param {string[]} addedURLs
* @param {string[]} removedURLs
*/
function renderRevision(revision, addedURLs, removedURLs) {
const targetElement = revisionTargetElement(revision);
targetElement.setAttribute('data-cw-processed', 'true');
const added = resolveURLs(addedURLs);
const removed = resolveURLs(removedURLs);
// Highest-priority indicator among added links (lists only), for the gutter
const addType = highestPriority(added.map(a => a.indicator)) ||
(added.length > 0 ? 'added' : null);
try {
if (revision.viewHref) revision.element.dataset.cwViewHref = revision.viewHref;
if (revision.viewLabel) revision.element.dataset.cwViewLabel = revision.viewLabel;
} catch (e) {
/* ignore */
}
// Render two-column emojis: left = addition (highest-priority), right = removal (bin)
renderTwoColumnEmojis(revision.element, addType, removed.length > 0, added, removed);
// Data attributes drive the filter widget
const changedDomains = unique([...added, ...removed].map(r => r.domain)).sort();
if (changedDomains.length > 0) {
targetElement.dataset.cwLinkchange = changedDomains.join('|');
}
for (const type of listedTypes) {
const domains = unique([...added, ...removed]
.filter(r => r.indicator === type).map(r => r.domain)).sort();
if (domains.length > 0) {
targetElement.setAttribute(indicators[type].dataAttr, domains.join('|'));
}
}
}
/**
* ==========================================================================
* 2. User Interface
* ==========================================================================
*/
/**
* Pending hosts that need gutter padding applied. Collected during
* renderTwoColumnEmojis and flushed in a single batch via flushGutterPadding()
* so that all rows shift at the same time instead of one at a time.
*/
const pendingGutterPadding = [];
/**
* Applies the gutter padding to a single host element, reserving horizontal
* space for the two-column emoji gutter.
*
* @param {HTMLElement} host - The element to pad.
*/
function applyGutterPadding(host) {
const pad = 'calc(2em + 0.5em)';
try {
const cs = window.getComputedStyle(host);
const existing = parseFloat(cs.paddingLeft || '0');
const fontSizePx = parseFloat(cs.fontSize || '16');
const desiredPx = 2.5 * fontSizePx; // 2em + 0.5em
if (isNaN(existing) || existing < desiredPx - 0.5) {
host.style.paddingLeft = pad;
}
} catch (e) {
host.style.paddingLeft = pad;
}
}
/**
* Applies gutter padding to all collected hosts at once, then clears the
* queue. Call this after a batch of renderTwoColumnEmojis calls to avoid
* one-at-a-time layout shifts.
*/
function flushGutterPadding() {
for (const host of pendingGutterPadding) {
applyGutterPadding(host);
}
pendingGutterPadding.length = 0;
}
/**
* Finds the element that hosts the gutter for a change-list element:
* the list container or, for table rows, the content cell.
*
* @param {HTMLElement} element
* @returns {HTMLElement|null}
*/
function gutterHostFor(element) {
const container =
element.closest('[data-mw-revid]:not(.mw-userlink)') ||
element.closest('li') ||
element.closest('.mw-changeslist-line, tr') || element.parentNode || element;
if (!container) return null;
let host = container;
if (host.tagName && host.tagName.toUpperCase() === 'TR') {
// For nested enhanced RC rows, place the gutter in the content cell so
// icons align with singleton rows that use .mw-changeslist-line-inner.
const nestedContent = host.querySelector('td.mw-enhanced-rc-nested[data-target-page]');
host = nestedContent || host.querySelector('td, th') || host;
}
// Prefer inner change-line wrappers on Watchlist/RC if present
const innerHost = host.querySelector('.mw-changeslist-line-inner, .mw-changeslist-line-main');
return innerHost || host;
}
/**
* Renders exactly two fixed-width emoji columns before a revision entry:
* - Left column: highest-priority addition indicator
* (warning/caution/inspect/added), or spacer if none.
* - Right column: removal indicator if removals exist, otherwise spacer.
*
* This preserves consistent alignment even when no links are added/removed.
*
* @param {HTMLElement} element - The revision list DOM element.
* @param {string|null} addType - Indicator type for the left column, or null.
* @param {boolean} hasRemovals - Whether to show the removal icon.
* @param {Object[]} added - Resolved added links ({url, domain, indicator, ...}).
* @param {Object[]} removed - Resolved removed links.
*/
function renderTwoColumnEmojis(element, addType, hasRemovals, added = [], removed = []) {
if (!element) return;
const host = gutterHostFor(element);
if (!host) return;
const cell = (char, className) => makeElement('span', {
className, text: char,
style: { display: 'inline-block', width: '1em', textAlign: 'center', userSelect: 'none' }
});
const addChar = addType ? indicators[addType].emoji : ' ';
const remChar = hasRemovals ? indicators.removed.emoji : ' ';
// Propagate dataset for view link from source element to host
try {
if (element.dataset.cwViewHref) host.dataset.cwViewHref = element.dataset.cwViewHref;
if (element.dataset.cwViewLabel) host.dataset.cwViewLabel = element.dataset.cwViewLabel;
} catch (e) {
/* ignore */
}
// Ensure a single gutter exists
let gutter = host.querySelector('.cw-gutter');
if (gutter) {
gutter.querySelector('.cw-add').textContent = addChar;
gutter.querySelector('.cw-rem').textContent = remChar;
} else {
// Ensure the host can anchor absolutely positioned children
if (!host.style.position) host.style.position = 'relative';
// Create a left gutter that does not participate in text wrapping
gutter = makeElement('span', {
className: 'cw-gutter',
style: {
position: 'absolute', left: '0', top: '0', display: 'inline-flex', gap: '0.25em',
width: 'calc(2em + 0.25em)', alignItems: 'flex-start', userSelect: 'none'
}
});
gutter.appendChild(cell(addChar, 'cw-add'));
gutter.appendChild(cell(remChar, 'cw-rem'));
// Insert the gutter into the host; padding is deferred to flushGutterPadding()
host.insertBefore(gutter, host.firstChild);
pendingGutterPadding.push(host);
}
attachPopup(gutter, [popupLine('added', added, true), popupLine('removed', removed, true)], {
viewHref: host.dataset.cwViewHref || '',
viewLabel: host.dataset.cwViewLabel || ''
});
host.setAttribute('data-processed-twocol', 'true');
}
/**
* Fills a popup with a close button, a two-column grid of labelled link
* lines, and a footer. Idempotent: safe to call again to refresh content.
*
* @param {HTMLElement} popupEl - The popup element.
* @param {Array<{labelText: string, labelTitle: string, items: Object[]}>} lines
* Each item is a resolved link ({url, domain, indicator, archiveHost}).
* @param {Object} footerOptions - See createPopupFooter.
*/
function buildPopupContent(popupEl, lines, footerOptions) {
if (!popupEl.querySelector('.cw-close')) {
popupEl.appendChild(createCloseButton(popupEl));
}
let contentWrap = popupEl.querySelector('.cw-popup-content');
if (!contentWrap) {
contentWrap = makeElement('div', { className: 'cw-popup-content' });
popupEl.appendChild(contentWrap);
}
// Grid layout so labels are flush-aligned with content
Object.assign(contentWrap.style, {
display: 'grid', gridTemplateColumns: 'max-content 1fr', columnGap: '0.5em', rowGap: '2px'
});
contentWrap.innerHTML = '';
for (const line of lines) {
if (!line.items || line.items.length === 0) continue;
contentWrap.appendChild(makeElement('strong', {
text: `${line.labelText}:`, title: line.labelTitle,
ariaLabel: `${line.labelText}: ${line.labelTitle}`,
style: { alignSelf: 'start', justifySelf: 'end', textAlign: 'right', whiteSpace: 'nowrap' }
}));
const content = makeElement('div', { style: { display: 'inline' } });
line.items.forEach((item, idx) => {
content.appendChild(makeLinkItem(item));
if (idx < line.items.length - 1) content.appendChild(makeElement('span', { text: ' • ' }));
});
contentWrap.appendChild(content);
}
const footer = popupEl.querySelector('.cw-popup-footer');
if (footer) footer.remove();
popupEl.appendChild(createPopupFooter(footerOptions));
}
/**
* Creates (or refreshes) the popup under a host element and wires its
* hover/click handlers. With no lines, any existing popup is hidden.
*
* @param {HTMLElement} host - The gutter or icon wrapper that owns the popup.
* @param {Array} lines - See buildPopupContent.
* @param {Object} footerOptions - See createPopupFooter.
*/
function attachPopup(host, lines, footerOptions) {
let popup = host.querySelector('.cw-popup');
const hasContent = lines.some(l => l.items && l.items.length > 0);
if (!hasContent) {
if (popup) popup.style.display = 'none';
host._cwEnablePopup = false;
return;
}
if (!popup) {
popup = createPopupElement();
host.appendChild(popup);
}
buildPopupContent(popup, lines, footerOptions);
host._cwEnablePopup = true;
installPopupHandlers(host);
}
/**
* Builds a popup line for an indicator type over a set of resolved links.
*
* @param {string} type - Indicator type (added, removed, warning, ...).
* @param {Object[]} items - Resolved links.
* @param {boolean} withEmoji - Prefix the label with the type's emoji.
* @returns {{labelText: string, labelTitle: string, items: Object[]}}
*/
function popupLine(type, items, withEmoji) {
const ind = indicators[type];
return {
labelText: withEmoji ? `${ind.emoji} ${ind.msg}` : ind.msg,
labelTitle: ind.explanation,
items
};
}
/**
* Builds the popup representation of one resolved link: an optional
* indicator emoji followed by a link whose text is the screened domain.
*
* @param {Object} item - {url, domain, indicator, archiveHost}.
* @returns {HTMLSpanElement}
*/
function makeLinkItem(item) {
const span = makeElement('span', { style: { whiteSpace: 'nowrap', display: 'inline-block' } });
if (item.indicator) {
const ind = indicators[item.indicator];
span.appendChild(makeElement('span', {
className: 'cw-emoji', text: ind.emoji + ' ',
title: ind.explanation, ariaLabel: `${ind.msg}: ${ind.explanation}`
}));
}
const a = makeExternalLink(item.url, item.domain);
if (item.archiveHost) a.title = `${archivedViaLabel} ${item.archiveHost}`;
span.appendChild(a);
return span;
}
/**
* Creates a link with the attributes every outbound link in this script
* uses. The single place an attacker-influenced URL becomes an href, so the
* URL check lives here: absolute http(s) only, plus same-origin paths when
* `allowPath` is set (used for diff/permalink footers).
*
* @param {string} url
* @param {string} text
* @param {boolean} [allowPath=false]
* @returns {HTMLAnchorElement}
*/
function makeExternalLink(url, text, allowPath = false) {
const a = makeElement('a', {
text, attrs: { rel: 'nofollow noopener noreferrer', target: '_blank' },
style: { whiteSpace: 'nowrap' }
});
if (isValidHttpUrl(url) || (allowPath && /^\/(?!\/)/.test(url))) a.href = url;
return a;
}
/**
* Creates a DOM element with class, text, title, inline styles and
* attributes in one call. Text is always set through textContent.
*
* @param {string} tag
* @param {Object} [opts] - {className, text, title, ariaLabel, style, attrs}
* @returns {HTMLElement}
*/
function makeElement(tag, opts = {}) {
const el = document.createElement(tag);
if (opts.className) el.className = opts.className;
if (opts.text !== undefined) el.textContent = opts.text;
if (opts.title) {
el.title = opts.title;
el.setAttribute('aria-label', opts.ariaLabel || opts.title);
}
if (opts.style) Object.assign(el.style, opts.style);
for (const [k, v] of Object.entries(opts.attrs || {})) el.setAttribute(k, v);
return el;
}
/**
* Creates a styled popup element for displaying domain information.
*
* @returns {HTMLDivElement} The configured popup element.
*/
function createPopupElement() {
return makeElement('div', {
className: 'mwe-popups cw-popup',
style: {
position: 'absolute', minWidth: '260px', maxWidth: '420px', zIndex: '1000',
display: 'none', background: 'var(--background-color, #fff)',
border: '1px solid rgba(0,0,0,.15)', boxShadow: '0 2px 8px rgba(0,0,0,.2)',
padding: '8px 26px 8px 10px', borderRadius: '4px', fontSize: '0.92em'
}
});
}
/**
* Creates a styled close button for popups.
*
* @param {HTMLElement} popupEl - The popup element to hide when the button is clicked.
* @returns {HTMLButtonElement} The configured close button.
*/
function createCloseButton(popupEl) {
const closeBtn = makeElement('button', {
className: 'cw-close', text: '×', title: 'Close', attrs: { type: 'button' },
style: {
position: 'absolute', top: '4px', right: '6px', border: 'none',
background: 'transparent', cursor: 'pointer', fontSize: '14px',
lineHeight: '1', padding: '0', margin: '0'
}
});
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
popupEl.style.display = 'none';
});
return closeBtn;
}
/**
* Creates a popup footer element with an optional primary link and the
* "About Citation Watchlist" link.
*
* @param {Object} [options] - Optional configuration.
* @param {string} [options.viewHref] - URL for the primary link (e.g., diff or revision).
* @param {string} [options.viewLabel] - Label text for the primary link.
* @returns {HTMLDivElement} The configured footer element.
*/
function createPopupFooter(options) {
const footer = makeElement('div', {
className: 'cw-popup-footer', style: { marginTop: '10px', color: 'inherit' }
});
if (options && options.viewHref && options.viewLabel) {
const viewA = makeExternalLink(options.viewHref, options.viewLabel, true);
if (viewA.href) {
const strong = makeElement('strong');
strong.appendChild(viewA);
footer.appendChild(strong);
footer.appendChild(makeElement('span', { text: ' • ' }));
}
}
footer.appendChild(makeExternalLink(aboutLink, aboutLabel));
return footer;
}
/**
* Builds the shared filter widget container with radio buttons and an about link.
*
* @param {Object} config - Widget configuration.
* @param {string} config.containerId - The ID for the container element.
* @param {string} config.radioName - The name attribute for the radio button group.
* @param {Array<Object>} config.filters - Array of filter option objects
* ({id, label, checked?, tooltip?}).
* @param {Function} config.onFilter - Callback invoked with the selected filter ID.
* @param {string} [config.initialLabel] - Label text; defaults to showEditsLabel.
* @returns {HTMLDivElement} The configured filter widget container.
*/
function buildFilterWidgetContainer(config) {
const container = makeElement('div', {
attrs: { id: config.containerId },
style: {
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
border: '1px solid black', borderRadius: '25px', padding: '10px 20px',
margin: '10px 0', width: 'fit-content'
}
});
const leftSection = makeElement('div', {
style: { display: 'flex', alignItems: 'center', gap: '15px' }
});
leftSection.appendChild(makeElement('span', {
className: 'cw-filter-label', text: config.initialLabel || showEditsLabel,
style: { fontWeight: '500' }
}));
for (const filter of config.filters) {
const wrapper = makeElement('label', {
style: { display: 'flex', alignItems: 'center', gap: '5px', cursor: 'pointer' }
});
const radio = makeElement('input', {
attrs: { type: 'radio', name: config.radioName, value: filter.id }, style: { margin: '0' }
});
radio.checked = !!filter.checked;
radio.addEventListener('change', () => {
if (radio.checked) config.onFilter(filter.id);
});
const text = makeElement('span', { text: filter.label });
if (filter.tooltip) {
text.title = filter.tooltip;
Object.assign(text.style, { borderBottom: '1px dotted', cursor: 'help' });
}
wrapper.appendChild(radio);
wrapper.appendChild(text);
leftSection.appendChild(wrapper);
}
container.appendChild(leftSection);
const rightSection = makeElement('div', { style: { marginLeft: '4em' } });
const link = makeElement('a', {
text: toolName, attrs: { href: aboutLink },
style: { textDecoration: 'underline', color: '#0645ad' }
});
rightSection.appendChild(link);
container.appendChild(rightSection);
return container;
}
/**
* Builds the indicator filter list from the indicators config, sorted by
* descending priority and excluding non-positive priorities.
*
* @returns {Array<{id: string, label: string, tooltip: string}>}
*/
function buildIndicatorFilters() {
return listedTypes
.slice()
.sort((a, b) => indicators[b].priority - indicators[a].priority)
.map(id => ({
id,
label: `${indicators[id].emoji} ${indicators[id].msg}`,
tooltip: indicators[id].explanation
}));
}
/**
* Enables or greys out each radio in a filter widget according to whether
* any row currently matches it.
*
* @param {string} radioName - The radio group name.
* @param {Function} hasResults - (filterId) => boolean.
*/
function updateFilterRadios(radioName, hasResults) {
document.querySelectorAll(`input[name="${radioName}"]`).forEach(radio => {
const enabled = radio.value === 'all' || hasResults(radio.value);
const label = radio.closest('label');
radio.disabled = !enabled;
if (label) {
label.style.cursor = enabled ? 'pointer' : 'default';
label.style.color = enabled ? '' : 'gray';
}
});
}
/**
* Creates and inserts the change-list filter widget (watchlist, recent
* changes, history, contributions). Radios start disabled and are enabled by
* finishChangeListPass() once rows are annotated.
*
* @returns {HTMLElement|null} The created filter container or null if not created.
*/
function createChangeListFilterWidget() {
const ns = mw.config.get('wgNamespaceNumber');
const pageName = mw.config.get('wgPageName');
let target = document.querySelector('.mw-changeslist') ||
document.getElementById('mw-history-compare');
if (!target && ns === -1 && pageName.startsWith('Special:Contributions')) {
target = document.querySelector('.mw-pager-body');
}
if (!target) return null;
const container = buildFilterWidgetContainer({
containerId: 'cw-filter-widget',
radioName: 'cw-filter',
filters: [
{ id: 'all', label: allLabel, checked: true },
{ id: 'linkchange', label: linkChangesLabel, tooltip: linkChangesTooltip },
...buildIndicatorFilters()
],
onFilter: applyChangeListFilter,
initialLabel: loadingLabel
});
target.parentNode.insertBefore(container, target);
updateFilterRadios('cw-filter', () => false); // Disabled until annotation is complete
return container;
}
/**
* Creates and inserts a filter widget for the article surface, placed after
* the section heading. Excludes the "Link Changes" option.
*
* @param {Element} headingContainer - The heading element to insert the widget after.
*/
function createArticleFilterWidget(headingContainer) {
const container = buildFilterWidgetContainer({
containerId: 'cw-article-filter-widget',
radioName: 'cw-article-filter',
filters: [
{ id: 'all', label: allLabel, checked: true },
...buildIndicatorFilters()
],
onFilter: applyArticleFilter
});
headingContainer.after(container);
updateFilterRadios('cw-article-filter', (filterId) =>
!!document.querySelector(`li[id^="cite_note"][data-cw-indicator="${filterId}"]`));
renderLoadWarnings();
}
/**
* Called after each annotation pass on a change list: restores the widget
* label, renders any load warnings, and enables the radios that have matches.
*/
function finishChangeListPass() {
const filterLabel = document.querySelector('#cw-filter-widget .cw-filter-label');
if (filterLabel && filterLabel.firstChild &&
filterLabel.firstChild.nodeType === Node.TEXT_NODE) {
filterLabel.firstChild.textContent = showEditsLabel;
}
renderLoadWarnings();
const rows = document.querySelectorAll('[data-mw-revid]:not(.mw-userlink)');
updateFilterRadios('cw-filter', (filterId) =>
Array.from(rows).some(row => rowMatchesFilter(row, filterId)));
// Re-apply the active filter so newly loaded rows respect it
const active = document.querySelector('input[name="cw-filter"]:checked');
if (active && active.value !== 'all') applyChangeListFilter(active.value);
}
/**
* Whether a change-list row (or any nested revision inside it) matches a filter.
*
* @param {Element} row
* @param {string} filterId
* @returns {boolean}
*/
function rowMatchesFilter(row, filterId) {
if (elementMatchesFilter(row, filterId)) return true;
for (const nested of row.querySelectorAll('li[data-cw-processed="true"]')) {
if (elementMatchesFilter(nested, filterId)) return true;
}
return false;
}
/**
* Applies the selected filter to article section list items.
*
* @param {string} filterId - 'all' or an indicator type.
*/
function applyArticleFilter(filterId) {
document.querySelectorAll('li[id^="cite_note"]').forEach(li => {
li.style.display = (filterId === 'all' || li.dataset.cwIndicator === filterId) ? '' : 'none';
});
}
/**
* Applies the selected filter to the revision list.
*
* @param {string} filterId - 'all', 'linkchange', or an indicator type.
*/
let lastAppliedFilter = 'all';
function applyChangeListFilter(filterId) {
const isAll = filterId === 'all';
const revisions = document.querySelectorAll(
'[data-mw-revid]:not(.mw-userlink), [data-mw-logid]'
);
revisions.forEach(container => {
if (isAll) {
container.style.display = '';
return;
}
// Log entries (data-mw-logid) don't have revision-based attributes to
// match against filters — hide them when any specific filter is active.
const show = container.hasAttribute('data-mw-revid') && rowMatchesFilter(container, filterId);
container.style.display = show ? '' : 'none';
});
// Hide h4 date headers when all their rows are hidden
document.querySelectorAll('div.mw-changeslist h4').forEach(h4 => {
if (isAll) {
h4.style.display = '';
return;
}
const sibling = h4.nextElementSibling;
if (!sibling || sibling.tagName !== 'DIV') return;
const revItems = sibling.querySelectorAll('[data-mw-revid]');
if (revItems.length === 0) return;
h4.style.display = Array.from(revItems).every(el => el.style.display === 'none') ? 'none' : '';
});
const enhanced = document.querySelector('table.mw-enhanced-rc');
if (enhanced) {
// Hide the group header row in each tbody if all other rows are hidden
document.querySelectorAll('table.mw-enhanced-rc tbody').forEach(tbody => {
const rows = tbody.querySelectorAll('tr');
if (rows.length < 2) return;
rows[0].style.display = (!isAll && Array.from(rows).slice(1)
.every(tr => tr.style.display === 'none')) ? 'none' : '';
});
// Expand grouped sections while a specific filter is active, collapse on
// "all". Only on the transition, so switching between two filters leaves
// the expanded state alone.
const wasAll = lastAppliedFilter === 'all';
if (wasAll !== isAll) {
document.querySelectorAll('table.mw-enhanced-rc').forEach(table => {
const checkbox = groupToggleCheckbox(table);
if (checkbox && checkbox.checked === isAll) {
checkbox.checked = !isAll;
checkbox.dispatchEvent(new Event('change'));
}
});
}
}
showNoMatchingEdits(!isAll && Array.from(
document.querySelectorAll('[data-mw-revid]:not(.mw-userlink)')
).every(el => el.style.display === 'none'));
lastAppliedFilter = filterId;
}
/**
* Shows or removes the "No matching edits" message under the filter widget.
*
* @param {boolean} show
*/
function showNoMatchingEdits(show) {
const existing = document.getElementById('cw-no-matching-edits');
if (existing) existing.remove();
if (!show) return;
const msg = makeElement('p', {
text: noMatchingEditsLabel, attrs: { id: 'cw-no-matching-edits' },
style: { margin: '10px 0', fontStyle: 'italic' }
});
const widget = document.getElementById('cw-filter-widget');
if (widget && widget.nextSibling) {
widget.parentNode.insertBefore(msg, widget.nextSibling);
}
}
/**
* Returns the hidden checkbox that expands/collapses an enhanced-RC group.
*
* @param {Element} table - A table.mw-enhanced-rc element.
* @returns {HTMLInputElement|null}
*/
function groupToggleCheckbox(table) {
const arrow = table.querySelector('.mw-enhancedchanges-arrow');
const checkboxId = arrow && arrow.getAttribute('for');
return checkboxId ? document.getElementById(checkboxId) : null;
}
/**
* If any configuration pages failed to load, appends a warning glyph (with
* the details in its tooltip) to every filter-widget label on the page.
* Safe to call more than once.
*/
function renderLoadWarnings() {
const messages = SHOW_LINT_IN_WIDGET ? [...loadWarnings, ...listLint] : loadWarnings;
if (messages.length === 0) return;
document.querySelectorAll('.cw-filter-label').forEach(label => {
if (label.querySelector('.cw-load-warning')) return;
label.appendChild(makeElement('span', {
className: 'cw-load-warning', text: ' ' + loadWarningGlyph,
title: loadWarningPrefix + '\n' + messages.join('\n'), style: { cursor: 'help' }
}));
});
}
/**
* Whether the single document-level outside-click handler has been installed.
* One delegated listener replaces the former one-listener-per-gutter design.
*/
let outsideClickHandlerInstalled = false;
/**
* Installs a single document-level click handler that hides every open popup
* whose owning gutter does not contain the click target.
*/
function installOutsideClickHandler() {
if (outsideClickHandlerInstalled) return;
document.addEventListener('click', (ev) => {
document.querySelectorAll('.cw-popup').forEach(p => {
if (p.style.display === 'none' || p.style.display === '') return;
const owner = p.parentElement;
if (owner && !owner.contains(ev.target)) {
p.style.display = 'none';
}
});
});
outsideClickHandlerInstalled = true;
}
/**
* Installs hover, click, touch, and outside-click handlers on a gutter element
* to show/hide its popup.
*
* @param {HTMLElement} gutter - The gutter element containing a .cw-popup child.
*/
function installPopupHandlers(gutter) {
if (gutter._cwHandlersInstalled) return;
let hideTimer = null;
const positionPopup = () => {
const p = gutter.querySelector('.cw-popup');
if (!gutter._cwEnablePopup || !p) return;
p.style.display = 'block';
p.style.top = '1.4em';
p.style.left = '0px';
const rect = p.getBoundingClientRect();
if (rect.right > window.innerWidth - 10) {
const overflow = rect.right - (window.innerWidth - 10);
p.style.left = Math.max(0, (parseFloat(p.style.left) || 0) - overflow) + 'px';
}
};
const show = () => {
if (!gutter._cwEnablePopup) return;
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
positionPopup();
};
const scheduleHide = () => {
hideTimer = setTimeout(() => {
const p = gutter.querySelector('.cw-popup');
if (p) p.style.display = 'none';
}, 150);
};
gutter.addEventListener('mouseenter', show);
gutter.addEventListener('mouseleave', scheduleHide);
const pInit = gutter.querySelector('.cw-popup');
if (pInit) {
pInit.addEventListener('mouseenter', () => {
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
});
pInit.addEventListener('mouseleave', scheduleHide);
}
const toggle = (e) => {
if (!gutter._cwEnablePopup) return;
e.stopPropagation();
const p = gutter.querySelector('.cw-popup');
if (!p) return;
if (p.style.display === 'none' || p.style.display === '') {
show();
} else {
p.style.display = 'none';
}
};
gutter.addEventListener('click', toggle);
gutter.addEventListener('touchstart', toggle, {
passive: true
});
installOutsideClickHandler();
gutter._cwHandlersInstalled = true;
}
/**
* Watches for the change list being replaced by MediaWiki's live filters
* ("View new changes", filter edits on Special:Watchlist / RecentChanges)
* and annotates the new rows. The existing widget sits outside the replaced
* element and survives; its radios are refreshed after each pass.
*/
function installLiveReloadObserver() {
if (typeof MutationObserver === 'undefined') return;
let timer = null;
let running = false;
let rerun = false;
const run = async () => {
if (running) {
rerun = true;
return;
}
running = true;
try {
await annotateChangeList(document, false);
} catch (e) {
console.warn('Citation Watchlist: live reload pass failed:', e);
} finally {
running = false;
if (rerun) {
rerun = false;
run();
}
}
};
const observer = new MutationObserver(mutations => {
const relevant = mutations.some(m => Array.from(m.addedNodes).some(node =>
node.nodeType === Node.ELEMENT_NODE &&
(node.matches('[data-mw-revid], .mw-changeslist, .mw-changeslist-line') ||
node.querySelector('[data-mw-revid], .mw-changeslist-line'))
));
if (!relevant) return;
clearTimeout(timer);
timer = setTimeout(run, LIVE_RELOAD_DEBOUNCE_MS);
});
observer.observe(document.body, { childList: true, subtree: true });
}
const enabledNsStrings = new Set(enabledNamespaces.filter(n => n >= 0).map(String));
const namespaces = Object.entries(mw.config.get('wgFormattedNamespaces'))
.filter(([num, name]) => num !== '0' && !enabledNsStrings.has(num))
.map(([_, name]) => name.replace(/ /g, '_') + ':');
/**
* Pre-pads an element with empty emoji gutters to prevent layout shift.
*
* @param {Element} element - The DOM element to pre-pad.
*/
function prePadElement(element) {
try {
renderTwoColumnEmojis(element, null, false, [], []);
} catch (e) {
/* ignore */
}
}
/**
* ==========================================================================
* 3. Builders and Extractors
* ==========================================================================
*/
/**
* Constructs a URL for viewing a diff between two revisions.
*
* @param {string} title - The page title.
* @param {string} fromId - The older revision ID.
* @param {string} toId - The newer revision ID.
* @returns {string} The diff URL, or empty string on error.
*/
function buildDiffUrl(title, fromId, toId) {
try {
const script = mw.config.get('wgScript') || '/w/index.php';
return `${script}` +
`?title=${encodeURIComponent(title)}` +
`&diff=${encodeURIComponent(toId)}` +
`&oldid=${encodeURIComponent(fromId)}`;
} catch (e) {
return '';
}
}
/**
* Constructs a permalink URL for a specific revision.
*
* @param {string} revId - The revision ID.
* @returns {string} The permalink URL, or empty string on error.
*/
function buildPermalinkUrl(revId) {
try {
const articlePath = mw.config.get('wgArticlePath');
if (articlePath && articlePath.includes('$1')) {
return articlePath.replace('$1',
`Special:Permalink/${encodeURIComponent(revId)}`);
}
const script = mw.config.get('wgScript') || '/w/index.php';
return `${script}?oldid=${encodeURIComponent(revId)}`;
} catch (e) {
return '';
}
}
/**
* Strips all HTML comments from a string.
*
* @param {string} text - Input text potentially containing HTML comments.
* @returns {string} Text with all HTML comments removed.
*/
function stripHTMLComments(text) {
if (!text) return text;
return text.replace(/<!--[\s\S]*?-->/g, '');
}
/**
* Extracts all HTTP(S) URLs from a given wikitext string. The scheme is
* matched case-insensitively (MediaWiki links HTTPS:// just as it does
* https://), and protocol-relative links ("[//example.com ...]") are
* normalized to https://.
*
* @param {string} wikitext - Raw wikitext revision content.
* @returns {string[]} List of valid extracted URLs.
*/
function extractURLs(wikitext) {
const urls = [];
if (!wikitext) return urls;
const urlRegex = // eslint-disable-next-line max-len
/(?:https?:\/\/|(?<=^|[\s\[|=<>"'])\/\/)(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)/gi;
let match;
while ((match = urlRegex.exec(wikitext)) !== null) {
const raw = match[0].startsWith('//') ? 'https:' + match[0] : match[0];
try {
const url = new URL(raw);
if (/^https?:$/.test(url.protocol)) urls.push(url.href);
} catch (error) {
console.error(`Invalid URL rejected: ${match[0]}`, error.message);
}
}
return urls;
}
/**
* Web archive services whose URLs embed the original URL. Each entry's
* `extract` returns the embedded URL string or null.
*/
/** Restores "https:/example.com" (slash-collapsed by some archives) to "https://example.com". */
const fixScheme = s => s.replace(/^(https?:)\/+/i, '$1//');
const archiveUnwrappers = [
{
// https://web.archive.org/web/20200101000000/https://example.com/page
// (the embedded scheme is sometimes collapsed to "https:/")
test: h => h === 'web.archive.org' || h === 'wayback.archive.org',
extract: u => {
const m = u.pathname.match(/^\/web\/\d{1,14}[a-z_]*\/(https?:\/{1,2}.+)$/i);
return m ? fixScheme(m[1]) : null;
}
},
{
// https://archive.blah/2020.01.01-000000/https://example.com/page
test: h => /^archive\.(ph|today|is|li|vn|fo|md)$/i.test(h),
extract: u => {
const m = u.pathname.match(/^\/[^/]+\/(https?:\/{1,2}.+)$/i);
return m ? fixScheme(m[1]) : null;
}
},
{
// https://webcache.googleusercontent.com/search?q=cache:https://example.com/page
test: h => h === 'webcache.googleusercontent.com',
extract: u => {
const q = u.searchParams.get('q') || '';
const m = q.match(/^cache:(?:[A-Za-z0-9_-]+:)?(.+)$/);
if (!m) return null;
return /^https?:\/\//i.test(m[1]) ? m[1] : 'https://' + m[1];
}
}
];
/**
* Resolves a URL to the hostname that should be screened. If the URL is a
* web-archive capture of another page, the captured page's host is used.
*
* @param {string} url
* @returns {{hostname: string, archiveHost: string|null}|null} Null if unparsable.
*/
function screenedHost(url) {
let current;
try {
current = new URL(url);
} catch (e) {
return null;
}
let archiveHost = null;
for (let depth = 0; depth < 3 && UNWRAP_ARCHIVES; depth++) {
const unwrapper = archiveUnwrappers.find(a => a.test(current.hostname));
if (!unwrapper) break;
const inner = unwrapper.extract(current);
if (!inner) break;
try {
const innerUrl = new URL(inner);
if (!/^https?:$/.test(innerUrl.protocol)) break;
archiveHost = archiveHost || current.hostname;
current = innerUrl;
} catch (e) {
break;
}
}
return { hostname: current.hostname, archiveHost };
}
/**
* Resolves URLs to screened domains and list indicators.
*
* @param {string[]} urls
* @returns {Array<{url: string, domain: string, indicator: string|null, archiveHost: string|null}>}
*/
function resolveURLs(urls) {
const out = [];
for (const url of urls || []) {
const host = screenedHost(url);
if (!host) continue;
const domain = getRootDomain(host.hostname, publicSuffixSet);
if (!domain) continue;
out.push({ url, domain, indicator: highestIndicatorFor(domain), archiveHost: host.archiveHost });
}
return out;
}
/**
* Returns the highest-priority list indicator that contains a domain.
*
* @param {string} domain
* @returns {string|null}
*/
function highestIndicatorFor(domain) {
let chosen = null;
for (const type of listedTypes) {
if (indicators[type].list.has(domain) &&
(!chosen || indicators[type].priority > indicators[chosen].priority)) {
chosen = type;
}
}
return chosen;
}
/**
* Returns the highest-priority type among a list of indicator types.
*
* @param {Array<string|null>} types
* @returns {string|null}
*/
function highestPriority(types) {
let chosen = null;
for (const t of types) {
if (t && (!chosen || indicators[t].priority > indicators[chosen].priority)) chosen = t;
}
return chosen;
}
/**
* Extracts the top-level domain from a full hostname using a public suffix set.
*
* @param {string} hostname - Full hostname (e.g., sub.example.co.uk).
* @param {Set<string>} publicSuffixSet - Set of known public suffixes.
* @returns {string} The top-level domain (e.g., example.co.uk).
*/
function getRootDomain(hostname, publicSuffixSet) {
// Handle empty or invalid hostnames
if (!hostname || typeof hostname !== 'string') {
console.warn('Invalid hostname provided to getRootDomain:', hostname);
return '';
}
// Check if this is an IP address (simple check for IPv4)
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(hostname)) {
return hostname; // Return IP addresses as-is
}
const domainParts = hostname.split('.');
// Handle hostnames that are too short
if (domainParts.length < 2) {
return hostname; // Return as-is if it's a single-part hostname
}
// Try to find a matching public suffix
for (let i = 0; i < domainParts.length; i++) {
const candidate = domainParts.slice(i).join('.');
// Check both normal and exception (prefixed with !) entries
if (publicSuffixSet.has(candidate) || publicSuffixSet.has(`!${candidate}`)) {
// If we found a match, return the domain part plus the public suffix
// But make sure we don't go out of bounds
if (i > 0) {
return domainParts.slice(i - 1).join('.');
} else {
// Edge case: the entire hostname is a public suffix
return hostname;
}
}
}
// If no match in public suffix list, use a simple fallback:
// For hostnames with 2 parts, return the whole thing
// For hostnames with >2 parts, return the last 2 parts
if (domainParts.length === 2) {
return hostname;
} else {
return domainParts.slice(-2).join('.');
}
}
/**
* Extracts the first page object from MediaWiki API query response.
*
* @param {Object} data - MediaWiki API response.
* @returns {Object|null} The first page object or null if unavailable.
*/
function getFirstPage(data) {
if (!data || !data.query || !data.query.pages) return null;
return Object.values(data.query.pages)[0] || null;
}
/**
* Iterates every revision object in an API response, across all pages.
* (A revids= query spanning several articles returns several page objects.)
*
* @param {Object|null} data - MediaWiki API response.
* @returns {Object[]} Revision objects.
*/
function allRevisionsIn(data) {
if (!data || !data.query || !data.query.pages) return [];
const out = [];
for (const page of Object.values(data.query.pages)) {
if (page && Array.isArray(page.revisions)) out.push(...page.revisions);
}
return out;
}
/**
* Returns the main-slot wikitext of a revision object, or null if the
* revision is missing, hidden, or malformed.
*
* @param {Object} rev - Revision object from the API.
* @returns {string|null}
*/
function revisionText(rev) {
if (!rev || rev.texthidden !== undefined) return null;
const slot = rev.slots && rev.slots.main;
if (!slot || typeof slot['*'] !== 'string') return null;
return slot['*'];
}
/**
* Returns true if an API page object represents a page that cannot be
* read (missing, invalid, or without an accessible revision).
*
* @param {Object} page - Page object from the API.
* @returns {boolean}
*/
function isUnreadablePage(page) {
return !page || page.missing !== undefined || page.invalid !== undefined ||
!Array.isArray(page.revisions) || page.revisions.length === 0;
}
/**
* ==========================================================================
* 4. Validation
* ==========================================================================
*/
/**
* Validates that a string is a well-formed domain name.
*
* @param {string} str - The string to validate.
* @returns {boolean} True if the string is a valid domain.
*/
function isValidDomain(str) {
if (!str || str.length === 0 || str.length > 253) return false;
if (!/^[a-zA-Z0-9._-]+$/.test(str)) return false;
if (!str.includes('.')) return false;
if (/\.\./.test(str) || str.startsWith('.') || str.endsWith('.')) return false;
const labels = str.split('.');
if (labels.some(l => l.length === 0 || l.length > 63)) return false;
return true;
}
/**
* Validates that a string is a well-formed public suffix list entry.
* Allows wildcard entries (e.g., *.ck) and exception entries (e.g., !www.ck).
*
* @param {string} str - The string to validate.
* @returns {boolean} True if the string is a valid PSL entry.
*/
function isValidSuffixEntry(str) {
const cleaned = str.replace(/^[!*.]/, '');
return /^[a-zA-Z0-9._-]+$/.test(cleaned) && cleaned.length > 0 && cleaned.length <= 253;
}
/**
* Validates that a value is a string parseable as an absolute http(s) URL.
* Used to re-validate anything read back from localStorage before it is
* assigned to an href.
*
* @param {*} u - The value to validate.
* @returns {boolean} True if the value is an http(s) URL.
*/
function isValidHttpUrl(u) {
if (typeof u !== 'string') return false;
try {
return /^https?:$/i.test(new URL(u).protocol);
} catch (e) {
return false;
}
}
/**
* Validates that a value is a plausible revision ID (a positive integer
* string). Used before an ID is put into an API request or a cache key.
*
* @param {*} id - The value to validate.
* @returns {boolean}
*/
function isRevisionId(id) {
return typeof id === 'string' && /^[1-9][0-9]{0,15}$/.test(id);
}
/**
* Validates that a string is a plausible MediaWiki page title.
* Rejects titles with invalid characters, excessive length, or suspicious patterns.
*
* @param {string} title - The page title to validate.
* @returns {boolean} True if the title looks like a valid page link.
*/
function isValidPageTitle(title) {
if (typeof title !== 'string') return false;
if (title.length === 0 || title.length > 255) return false;
if (/[\[\]{}|#<>\n\r\t]/.test(title)) return false;
if (title.includes('..')) return false;
if (title.startsWith(':') || title.startsWith(' ') || title.endsWith(' ')) return false;
return true;
}
/**
* Determines whether a given page title does *not* belong to the main or draft namespaces.
* A missing title is treated as "not an article" so the row is skipped rather
* than crashing the run.
*
* @param {string} pageTitle - The title of the page.
* @returns {boolean} True if not an article namespace.
*/
function isNotArticle(pageTitle) {
if (typeof pageTitle !== 'string') return true;
return namespaces.some(namespace => pageTitle.startsWith(namespace));
}
/**
* Checks whether an element matches the specified filter.
*
* @param {Element} element - The DOM element to test.
* @param {string} filterId - The active filter ID.
* @returns {boolean} True if the element matches the filter.
*/
function elementMatchesFilter(element, filterId) {
if (filterId === 'linkchange') {
return element.hasAttribute('data-cw-linkchange') ||
listedTypes.some(type => element.hasAttribute(indicators[type].dataAttr));
}
if (indicators[filterId] && indicators[filterId].dataAttr) {
return element.hasAttribute(indicators[filterId].dataAttr);
}
return false;
}
/**
* ==========================================================================
* 5. Fetch
* ==========================================================================
*/
/**
* Fetches revisions by ID and returns them keyed by revision ID (as strings),
* across all pages in the response.
*
* @param {string[]} revIds
* @param {string[]} rvprop - Properties to request (always includes 'ids').
* @returns {Promise<Map<string, Object>|null>} Map of id -> revision object,
* or null if the request failed.
*/
async function fetchRevisionsById(revIds, rvprop) {
const data = await fetchRevisionData({
revids: revIds,
rvprop: unique(['ids', ...rvprop]),
rvslots: ['main']
});
if (!data) return null;
const out = new Map();
for (const rev of allRevisionsIn(data)) {
if (rev && rev.revid !== undefined) out.set(String(rev.revid), rev);
}
return out;
}
/**
* Fetches wikitext for a chunk of revision IDs in one request.
*
* @param {string[]} revIds - Revision IDs (at most CONTENT_CHUNK_SIZE).
* @returns {Promise<Map<string, string|null>|null>} Map of id -> wikitext
* (null for a revision that exists but is unreadable), or null if the
* request itself failed. IDs the API did not return are absent.
*/
async function fetchRevisionTexts(revIds) {
const revs = await fetchRevisionsById(revIds, ['content']);
if (!revs) return null;
return new Map(Array.from(revs, ([id, rev]) => [id, revisionText(rev)]));
}
/**
* Fetches parent IDs and sizes for a list of revision IDs, chunked at the
* API maximum and fetched concurrently.
*
* @param {string[]} revisionIds - Revision IDs.
* @returns {Promise<Object>} Map of id -> {parentid, size}; IDs that could
* not be resolved are absent.
*/
async function fetchRevisionMetadata(revisionIds) {
const result = {};
const chunks = [];
for (let i = 0; i < revisionIds.length; i += METADATA_CHUNK_SIZE) {
chunks.push(revisionIds.slice(i, i + METADATA_CHUNK_SIZE));
}
await runPool(chunks.map(chunk => async () => {
const revs = await fetchRevisionsById(chunk, ['size']);
for (const [id, rev] of revs || []) {
result[id] = {
parentid: rev.parentid || 0,
size: typeof rev.size === 'number' ? rev.size : undefined
};
}
}), CONTENT_CONCURRENCY);
return result;
}
/**
* Fetches a page's current wikitext (main slot) by title.
*
* @param {string} pageTitle
* @returns {Promise<string|null>} Null if the page is missing, unreadable, or the request failed.
*/
async function fetchPageWikitext(pageTitle) {
if (!isValidPageTitle(pageTitle)) return null;
const page = getFirstPage(await fetchRevisionData({
titles: [pageTitle],
rvprop: ['content'],
rvslots: ['main']
}));
return isUnreadablePage(page) ? null : revisionText(page.revisions[0]);
}
/**
* Fetches the ID of the first revision of a page.
*
* @param {string} pageTitle - The page title to look up.
* @returns {number|null} Revision ID or null.
*/
async function fetchFirstRevisionId(pageTitle) {
if (!isValidPageTitle(pageTitle)) return null;
const page = getFirstPage(await fetchRevisionData({
titles: [pageTitle],
rvlimit: 1,
rvdir: 'newer',
rvprop: ['ids'],
}));
if (isUnreadablePage(page)) return null;
return page.revisions[0].revid || null;
}
/**
* Fetches the list of subpages from the list of lists, parses wikilinks, caches
* the result, and returns list of subpage titles.
*
* @param {string} pageName - Title of the list-of-lists page.
* @returns {Promise<string[]>} List of subpage titles (empty on failure).
*/
async function fetchDomainListPages(pageName) {
const cacheKey = `citationWatchlistFetchDomainListPages_${pageName}`;
const cachedContent = readCache(cacheKey);
if (Array.isArray(cachedContent) && cachedContent.length > 0) {
console.log('Loaded list of lists from cache');
return cachedContent.filter(title => isValidPageTitle(title));
}
const content = await fetchPageWikitext(pageName);
if (content === null) {
console.error(`Citation Watchlist: list of lists "${pageName}" could not be loaded`);
return [];
}
const pageTitles = [];
for (const line of content.split('\n')) {
if (!line.startsWith('* [[')) continue;
const match = line.match(/\[\[([^\]]+)\]\]/); // first [[Page Title]]
if (!match) continue;
const title = match[1];
if (isValidPageTitle(title)) {
pageTitles.push(title);
} else {
console.warn(`Rejected invalid page title: "${title}"`);
}
}
if (pageTitles.length > 0) {
writeCache(cacheKey, pageTitles);
console.log('Loaded from API and stored in cache');
}
return pageTitles;
}
/**
* Parses one domain list page into per-indicator domain arrays.
* Entries that are not bare domains are collected as lint. A leading "www."
* is stripped (with a lint note), since root domains never carry it.
*
* @param {string} title - Page title, for lint messages.
* @param {string} content - Page wikitext.
* @returns {Object} {warning: string[], caution: string[], inspect: string[], lint: string[]}
*/
function parseDomainList(title, content) {
const parsed = { lint: [] };
for (const type of listedTypes) parsed[type] = [];
let current = null;
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (/^==/.test(trimmed)) {
// Any heading ends the current section; only recognized ones open a new one.
current = listedTypes.find(t => indicators[t].section === trimmed) || null;
continue;
}
if (!line.startsWith('*') || !current) continue;
let domain = line.substring(1).trim().toLowerCase();
if (!domain) continue;
if (/^www\./.test(domain) && isValidDomain(domain.slice(4))) {
parsed.lint.push(`${title}: "${domain}" has a www. prefix; matched as "${domain.slice(4)}"`);
domain = domain.slice(4);
}
if (isValidDomain(domain)) {
if (parsed[current].length < MAX_DOMAINS_PER_LIST) {
parsed[current].push(domain);
} else {
parsed.lint.push(`${title}: cap of ${MAX_DOMAINS_PER_LIST} reached in ${current}`);
}
} else {
parsed.lint.push(`${title}: "${domain}" is not a bare domain (paths, wildcards and ` +
`bare TLDs are not supported) and was ignored`);
}
}
return parsed;
}
/**
* Loads domain lists from a set of pages (parsed form cached per page) and
* populates the `Set` of each indicator. Pages that are missing or fail to
* load are skipped and recorded in loadWarnings; the remaining lists still load.
*
* @param {string[]} pageNames - List of page titles to fetch.
*/
async function fetchAndOrganizeDomainLists(pageNames) {
const parsedLists = [];
const pagesToFetch = [];
const isParsed = p => p && typeof p === 'object' && listedTypes.every(t => Array.isArray(p[t]));
for (const title of pageNames) {
const cached = readCache(`domainList:${location.hostname}:${title}`);
if (isParsed(cached)) {
parsedLists.push(cached);
} else {
pagesToFetch.push(title);
}
}
if (pagesToFetch.length > 0) {
const apiData = await fetchRevisionData({
titles: pagesToFetch,
rvprop: ['content'],
rvslots: ['main'],
});
const pages = (apiData && apiData.query && apiData.query.pages) || null;
if (!pages) {
loadWarnings.push(`API request for ${pagesToFetch.length} list page(s) failed`);
console.error('Citation Watchlist: domain list fetch failed for', pagesToFetch);
} else {
// The API normalizes titles (underscores -> spaces); cache under the
// requested spelling so the next load hits.
const requestedFor = {};
for (const t of pagesToFetch) requestedFor[t.replace(/_/g, ' ')] = t;
for (const page of Object.values(pages)) {
const title = page.title || '?';
if (isUnreadablePage(page)) {
loadWarnings.push(`List page "${title}" is missing or unreadable`);
console.warn(`Citation Watchlist: skipping list page "${title}"`);
continue;
}
const content = revisionText(page.revisions[0]);
if (content === null) {
loadWarnings.push(`List page "${title}" has no readable content`);
continue;
}
const parsed = parseDomainList(title, content);
parsedLists.push(parsed);
const cacheTitle = requestedFor[title] || title;
writeCache(`domainList:${location.hostname}:${cacheTitle}`, parsed);
}
}
}
for (const parsed of parsedLists) {
for (const type of listedTypes) {
for (const domain of parsed[type]) {
if (isValidDomain(domain)) indicators[type].list.add(domain);
}
}
if (Array.isArray(parsed.lint)) {
listLint.push(...parsed.lint.filter(l => typeof l === 'string'));
}
}
}
/**
* Fetches and caches the public suffix list used to identify top-level domains.
* The cache holds the validated entries, so cached loads skip parsing.
*
* @returns {Promise<Set<string>>} Set of public suffixes.
*/
async function fetchPublicSuffixList() {
const cacheKey = 'publicSuffixListCache';
const cached = readCache(cacheKey);
if (Array.isArray(cached) && cached.length >= MIN_PSL_ENTRIES) {
console.log('Using cached public suffix list');
return new Set(cached.filter(e => typeof e === 'string' && isValidSuffixEntry(e)));
}
const pslUrl = mw.config.get('wgArticlePath').replace('$1', publicSuffixList) +
'?action=raw';
console.log(`Raw page text request: ${pslUrl}`);
const content = await safeFetch(fetch, pslUrl).then(response => response ?
response.text() : null);
if (!content) return new Set();
const entries = [];
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith('//') && isValidSuffixEntry(trimmed)) {
entries.push(trimmed);
}
}
if (entries.length < MIN_PSL_ENTRIES) {
console.error(`Public suffix list suspiciously small (${entries.length} entries), rejecting`);
return new Set();
}
// Only cache a list that passed validation
writeCache(cacheKey, entries);
return new Set(entries);
}
/**
* Makes a MediaWiki API call to fetch revision metadata or content.
*
* @param {Object} data - Options for the API call, such as `revids`, `titles`, `rvprop`, etc.
* @returns {Promise<Object|null>} MediaWiki API query result, or null on failure.
*/
async function fetchRevisionData(data) {
const paramKeys = ['rvprop', 'revids', 'titles', 'rvslots'];
const params = {
action: 'query',
prop: 'revisions',
format: 'json',
rvdir: data.rvdir || 'older',
origin: '*'
};
if (data.rvlimit) {
params.rvlimit = data.rvlimit;
}
paramKeys.forEach(key => {
if (data[key]) {
params[key] = Array.isArray(data[key]) ? data[key].join('|') : data[key];
}
});
const api = new mw.Api();
return await safeFetch(api.get.bind(api), params);
}
/**
* Wraps any asynchronous fetch function with retry logic and error handling.
*
* @param {Function} fn - The function to execute (usually an API call).
* @param {...any} args - Arguments to pass to the fetch function.
* @returns {Promise<any|null>} Result of the fetch or null on failure.
*/
async function safeFetch(fn, ...args) {
const retries = 2;
const retryDelay = 1000;
const maxAttempts = retries + 1;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const result = await fn(...args);
if (result === null || result === undefined) {
throw new Error('Received null or undefined response');
}
if (typeof result.ok === 'boolean' && !result.ok) {
throw new Error(`HTTP error ${result.status}: ${result.statusText || 'Unknown error'}`);
}
return result;
} catch (error) {
if (attempt < maxAttempts) {
console.warn(
`Error during ${fn.name || 'fetch operation'} (attempt ${attempt}/${maxAttempts}):`,
error.message || error);
await new Promise(resolve => setTimeout(resolve, retryDelay));
} else {
console.error(
`All ${maxAttempts} attempts failed for ${fn.name || 'fetch operation'}:`,
error.message || error);
}
}
}
return null;
}
/**
* ==========================================================================
* 6. Cache
* ==========================================================================
*/
/**
* Returns every localStorage key with the given prefix. Snapshotting the keys
* first means callers can remove entries without the index shifting under them.
*
* @param {string} prefix - Key prefix to match.
* @returns {string[]}
*/
function cacheKeysWithPrefix(prefix) {
const keys = [];
try {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith(prefix)) keys.push(key);
}
} catch (e) {
/* storage unavailable */
}
return keys;
}
/**
* Removes every localStorage key with the given prefix.
*
* @param {string} prefix
*/
function removeKeysWithPrefix(prefix) {
for (const key of cacheKeysWithPrefix(prefix)) localStorage.removeItem(key);
}
/**
* Runs all cache maintenance: retired prefixes, version invalidation, and
* (at most once per CACHE_PURGE_INTERVAL) expiry and eviction.
*/
function purgeCaches() {
try {
purgeStaleVersionedCaches();
const lastKey = 'citationWatchlist_lastPurge';
const last = parseInt(localStorage.getItem(lastKey), 10);
if (isNaN(last) || Date.now() - last > CACHE_PURGE_INTERVAL) {
purgeExpiredCache();
localStorage.setItem(lastKey, Date.now().toString());
}
} catch (e) {
console.warn('Citation Watchlist: cache maintenance failed:', e);
}
}
/**
* Removes caches from retired prefixes and purges cached entries whose stored
* version is older than the current version. Each cache type tracks its own
* version independently via a localStorage key.
*/
function purgeStaleVersionedCaches() {
for (const prefix of retiredCachePrefixes) {
removeKeysWithPrefix(prefix);
localStorage.removeItem(`citationWatchlist_cacheVersion:${prefix}`);
}
for (const cache of cacheConfig) {
const versionKey = `citationWatchlist_cacheVersion:${cache.prefix}`;
const storedVersion = parseInt(localStorage.getItem(versionKey), 10);
const effectiveVersion = isNaN(storedVersion) ? 0 : storedVersion;
if (effectiveVersion < cache.version) {
removeKeysWithPrefix(cache.prefix);
try {
localStorage.setItem(versionKey, cache.version.toString());
} catch (e) {
console.warn('Cache version write error:', e);
}
console.log(`Cache invalidated for "${cache.prefix}": version ${effectiveVersion} → ${cache.version}`);
}
}
}
/**
* Removes expired localStorage cache entries and enforces per-cache entry
* caps by evicting the oldest entries.
*/
function purgeExpiredCache() {
const now = Date.now();
const stats = { checked: 0, expired: 0, evicted: 0, errors: 0 };
for (const cache of cacheConfig) {
// Snapshot keys first so removals do not shift the iteration index
const survivors = []; // { key, timestamp }
for (const key of cacheKeysWithPrefix(cache.prefix)) {
stats.checked++;
try {
const value = localStorage.getItem(key);
let parsed = null;
try {
parsed = value ? JSON.parse(value) : null;
} catch (jsonError) {
stats.errors++;
}
if (!parsed || typeof parsed.timestamp !== 'number' || now - parsed.timestamp > cache.ttl) {
localStorage.removeItem(key);
stats.expired++;
continue;
}
survivors.push({ key, timestamp: parsed.timestamp });
} catch (itemError) {
stats.errors++;
try {
localStorage.removeItem(key);
} catch (e) { /* ignore */ }
}
}
if (cache.maxEntries && survivors.length > cache.maxEntries) {
survivors.sort((a, b) => a.timestamp - b.timestamp);
const excess = survivors.length - cache.maxEntries;
for (let i = 0; i < excess; i++) {
localStorage.removeItem(survivors[i].key);
stats.evicted++;
}
}
}
if (stats.checked > 0) {
console.log(`Cache cleanup: checked ${stats.checked}, expired ${stats.expired}, ` +
`evicted ${stats.evicted}, errors ${stats.errors}`);
}
}
/**
* Looks up the cache configuration whose prefix matches a key.
*
* @param {string} key - A cache key or prefix.
* @returns {Object|undefined}
*/
function cacheConfigFor(key) {
return cacheConfig.find(c => key.startsWith(c.prefix));
}
/**
* Reads a JSON cache entry from localStorage, returning the stored content
* if it exists and has not expired (TTL taken from the key's cacheConfig
* entry). Callers must validate the shape of the returned content.
*
* @param {string} key - The localStorage key.
* @returns {*|null} The cached content, or null if missing/expired/invalid.
*/
function readCache(key) {
const config = cacheConfigFor(key);
let raw;
try {
raw = config ? localStorage.getItem(key) : null;
} catch (e) {
return null;
}
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed.timestamp === 'number' &&
(Date.now() - parsed.timestamp < config.ttl) && parsed.content !== undefined) {
return parsed.content;
}
} catch (e) {
console.warn('Cache read error:', e);
}
return null;
}
/**
* Writes a JSON cache entry to localStorage with an embedded timestamp.
* Failures (e.g. quota exceeded) are logged and otherwise ignored; the
* cache is an optimization, never a dependency.
*
* @param {string} key - The localStorage key.
* @param {*} content - The data to cache.
*/
function writeCache(key, content) {
try {
localStorage.setItem(key, JSON.stringify({ timestamp: Date.now(), content }));
} catch (e) {
console.warn('Cache write error:', e);
}
}
/**
* ==========================================================================
* 7. Entry Point
* ==========================================================================
*/
analyzeView().catch(e => {
console.error('Citation Watchlist: unrecoverable error:', e);
loadWarnings.push('Citation Watchlist stopped early due to an error (see console)');
finishChangeListPass();
}).then(() => {
console.log('Citation Watchlist script finished executing');
// On enhanced RC pages, hide the summary gutter on a group's header row
// while the group is expanded (each nested row then shows its own).
document.querySelectorAll('table.mw-enhanced-rc').forEach(table => {
const checkbox = groupToggleCheckbox(table);
if (!checkbox) return;
checkbox.addEventListener('change', () => {
const topRow = table.querySelector('tr');
const gutter = topRow && topRow.querySelector('.cw-gutter');
if (!gutter) return;
const host = gutter.parentElement;
gutter.style.display = checkbox.checked ? 'none' : 'inline-flex';
if (host) host.style.paddingLeft = checkbox.checked ? '' : 'calc(2em + 0.5em)';
});
});
});
})();
qwum2ksk0lp4ei2ptxo4qo72sem4fdd
781270
781269
2026-10-27T23:28:19Z
Harej
7994
More correct description
781270
javascript
text/javascript
/**
*
* Citation Watchlist
* https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist
*
*/
(function() {
'use strict';
/**
* ==========================================================================
* LOCALIZATION
* ==========================================================================
*/
/* Labeling tool in the user interface */
const toolName = 'Citation Watchlist';
const aboutLink = 'https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist';
/* Revision inspection popup */
const addedLabel = 'Added';
const removedLabel = 'Removed';
const viewDiffLabel = 'View Diff';
const viewRevisionLabel = 'View Revision';
const aboutLabel = 'About Citation Watchlist';
const archivedViaLabel = 'Archived copy hosted at';
/* Filter widget */
const showEditsLabel = 'Show edits:';
const loadingLabel = 'Loading...';
const allLabel = 'All';
const linkChangesLabel = 'Link Changes';
const linkChangesTooltip = 'Edits that add or remove links in articles';
const noMatchingEditsLabel = 'No matching edits';
/* Load-failure indicator */
const loadWarningGlyph = '⚠';
const loadWarningPrefix = 'Some Citation Watchlist lists could not be loaded:';
/**
* ==========================================================================
* DOMAIN LIST CONFIGURATION
* ==========================================================================
*
* Citation Watchlist requires the following wiki pages to function:
*
* 1. Public Suffix List
* - A local copy of the public suffix list, used for domain parsing.
* - Copy the contents of:
* https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist/Public_Suffix_List
* to a page on your own wiki.
* - Update the `publicSuffixList` variable below to reflect your page title.
*
* 2. List of Lists
* - A page linking to one or more domain list pages.
* - Format as a bullet list: "* [[Page Title]]" (space after asterisk).
* - Reference formatting example:
* https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist/Lists
* - Update the `listOfLists` variable below accordingly.
*
* 3+. Domain List Pages
* - One or more pages listing suspicious or noteworthy domains.
* - Each page must contain section headers that match the `indicators` config
* below (e.g., "==Warn==", "==Caution==").
* - Under each section, list domains in the format: "* example.com"
* - Do not use link formatting—just plain text.
* - Entries with paths ("example.com/opinion"), no dot ("gov"), or a
* leading "www." are not supported and are reported in the console.
*/
const publicSuffixList = 'Wikipedia:Citation_Watchlist/Public_Suffix_List';
const listOfLists = 'Wikipedia:Citation_Watchlist/Lists';
const enabledNamespaces = [-1, 0, 118];
const enabledSectionHeaders = ['References'];
/**
* Detection options.
* - UNWRAP_ARCHIVES: screen the original URL embedded in a web archive link
* (web.archive.org, archive.today, Google cache) rather than the archive
* host. The popup still links to the cited archive URL.
* - SHOW_LINT_IN_WIDGET: also show domain-list lint (ignored entries) in the
* filter widget's warning tooltip. Lint always goes to the console.
*/
const UNWRAP_ARCHIVES = true;
const SHOW_LINT_IN_WIDGET = false;
/**
* ==========================================================================
* INDICATOR CONFIGURATION
* ==========================================================================
*
* Defines metadata for domain indicators used in the watchlist UI.
* Each indicator is associated with a level of urgency and a unique symbol.
*
* Fields:
* - msg: Display label for the level (e.g., "Warning", "Caution").
* - emoji: Unicode character for the visual indicator (escaped as `\uXXXX`).
* - section: Must exactly match the section headers in the domain list pages.
* - priority: Higher values override lower ones for conflicting domain matches.
* Priority scale: 1 (lowest) to N (highest).
* - list: Defined as "new Set()" for all indicator types.
*
* If a domain appears in multiple lists, the one with the highest priority
* takes precedence.
*/
const indicators = {
warning: {
msg: 'Warning',
emoji: '❗',
section: '==Warn==',
explanation: 'This domain is classified as an unreliable source',
priority: 3,
dataAttr: 'data-cw-linkwarn',
list: new Set()
},
caution: {
msg: 'Caution',
emoji: '✋',
section: '==Caution==',
explanation: 'This source may be unreliable depending on context',
priority: 2,
dataAttr: 'data-cw-linkcaution',
list: new Set()
},
inspect: {
msg: 'Inspect',
emoji: '🔎',
section: '==Inspect==',
explanation: 'Take a closer look at this particular source',
priority: 1,
dataAttr: 'data-cw-linkinspect',
list: new Set()
},
added: {
msg: addedLabel,
emoji: '\u{1F517}',
section: null,
explanation: 'All added links',
priority: 0,
list: new Set()
},
removed: {
msg: removedLabel,
emoji: '\u{1F5D1}',
section: null,
explanation: 'All removed links',
priority: -1,
list: new Set()
}
};
/** Indicator types that come from the domain lists (priority > 0). */
const listedTypes = Object.keys(indicators).filter(t => indicators[t].priority > 0);
/**
* Citation Watchlist
*
* Highlights potentially questionable citations added in Wikipedia revisions
* based on community-built lists of domain names
*
*
* Documentation: https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist
*
* Author: James Hare
* License: GNU General Public License v3.0 (GPL-3.0)
*
* @version 2.5
* @since 2026-
*/
/**
* IF YOU WANT TO MODIFY any part of the script below this point, please submit
* your edits to https://test.wikipedia.org/wiki/User:Harej/citation-watchlist.js
* so that your modifications can be tested.
*/
let publicSuffixSet = new Set();
const MAX_DOMAINS_PER_LIST = 50000;
const MIN_PSL_ENTRIES = 1000;
/**
* Revisions larger than this are not scanned. MediaWiki's own page-size
* limit is 2 MiB, so this only trips on pathological cases; it bounds the
* download and regex work done per row.
*/
const MAX_REVISION_CHARS = 2 * 1024 * 1024;
/**
* Revision fetching strategy. Rows are never fetched one at a time: revision
* IDs are grouped into small chunks (so the first chunk returns quickly and
* its rows render while the rest are still in flight), several chunks are
* kept in flight at once, and rows are annotated the moment their chunk
* arrives. Concurrency is capped because origin=* requests are anonymous and
* therefore per-IP rate limited (see release notes for 1.5/1.6).
*/
const CONTENT_CHUNK_SIZE = 8; // max revisions per content request
const CONTENT_CHUNK_BYTES = 400 * 1024; // soft byte budget per chunk when sizes are known
const CONTENT_CONCURRENCY = 4; // simultaneous content requests
const METADATA_CHUNK_SIZE = 50; // revids per ids|size request (API max for users)
const ASSUMED_REVISION_BYTES = 40 * 1024; // chunking estimate when size is unknown
const LIVE_RELOAD_DEBOUNCE_MS = 250; // settle time after the change list is replaced
/**
* Non-fatal problems encountered while loading configuration (missing list
* pages, API failures). Surfaced in the filter widget so list maintainers
* notice quickly, instead of the tool silently running with partial data.
*/
const loadWarnings = [];
/**
* Domain-list lint: entries that were ignored because they are not bare
* domains. Always logged; shown in the widget only if SHOW_LINT_IN_WIDGET.
*/
const listLint = [];
/**
* Unified cache configuration. Each entry defines a cache key prefix, its TTL,
* version number, an optional cap on the number of entries kept, and a
* human-readable description.
*
* - revURLs: per-revision list of extracted URLs. Revisions are immutable,
* so the TTL and cap exist only to bound storage.
* - sectionURLs: per-revision, per-section URL map from rendered article HTML.
* - domainList: parsed domain lists ({warning: [...], caution: [...], ...}).
* - publicSuffixListCache: validated PSL entries as an array.
* - citationWatchlistFetchDomainListPages_: list-of-lists page titles.
*/
const cacheConfig = [
{
prefix: 'revURLs:',
ttl: 30 * 24 * 60 * 60 * 1000,
version: 1,
maxEntries: 4000,
description: 'Revision URL cache'
},
{
prefix: 'sectionURLs:',
ttl: 30 * 24 * 60 * 60 * 1000,
version: 1,
maxEntries: 500,
description: 'Article section URL cache'
},
{
prefix: 'domainList:',
ttl: 6 * 60 * 60 * 1000,
version: 1,
description: 'Domain list cache'
},
{
prefix: 'publicSuffixListCache',
ttl: 24 * 60 * 60 * 1000,
version: 1,
description: 'Public suffix list cache'
},
{
prefix: 'citationWatchlistFetchDomainListPages_',
ttl: 4 * 60 * 60 * 1000,
version: 0,
description: 'Domain list pages cache'
}
];
/** Cache prefixes from earlier versions, removed on startup. */
const retiredCachePrefixes = ['revisionDiff:'];
/** How often (ms) the full localStorage expiry/eviction pass runs. */
const CACHE_PURGE_INTERVAL = 60 * 60 * 1000;
/**
* ==========================================================================
* 1. Primary Workflow
* ==========================================================================
*/
/** Resolves once the PSL and domain lists are loaded (true) or failed (false). */
let configReady = null;
/** Rows already handed to processRevisions(), so live reloads only pick up new ones. */
const seenRows = new WeakSet();
/**
* Loads the public suffix list and the domain lists, in parallel. Never
* throws; failures are recorded in loadWarnings.
*
* @returns {Promise<boolean>} True if the tool can screen domains.
*/
async function loadConfiguration() {
try {
const [psl, listPages] = await Promise.all([
fetchPublicSuffixList(),
fetchDomainListPages(listOfLists)
]);
publicSuffixSet = psl;
if (publicSuffixSet.size === 0) {
console.error('Public Suffix List loading failed');
loadWarnings.push('Public Suffix List could not be loaded');
return false;
}
console.log('Welcome to Citation Watchlist');
if (listPages && listPages.length > 0) {
await fetchAndOrganizeDomainLists(listPages);
} else {
loadWarnings.push(`Could not load list of lists (${listOfLists})`);
}
if (listLint.length > 0) {
console.warn(`Citation Watchlist: ${listLint.length} domain-list entries were ignored:\n` +
listLint.join('\n'));
}
return true;
} catch (e) {
console.error('Citation Watchlist: configuration load failed:', e);
loadWarnings.push('Configuration could not be loaded (see console)');
return false;
}
}
/**
* Function called by the entry point for Citation Watchlist.
* Determines if the current page should be analyzed, starts loading the
* configuration, collects the revisions behind each change-list row, and
* fetches and annotates them while the configuration loads.
*/
async function analyzeView() {
purgeCaches();
const ns = mw.config.get('wgNamespaceNumber');
const inspectEnabled = enabledNamespaces.includes(ns);
// Start the configuration load immediately; nothing below waits on it
// until the first row is ready to render.
if (inspectEnabled) {
configReady = loadConfiguration();
}
// Article surface: scan enabled sections for watchlisted domains
const action = mw.config.get('wgAction');
if (action === 'view' && ns >= 0 && inspectEnabled) {
if (await configReady) {
await analyzeArticleSections();
}
return;
}
// Pre-pad change log lines immediately on Special:RecentChanges for visual stability
if (ns === -1) {
document.querySelectorAll('li.mw-changeslist-line, .mw-changeslist-line')
.forEach(line => prePadElement(line));
flushGutterPadding();
}
if (!inspectEnabled) {
return;
}
createChangeListFilterWidget();
await annotateChangeList(document, true);
installLiveReloadObserver();
}
/**
* Collects and annotates every unseen change-list row under `root`.
*
* @param {Document|Element} root - Where to look for rows.
* @param {boolean} allowFirstRevisionFallback - On a page history with no
* diff links (single-revision page), analyze the page's first revision.
*/
async function annotateChangeList(root, allowFirstRevisionFallback) {
const revisions = [];
let noLinks = true;
// Prefer iterating per-change rows when available (JS-enhanced watchlist/RC);
// fall back to link containers on non-enhanced layouts.
const unseen = selector => Array.from(root.querySelectorAll(selector)).filter(el => !seenRows.has(el));
let entries = unseen('[data-mw-revid]:not(.mw-userlink), .mw-changeslist-line');
let prePadCallback = null;
if (entries.length > 0) {
entries.forEach(line => prePadElement(line));
flushGutterPadding();
} else {
entries = unseen('.mw-changeslist-links');
prePadCallback = (_el, link) => {
const target = link.parentNode.parentNode;
prePadElement(target);
flushGutterPadding();
return target;
};
}
for (const entry of entries) {
seenRows.add(entry);
const result = await extractEntry(entry, entry, prePadCallback);
if (result.hasLink) noLinks = false;
if (!result.skip && result.revision) revisions.push(result.revision);
}
// If no links were found, extract the first revision ID
if (allowFirstRevisionFallback && noLinks && entries.length > 0) {
try {
const firstID = await fetchFirstRevisionId(mw.config.get('wgTitle'));
if (firstID) {
revisions.push({
oldrevision: String(firstID),
element: entries[0],
viewHref: buildPermalinkUrl(firstID),
viewLabel: viewRevisionLabel
});
}
} catch (e) {
console.warn('Citation Watchlist: single-revision fallback failed:', e);
}
}
await processRevisions(revisions);
finishChangeListPass();
}
/**
* Extracts the revision behind one change-list entry, isolating failures so
* that a single malformed row does not abort the remaining rows.
*
* @param {Element} container - The DOM element to query for revision links.
* @param {Element} element - The DOM element to attach to the revision object.
* @param {Function|null} prePadCallback - See extractRevisionFromLinks.
* @returns {Promise<{revision: Object|null, hasLink: boolean, skip: boolean}>}
*/
async function extractEntry(container, element, prePadCallback) {
try {
return await extractRevisionFromLinks(container, element, prePadCallback);
} catch (e) {
console.warn('Citation Watchlist: skipping entry due to error:', e);
// Report hasLink=true so a row-level failure never triggers the
// single-revision fallback for the whole page.
return { revision: null, hasLink: true, skip: true };
}
}
/**
* Annotates a set of revisions with as little latency as possible:
*
* 1. Parent IDs (for diff=prev and history rows) and sizes are resolved in
* one metadata request, only when any row needs them.
* 2. Per-revision URL lists already in the cache are used directly.
* 3. Remaining revision IDs are deduplicated (history rows share revisions),
* chunked in DOM order, fetched with bounded concurrency, and each row is
* rendered as soon as every revision it depends on is available. Fetching
* overlaps the configuration load; only rendering waits for it.
*
* @param {Object[]} revisions - Revision objects from extractRevisionFromLinks.
*/
async function processRevisions(revisions) {
if (revisions.length === 0) return;
// --- 1. Resolve parents / sizes ---------------------------------------
const parentLookups = revisions.filter(r => r.needsParentOf).map(r => r.needsParentOf);
let metadata = {};
if (parentLookups.length > 0) {
metadata = await fetchRevisionMetadata(unique(parentLookups));
for (const revision of revisions) {
if (!revision.needsParentOf) continue;
const currentId = revision.needsParentOf;
const meta = metadata[currentId];
delete revision.needsParentOf;
if (meta && meta.parentid) {
revision.oldrevision = String(meta.parentid);
revision.newrevision = currentId;
if (revision.pageTitle && !revision.viewHref) {
revision.viewHref = buildDiffUrl(revision.pageTitle,
revision.oldrevision, revision.newrevision);
}
} else if (meta) {
// First revision of the page: everything in it was "added"
revision.oldrevision = currentId;
revision.newrevision = null;
revision.viewHref = buildPermalinkUrl(currentId);
revision.viewLabel = viewRevisionLabel;
} else {
markSkipped(revision, 'fetch-failed');
revision.oldrevision = null;
}
}
}
// --- 2. Per-revision URL lists from cache; queue the rest --------------
const urlLists = new Map(); // id -> string[] | null (null = unreadable)
const pending = []; // { revision, needs: string[] }
for (const revision of revisions) {
if (revision.skipped) continue;
if (!isRevisionId(revision.oldrevision) ||
(revision.newrevision && !isRevisionId(revision.newrevision))) {
markSkipped(revision, 'invalid');
continue;
}
const needs = [revision.oldrevision];
if (revision.newrevision) needs.push(revision.newrevision);
for (const id of needs) {
if (urlLists.has(id)) continue;
const cached = readCache(`revURLs:${location.hostname}:${id}`);
if (Array.isArray(cached)) urlLists.set(id, cached.filter(isValidHttpUrl));
}
pending.push({ revision, needs });
}
if (pending.length === 0) return;
// --- 3. Fetch missing content in chunks; render as data arrives --------
const neededIds = unique(pending.flatMap(p => p.needs)).filter(id => !urlLists.has(id));
const oversized = new Set();
for (const id of neededIds) {
const meta = metadata[id];
if (meta && typeof meta.size === 'number' && meta.size > MAX_REVISION_CHARS) {
oversized.add(id);
urlLists.set(id, null);
}
}
const chunks = chunkRevisionIds(neededIds.filter(id => !oversized.has(id)), metadata);
let canRender = false;
const settle = () => {
if (!canRender) return;
for (let i = pending.length - 1; i >= 0; i--) {
const item = pending[i];
if (!item.needs.every(id => urlLists.has(id))) continue;
pending.splice(i, 1);
if (item.needs.some(id => oversized.has(id))) {
markSkipped(item.revision, 'oversized');
continue;
}
const oldList = urlLists.get(item.revision.oldrevision);
const newList = item.revision.newrevision ? urlLists.get(item.revision.newrevision) : null;
if (oldList === null || (item.revision.newrevision && newList === null)) {
markSkipped(item.revision, 'fetch-failed');
continue;
}
const diff = computeURLDiff(oldList, newList, !!item.revision.newrevision);
renderRevision(item.revision, diff.addedURLs, diff.removedURLs);
}
flushGutterPadding();
};
const onText = (id, text) => {
if (text === null) {
urlLists.set(id, null);
return;
}
if (text.length > MAX_REVISION_CHARS) {
oversized.add(id);
urlLists.set(id, null);
return;
}
const urls = extractURLs(stripHTMLComments(text));
urlLists.set(id, urls);
writeCache(`revURLs:${location.hostname}:${id}`, urls);
};
// Dispatch content fetches now; they run while the lists are still loading.
const fetching = runPool(chunks.map(chunk => async () => {
await fetchChunkIsolating(chunk, onText);
settle();
}), CONTENT_CONCURRENCY);
if (!(await configReady)) {
await fetching;
return; // no lists: nothing to screen against
}
canRender = true;
settle(); // cache hits and anything that landed while the lists loaded
await fetching;
// Anything still pending had an id the API never returned
for (const item of pending) {
markSkipped(item.revision, 'fetch-failed');
}
}
/**
* Fetches a chunk of revisions; if the request fails outright, splits the
* chunk in half and retries each half, so one problematic revision only
* costs itself rather than its neighbours. Each fetch already carries
* safeFetch's retries, so this only runs in genuine failure cases.
*
* @param {string[]} chunk - Revision IDs.
* @param {Function} onText - Called with (id, text|null) for every id.
*/
async function fetchChunkIsolating(chunk, onText) {
const result = await fetchRevisionTexts(chunk);
if (result) {
for (const id of chunk) {
onText(id, result.has(id) ? result.get(id) : null);
}
return;
}
if (chunk.length === 1) {
onText(chunk[0], null);
return;
}
const mid = Math.ceil(chunk.length / 2);
await fetchChunkIsolating(chunk.slice(0, mid), onText);
await fetchChunkIsolating(chunk.slice(mid), onText);
}
/**
* Groups revision IDs into fetch chunks in order, bounded by count and, when
* sizes are known, by an approximate byte budget so one huge article does not
* delay its neighbours.
*
* @param {string[]} ids - Revision IDs in the order they should be fetched.
* @param {Object} metadata - Map of id -> {parentid, size} (may be sparse).
* @returns {string[][]}
*/
function chunkRevisionIds(ids, metadata) {
const chunks = [];
let current = [];
let currentBytes = 0;
for (const id of ids) {
const meta = metadata[id];
const bytes = (meta && typeof meta.size === 'number') ? meta.size : ASSUMED_REVISION_BYTES;
if (current.length > 0 &&
(current.length >= CONTENT_CHUNK_SIZE || currentBytes + bytes > CONTENT_CHUNK_BYTES)) {
chunks.push(current);
current = [];
currentBytes = 0;
}
current.push(id);
currentBytes += bytes;
}
if (current.length > 0) chunks.push(current);
return chunks;
}
/**
* Runs async task factories with at most `concurrency` in flight, in order.
*
* @param {Array<() => Promise<*>>} tasks
* @param {number} concurrency
* @returns {Promise<void>}
*/
async function runPool(tasks, concurrency) {
let next = 0;
const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, async () => {
while (next < tasks.length) {
const task = tasks[next++];
try {
await task();
} catch (e) {
console.warn('Citation Watchlist: chunk failed:', e);
}
}
});
await Promise.all(workers);
}
/**
* Returns the elements of an array with duplicates removed, first occurrence wins.
*
* @param {Array} arr
* @returns {Array}
*/
function unique(arr) {
return Array.from(new Set(arr));
}
/**
* Marks a revision's row as skipped with a reason, without annotating it.
*
* @param {Object} revision
* @param {string} reason - 'fetch-failed', 'oversized' or 'invalid'.
*/
function markSkipped(revision, reason) {
revision.skipped = reason;
try {
revisionTargetElement(revision).setAttribute('data-cw-skipped', reason);
} catch (e) {
/* ignore */
}
console.warn(`Citation Watchlist: skipped ${revision.oldrevision || '?'}` +
`${revision.newrevision ? ' -> ' + revision.newrevision : ''} (${reason})`);
}
/**
* Finds the row-level element that carries data attributes for a revision.
*
* @param {Object} revision
* @returns {Element}
*/
function revisionTargetElement(revision) {
const el = revision.element;
return el.closest('[data-mw-revid]:not(.mw-userlink)') ||
el.closest('li') || el.closest('.mw-changeslist-line, tr') || el;
}
/**
* Scans the article page for sections whose H2 headers match enabledSectionHeaders,
* then analyzes each matching section for watchlisted domains.
*/
async function analyzeArticleSections() {
const headings = document.querySelectorAll('.mw-heading2 h2, h2');
for (const h2 of headings) {
const headerText = h2.textContent.trim();
if (!enabledSectionHeaders.includes(headerText)) continue;
// Collect all content between this heading and the next heading
const headingContainer = h2.closest('.mw-heading') || h2;
let sibling = headingContainer.nextElementSibling;
const sectionElements = [];
while (sibling && !sibling.classList.contains('mw-heading')) {
sectionElements.push(sibling);
sibling = sibling.nextElementSibling;
}
try {
analyzeSection(headerText, sectionElements);
createArticleFilterWidget(headingContainer);
} catch (e) {
console.warn(`Citation Watchlist: failed to analyze section "${headerText}":`, e);
}
}
}
/**
* Analyzes a single article section: extracts URLs from each <li>, screens them
* against domain lists, and prepends indicator icons to matching list items.
*
* @param {string} sectionName - The section header text (e.g. "References").
* @param {Element[]} sectionElements - DOM elements between this heading and the next.
*/
function analyzeSection(sectionName, sectionElements) {
const revisionId = mw.config.get('wgRevisionId');
const cacheKey = `sectionURLs:${location.hostname}:${revisionId}:${sectionName}`;
// Map: array of { liId, urls }. Anything read back from localStorage is
// re-validated before it can reach an href.
let liURLMap = null;
const cachedMap = readCache(cacheKey);
if (Array.isArray(cachedMap)) {
liURLMap = cachedMap
.filter(entry => entry && typeof entry.liId === 'string' && Array.isArray(entry.urls))
.map(entry => ({ liId: entry.liId, urls: entry.urls.filter(isValidHttpUrl) }));
}
const citeNotes = sectionElements.flatMap(el => Array.from(el.querySelectorAll('li[id^="cite_note"]')));
// Build from DOM if not cached
if (!liURLMap) {
liURLMap = citeNotes.map(li => ({
liId: li.id,
urls: Array.from(li.querySelectorAll('a.external[href]'))
.map(a => a.getAttribute('href'))
.filter(href => /^https?:\/\//i.test(href))
}));
writeCache(cacheKey, liURLMap);
}
// Add padding to all list items so citation text is flush with icons
for (const li of citeNotes) li.style.paddingLeft = '1.5em';
// Screen each li's URLs against domain lists and prepend icons with popups
for (const entry of liURLMap) {
const li = document.getElementById(entry.liId);
if (!li) continue;
const matched = resolveURLs(entry.urls).filter(r => r.indicator);
if (matched.length === 0) continue;
const highest = highestPriority(matched.map(m => m.indicator));
const wrapper = makeElement('span', {
style: { position: 'relative', cursor: 'pointer', marginLeft: '-1.5em',
display: 'inline-block', width: '1.5em' }
});
wrapper.appendChild(makeElement('span', { text: indicators[highest].emoji }));
attachPopup(wrapper, matched.map(m => popupLine(m.indicator, [m], false)), {});
li.prepend(wrapper);
li.dataset.cwIndicator = highest;
}
}
/**
* Extracts revision information from diff/hist/prev/cur links within a container.
* Parent IDs are not fetched here; rows that need one set `needsParentOf` and
* processRevisions() resolves them in a single batched request.
*
* @param {Element} container - The DOM element to query for revision links.
* @param {Element} element - The DOM element to attach to the revision object.
* @param {Function|null} prePadCallback - Optional callback invoked with (element, link) before
* processing each branch. In fallback mode this derives the target element and pre-pads it.
* @returns {Promise<{revision: Object|null, hasLink: boolean, skip: boolean}>}
* - revision: the extracted revision object, or null if no usable link was found.
* - hasLink: true if any recognized link was present (even if skipped).
* - skip: true if the entry should be skipped (non-article after pre-padding).
*/
async function extractRevisionFromLinks(container, element, prePadCallback) {
const kinds = [
{ selector: 'a.mw-changeslist-diff', kind: 'diff' },
{ selector: 'a.mw-changeslist-history', kind: 'hist' },
{ selector: 'a.mw-history-histlinks-previous', kind: 'prev' },
{ selector: 'a.mw-history-histlinks-current', kind: 'cur' }
];
let link = null;
let kind = null;
for (const k of kinds) {
link = container.querySelector(k.selector);
if (link) {
kind = k.kind;
break;
}
}
if (!link) {
return { revision: null, hasLink: false, skip: false };
}
const params = new URL(link.href).searchParams;
const pageTitle = params.get('title');
const target = prePadCallback ? prePadCallback(element, link) : element;
// The diff and hist links appear on watchlist/RC/contribs rows that can be
// for any namespace; history prev/cur links are always for the page itself.
if ((kind === 'diff' || kind === 'hist') && isNotArticle(pageTitle)) {
return { revision: null, hasLink: true, skip: true };
}
const revision = {
oldrevision: null,
newrevision: null,
element: target,
viewHref: '',
viewLabel: viewDiffLabel,
pageTitle: pageTitle || undefined
};
switch (kind) {
case 'diff': {
// MediaWiki convention: diff = newer, oldid = older
const diffParam = params.get('diff');
const oldidParam = params.get('oldid');
revision.viewHref = link.href;
if (diffParam === 'prev' && oldidParam) {
// Contributions: oldid is the current revision; parent resolved later
revision.needsParentOf = oldidParam;
} else {
revision.oldrevision = oldidParam;
revision.newrevision = diffParam;
}
break;
}
case 'hist': {
// New page: analyze its first revision
const firstID = await fetchFirstRevisionId(pageTitle);
if (!firstID) {
return { revision: null, hasLink: true, skip: false };
}
revision.oldrevision = String(firstID);
revision.viewHref = buildPermalinkUrl(firstID);
revision.viewLabel = viewRevisionLabel;
break;
}
case 'prev': {
// viewHref is filled in once the parent is known
revision.needsParentOf = params.get('oldid') || null;
break;
}
case 'cur': {
revision.oldrevision = params.get('oldid');
revision.viewHref = buildPermalinkUrl(revision.oldrevision);
revision.viewLabel = viewRevisionLabel;
break;
}
}
return { revision, hasLink: true, skip: false };
}
/**
* Computes the URLs added and removed between two revisions' URL lists.
* Multiset semantics: a URL that appears twice in the new revision and once
* in the old counts as one addition.
*
* @param {string[]} fromList - URLs in the older revision.
* @param {string[]|null} toList - URLs in the newer revision (ignored if !hasNew).
* @param {boolean} hasNew - False for a page's first revision, in which case
* every URL in fromList is considered added.
* @returns {{addedURLs: string[], removedURLs: string[]}}
*/
function computeURLDiff(fromList, toList, hasNew) {
if (!hasNew) {
// For first revision, all URLs are considered added
return { addedURLs: fromList.slice(), removedURLs: [] };
}
const count = arr => arr.reduce((m, u) => {
m[u] = (m[u] || 0) + 1;
return m;
}, {});
const fromMap = count(fromList);
const toMap = count(toList || []);
const addedURLs = [];
const removedURLs = [];
for (const key of new Set([...Object.keys(fromMap), ...Object.keys(toMap)])) {
const addCount = Math.max(0, (toMap[key] || 0) - (fromMap[key] || 0));
const remCount = Math.max(0, (fromMap[key] || 0) - (toMap[key] || 0));
for (let i = 0; i < addCount; i++) addedURLs.push(key);
for (let i = 0; i < remCount; i++) removedURLs.push(key);
}
return { addedURLs, removedURLs };
}
/**
* Screens a revision's added/removed URLs against the domain lists and
* annotates its row (gutter icons, popup, filter data attributes).
* Synchronous: all network work happens in processRevisions().
*
* @param {Object} revision - Object containing oldrevision, optional
* newrevision, and DOM element.
* @param {string[]} addedURLs
* @param {string[]} removedURLs
*/
function renderRevision(revision, addedURLs, removedURLs) {
const targetElement = revisionTargetElement(revision);
targetElement.setAttribute('data-cw-processed', 'true');
const added = resolveURLs(addedURLs);
const removed = resolveURLs(removedURLs);
// Highest-priority indicator among added links (lists only), for the gutter
const addType = highestPriority(added.map(a => a.indicator)) ||
(added.length > 0 ? 'added' : null);
try {
if (revision.viewHref) revision.element.dataset.cwViewHref = revision.viewHref;
if (revision.viewLabel) revision.element.dataset.cwViewLabel = revision.viewLabel;
} catch (e) {
/* ignore */
}
// Render two-column emojis: left = addition (highest-priority), right = removal (bin)
renderTwoColumnEmojis(revision.element, addType, removed.length > 0, added, removed);
// Data attributes drive the filter widget
const changedDomains = unique([...added, ...removed].map(r => r.domain)).sort();
if (changedDomains.length > 0) {
targetElement.dataset.cwLinkchange = changedDomains.join('|');
}
for (const type of listedTypes) {
const domains = unique([...added, ...removed]
.filter(r => r.indicator === type).map(r => r.domain)).sort();
if (domains.length > 0) {
targetElement.setAttribute(indicators[type].dataAttr, domains.join('|'));
}
}
}
/**
* ==========================================================================
* 2. User Interface
* ==========================================================================
*/
/**
* Pending hosts that need gutter padding applied. Collected during
* renderTwoColumnEmojis and flushed in a single batch via flushGutterPadding()
* so that all rows shift at the same time instead of one at a time.
*/
const pendingGutterPadding = [];
/**
* Applies the gutter padding to a single host element, reserving horizontal
* space for the two-column emoji gutter.
*
* @param {HTMLElement} host - The element to pad.
*/
function applyGutterPadding(host) {
const pad = 'calc(2em + 0.5em)';
try {
const cs = window.getComputedStyle(host);
const existing = parseFloat(cs.paddingLeft || '0');
const fontSizePx = parseFloat(cs.fontSize || '16');
const desiredPx = 2.5 * fontSizePx; // 2em + 0.5em
if (isNaN(existing) || existing < desiredPx - 0.5) {
host.style.paddingLeft = pad;
}
} catch (e) {
host.style.paddingLeft = pad;
}
}
/**
* Applies gutter padding to all collected hosts at once, then clears the
* queue. Call this after a batch of renderTwoColumnEmojis calls to avoid
* one-at-a-time layout shifts.
*/
function flushGutterPadding() {
for (const host of pendingGutterPadding) {
applyGutterPadding(host);
}
pendingGutterPadding.length = 0;
}
/**
* Finds the element that hosts the gutter for a change-list element:
* the list container or, for table rows, the content cell.
*
* @param {HTMLElement} element
* @returns {HTMLElement|null}
*/
function gutterHostFor(element) {
const container =
element.closest('[data-mw-revid]:not(.mw-userlink)') ||
element.closest('li') ||
element.closest('.mw-changeslist-line, tr') || element.parentNode || element;
if (!container) return null;
let host = container;
if (host.tagName && host.tagName.toUpperCase() === 'TR') {
// For nested enhanced RC rows, place the gutter in the content cell so
// icons align with singleton rows that use .mw-changeslist-line-inner.
const nestedContent = host.querySelector('td.mw-enhanced-rc-nested[data-target-page]');
host = nestedContent || host.querySelector('td, th') || host;
}
// Prefer inner change-line wrappers on Watchlist/RC if present
const innerHost = host.querySelector('.mw-changeslist-line-inner, .mw-changeslist-line-main');
return innerHost || host;
}
/**
* Renders exactly two fixed-width emoji columns before a revision entry:
* - Left column: highest-priority addition indicator
* (warning/caution/inspect/added), or spacer if none.
* - Right column: removal indicator if removals exist, otherwise spacer.
*
* This preserves consistent alignment even when no links are added/removed.
*
* @param {HTMLElement} element - The revision list DOM element.
* @param {string|null} addType - Indicator type for the left column, or null.
* @param {boolean} hasRemovals - Whether to show the removal icon.
* @param {Object[]} added - Resolved added links ({url, domain, indicator, ...}).
* @param {Object[]} removed - Resolved removed links.
*/
function renderTwoColumnEmojis(element, addType, hasRemovals, added = [], removed = []) {
if (!element) return;
const host = gutterHostFor(element);
if (!host) return;
const cell = (char, className) => makeElement('span', {
className, text: char,
style: { display: 'inline-block', width: '1em', textAlign: 'center', userSelect: 'none' }
});
const addChar = addType ? indicators[addType].emoji : ' ';
const remChar = hasRemovals ? indicators.removed.emoji : ' ';
// Propagate dataset for view link from source element to host
try {
if (element.dataset.cwViewHref) host.dataset.cwViewHref = element.dataset.cwViewHref;
if (element.dataset.cwViewLabel) host.dataset.cwViewLabel = element.dataset.cwViewLabel;
} catch (e) {
/* ignore */
}
// Ensure a single gutter exists
let gutter = host.querySelector('.cw-gutter');
if (gutter) {
gutter.querySelector('.cw-add').textContent = addChar;
gutter.querySelector('.cw-rem').textContent = remChar;
} else {
// Ensure the host can anchor absolutely positioned children
if (!host.style.position) host.style.position = 'relative';
// Create a left gutter that does not participate in text wrapping
gutter = makeElement('span', {
className: 'cw-gutter',
style: {
position: 'absolute', left: '0', top: '0', display: 'inline-flex', gap: '0.25em',
width: 'calc(2em + 0.25em)', alignItems: 'flex-start', userSelect: 'none'
}
});
gutter.appendChild(cell(addChar, 'cw-add'));
gutter.appendChild(cell(remChar, 'cw-rem'));
// Insert the gutter into the host; padding is deferred to flushGutterPadding()
host.insertBefore(gutter, host.firstChild);
pendingGutterPadding.push(host);
}
attachPopup(gutter, [popupLine('added', added, true), popupLine('removed', removed, true)], {
viewHref: host.dataset.cwViewHref || '',
viewLabel: host.dataset.cwViewLabel || ''
});
host.setAttribute('data-processed-twocol', 'true');
}
/**
* Fills a popup with a close button, a two-column grid of labelled link
* lines, and a footer. Idempotent: safe to call again to refresh content.
*
* @param {HTMLElement} popupEl - The popup element.
* @param {Array<{labelText: string, labelTitle: string, items: Object[]}>} lines
* Each item is a resolved link ({url, domain, indicator, archiveHost}).
* @param {Object} footerOptions - See createPopupFooter.
*/
function buildPopupContent(popupEl, lines, footerOptions) {
if (!popupEl.querySelector('.cw-close')) {
popupEl.appendChild(createCloseButton(popupEl));
}
let contentWrap = popupEl.querySelector('.cw-popup-content');
if (!contentWrap) {
contentWrap = makeElement('div', { className: 'cw-popup-content' });
popupEl.appendChild(contentWrap);
}
// Grid layout so labels are flush-aligned with content
Object.assign(contentWrap.style, {
display: 'grid', gridTemplateColumns: 'max-content 1fr', columnGap: '0.5em', rowGap: '2px'
});
contentWrap.innerHTML = '';
for (const line of lines) {
if (!line.items || line.items.length === 0) continue;
contentWrap.appendChild(makeElement('strong', {
text: `${line.labelText}:`, title: line.labelTitle,
ariaLabel: `${line.labelText}: ${line.labelTitle}`,
style: { alignSelf: 'start', justifySelf: 'end', textAlign: 'right', whiteSpace: 'nowrap' }
}));
const content = makeElement('div', { style: { display: 'inline' } });
line.items.forEach((item, idx) => {
content.appendChild(makeLinkItem(item));
if (idx < line.items.length - 1) content.appendChild(makeElement('span', { text: ' • ' }));
});
contentWrap.appendChild(content);
}
const footer = popupEl.querySelector('.cw-popup-footer');
if (footer) footer.remove();
popupEl.appendChild(createPopupFooter(footerOptions));
}
/**
* Creates (or refreshes) the popup under a host element and wires its
* hover/click handlers. With no lines, any existing popup is hidden.
*
* @param {HTMLElement} host - The gutter or icon wrapper that owns the popup.
* @param {Array} lines - See buildPopupContent.
* @param {Object} footerOptions - See createPopupFooter.
*/
function attachPopup(host, lines, footerOptions) {
let popup = host.querySelector('.cw-popup');
const hasContent = lines.some(l => l.items && l.items.length > 0);
if (!hasContent) {
if (popup) popup.style.display = 'none';
host._cwEnablePopup = false;
return;
}
if (!popup) {
popup = createPopupElement();
host.appendChild(popup);
}
buildPopupContent(popup, lines, footerOptions);
host._cwEnablePopup = true;
installPopupHandlers(host);
}
/**
* Builds a popup line for an indicator type over a set of resolved links.
*
* @param {string} type - Indicator type (added, removed, warning, ...).
* @param {Object[]} items - Resolved links.
* @param {boolean} withEmoji - Prefix the label with the type's emoji.
* @returns {{labelText: string, labelTitle: string, items: Object[]}}
*/
function popupLine(type, items, withEmoji) {
const ind = indicators[type];
return {
labelText: withEmoji ? `${ind.emoji} ${ind.msg}` : ind.msg,
labelTitle: ind.explanation,
items
};
}
/**
* Builds the popup representation of one resolved link: an optional
* indicator emoji followed by a link whose text is the screened domain.
*
* @param {Object} item - {url, domain, indicator, archiveHost}.
* @returns {HTMLSpanElement}
*/
function makeLinkItem(item) {
const span = makeElement('span', { style: { whiteSpace: 'nowrap', display: 'inline-block' } });
if (item.indicator) {
const ind = indicators[item.indicator];
span.appendChild(makeElement('span', {
className: 'cw-emoji', text: ind.emoji + ' ',
title: ind.explanation, ariaLabel: `${ind.msg}: ${ind.explanation}`
}));
}
const a = makeExternalLink(item.url, item.domain);
if (item.archiveHost) a.title = `${archivedViaLabel} ${item.archiveHost}`;
span.appendChild(a);
return span;
}
/**
* Creates a link with the attributes every outbound link in this script
* uses. The single place an attacker-influenced URL becomes an href, so the
* URL check lives here: absolute http(s) only, plus same-origin paths when
* `allowPath` is set (used for diff/permalink footers).
*
* @param {string} url
* @param {string} text
* @param {boolean} [allowPath=false]
* @returns {HTMLAnchorElement}
*/
function makeExternalLink(url, text, allowPath = false) {
const a = makeElement('a', {
text, attrs: { rel: 'nofollow noopener noreferrer', target: '_blank' },
style: { whiteSpace: 'nowrap' }
});
if (isValidHttpUrl(url) || (allowPath && /^\/(?!\/)/.test(url))) a.href = url;
return a;
}
/**
* Creates a DOM element with class, text, title, inline styles and
* attributes in one call. Text is always set through textContent.
*
* @param {string} tag
* @param {Object} [opts] - {className, text, title, ariaLabel, style, attrs}
* @returns {HTMLElement}
*/
function makeElement(tag, opts = {}) {
const el = document.createElement(tag);
if (opts.className) el.className = opts.className;
if (opts.text !== undefined) el.textContent = opts.text;
if (opts.title) {
el.title = opts.title;
el.setAttribute('aria-label', opts.ariaLabel || opts.title);
}
if (opts.style) Object.assign(el.style, opts.style);
for (const [k, v] of Object.entries(opts.attrs || {})) el.setAttribute(k, v);
return el;
}
/**
* Creates a styled popup element for displaying domain information.
*
* @returns {HTMLDivElement} The configured popup element.
*/
function createPopupElement() {
return makeElement('div', {
className: 'mwe-popups cw-popup',
style: {
position: 'absolute', minWidth: '260px', maxWidth: '420px', zIndex: '1000',
display: 'none', background: 'var(--background-color, #fff)',
border: '1px solid rgba(0,0,0,.15)', boxShadow: '0 2px 8px rgba(0,0,0,.2)',
padding: '8px 26px 8px 10px', borderRadius: '4px', fontSize: '0.92em'
}
});
}
/**
* Creates a styled close button for popups.
*
* @param {HTMLElement} popupEl - The popup element to hide when the button is clicked.
* @returns {HTMLButtonElement} The configured close button.
*/
function createCloseButton(popupEl) {
const closeBtn = makeElement('button', {
className: 'cw-close', text: '×', title: 'Close', attrs: { type: 'button' },
style: {
position: 'absolute', top: '4px', right: '6px', border: 'none',
background: 'transparent', cursor: 'pointer', fontSize: '14px',
lineHeight: '1', padding: '0', margin: '0'
}
});
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
popupEl.style.display = 'none';
});
return closeBtn;
}
/**
* Creates a popup footer element with an optional primary link and the
* "About Citation Watchlist" link.
*
* @param {Object} [options] - Optional configuration.
* @param {string} [options.viewHref] - URL for the primary link (e.g., diff or revision).
* @param {string} [options.viewLabel] - Label text for the primary link.
* @returns {HTMLDivElement} The configured footer element.
*/
function createPopupFooter(options) {
const footer = makeElement('div', {
className: 'cw-popup-footer', style: { marginTop: '10px', color: 'inherit' }
});
if (options && options.viewHref && options.viewLabel) {
const viewA = makeExternalLink(options.viewHref, options.viewLabel, true);
if (viewA.href) {
const strong = makeElement('strong');
strong.appendChild(viewA);
footer.appendChild(strong);
footer.appendChild(makeElement('span', { text: ' • ' }));
}
}
footer.appendChild(makeExternalLink(aboutLink, aboutLabel));
return footer;
}
/**
* Builds the shared filter widget container with radio buttons and an about link.
*
* @param {Object} config - Widget configuration.
* @param {string} config.containerId - The ID for the container element.
* @param {string} config.radioName - The name attribute for the radio button group.
* @param {Array<Object>} config.filters - Array of filter option objects
* ({id, label, checked?, tooltip?}).
* @param {Function} config.onFilter - Callback invoked with the selected filter ID.
* @param {string} [config.initialLabel] - Label text; defaults to showEditsLabel.
* @returns {HTMLDivElement} The configured filter widget container.
*/
function buildFilterWidgetContainer(config) {
const container = makeElement('div', {
attrs: { id: config.containerId },
style: {
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
border: '1px solid black', borderRadius: '25px', padding: '10px 20px',
margin: '10px 0', width: 'fit-content'
}
});
const leftSection = makeElement('div', {
style: { display: 'flex', alignItems: 'center', gap: '15px' }
});
leftSection.appendChild(makeElement('span', {
className: 'cw-filter-label', text: config.initialLabel || showEditsLabel,
style: { fontWeight: '500' }
}));
for (const filter of config.filters) {
const wrapper = makeElement('label', {
style: { display: 'flex', alignItems: 'center', gap: '5px', cursor: 'pointer' }
});
const radio = makeElement('input', {
attrs: { type: 'radio', name: config.radioName, value: filter.id }, style: { margin: '0' }
});
radio.checked = !!filter.checked;
radio.addEventListener('change', () => {
if (radio.checked) config.onFilter(filter.id);
});
const text = makeElement('span', { text: filter.label });
if (filter.tooltip) {
text.title = filter.tooltip;
Object.assign(text.style, { borderBottom: '1px dotted', cursor: 'help' });
}
wrapper.appendChild(radio);
wrapper.appendChild(text);
leftSection.appendChild(wrapper);
}
container.appendChild(leftSection);
const rightSection = makeElement('div', { style: { marginLeft: '4em' } });
const link = makeElement('a', {
text: toolName, attrs: { href: aboutLink },
style: { textDecoration: 'underline', color: '#0645ad' }
});
rightSection.appendChild(link);
container.appendChild(rightSection);
return container;
}
/**
* Builds the indicator filter list from the indicators config, sorted by
* descending priority and excluding non-positive priorities.
*
* @returns {Array<{id: string, label: string, tooltip: string}>}
*/
function buildIndicatorFilters() {
return listedTypes
.slice()
.sort((a, b) => indicators[b].priority - indicators[a].priority)
.map(id => ({
id,
label: `${indicators[id].emoji} ${indicators[id].msg}`,
tooltip: indicators[id].explanation
}));
}
/**
* Enables or greys out each radio in a filter widget according to whether
* any row currently matches it.
*
* @param {string} radioName - The radio group name.
* @param {Function} hasResults - (filterId) => boolean.
*/
function updateFilterRadios(radioName, hasResults) {
document.querySelectorAll(`input[name="${radioName}"]`).forEach(radio => {
const enabled = radio.value === 'all' || hasResults(radio.value);
const label = radio.closest('label');
radio.disabled = !enabled;
if (label) {
label.style.cursor = enabled ? 'pointer' : 'default';
label.style.color = enabled ? '' : 'gray';
}
});
}
/**
* Creates and inserts the change-list filter widget (watchlist, recent
* changes, history, contributions). Radios start disabled and are enabled by
* finishChangeListPass() once rows are annotated.
*
* @returns {HTMLElement|null} The created filter container or null if not created.
*/
function createChangeListFilterWidget() {
const ns = mw.config.get('wgNamespaceNumber');
const pageName = mw.config.get('wgPageName');
let target = document.querySelector('.mw-changeslist') ||
document.getElementById('mw-history-compare');
if (!target && ns === -1 && pageName.startsWith('Special:Contributions')) {
target = document.querySelector('.mw-pager-body');
}
if (!target) return null;
const container = buildFilterWidgetContainer({
containerId: 'cw-filter-widget',
radioName: 'cw-filter',
filters: [
{ id: 'all', label: allLabel, checked: true },
{ id: 'linkchange', label: linkChangesLabel, tooltip: linkChangesTooltip },
...buildIndicatorFilters()
],
onFilter: applyChangeListFilter,
initialLabel: loadingLabel
});
target.parentNode.insertBefore(container, target);
updateFilterRadios('cw-filter', () => false); // Disabled until annotation is complete
return container;
}
/**
* Creates and inserts a filter widget for the article surface, placed after
* the section heading. Excludes the "Link Changes" option.
*
* @param {Element} headingContainer - The heading element to insert the widget after.
*/
function createArticleFilterWidget(headingContainer) {
const container = buildFilterWidgetContainer({
containerId: 'cw-article-filter-widget',
radioName: 'cw-article-filter',
filters: [
{ id: 'all', label: allLabel, checked: true },
...buildIndicatorFilters()
],
onFilter: applyArticleFilter
});
headingContainer.after(container);
updateFilterRadios('cw-article-filter', (filterId) =>
!!document.querySelector(`li[id^="cite_note"][data-cw-indicator="${filterId}"]`));
renderLoadWarnings();
}
/**
* Called after each annotation pass on a change list: restores the widget
* label, renders any load warnings, and enables the radios that have matches.
*/
function finishChangeListPass() {
const filterLabel = document.querySelector('#cw-filter-widget .cw-filter-label');
if (filterLabel && filterLabel.firstChild &&
filterLabel.firstChild.nodeType === Node.TEXT_NODE) {
filterLabel.firstChild.textContent = showEditsLabel;
}
renderLoadWarnings();
const rows = document.querySelectorAll('[data-mw-revid]:not(.mw-userlink)');
updateFilterRadios('cw-filter', (filterId) =>
Array.from(rows).some(row => rowMatchesFilter(row, filterId)));
// Re-apply the active filter so newly loaded rows respect it
const active = document.querySelector('input[name="cw-filter"]:checked');
if (active && active.value !== 'all') applyChangeListFilter(active.value);
}
/**
* Whether a change-list row (or any nested revision inside it) matches a filter.
*
* @param {Element} row
* @param {string} filterId
* @returns {boolean}
*/
function rowMatchesFilter(row, filterId) {
if (elementMatchesFilter(row, filterId)) return true;
for (const nested of row.querySelectorAll('li[data-cw-processed="true"]')) {
if (elementMatchesFilter(nested, filterId)) return true;
}
return false;
}
/**
* Applies the selected filter to article section list items.
*
* @param {string} filterId - 'all' or an indicator type.
*/
function applyArticleFilter(filterId) {
document.querySelectorAll('li[id^="cite_note"]').forEach(li => {
li.style.display = (filterId === 'all' || li.dataset.cwIndicator === filterId) ? '' : 'none';
});
}
/**
* Applies the selected filter to the revision list.
*
* @param {string} filterId - 'all', 'linkchange', or an indicator type.
*/
let lastAppliedFilter = 'all';
function applyChangeListFilter(filterId) {
const isAll = filterId === 'all';
const revisions = document.querySelectorAll(
'[data-mw-revid]:not(.mw-userlink), [data-mw-logid]'
);
revisions.forEach(container => {
if (isAll) {
container.style.display = '';
return;
}
// Log entries (data-mw-logid) don't have revision-based attributes to
// match against filters — hide them when any specific filter is active.
const show = container.hasAttribute('data-mw-revid') && rowMatchesFilter(container, filterId);
container.style.display = show ? '' : 'none';
});
// Hide h4 date headers when all their rows are hidden
document.querySelectorAll('div.mw-changeslist h4').forEach(h4 => {
if (isAll) {
h4.style.display = '';
return;
}
const sibling = h4.nextElementSibling;
if (!sibling || sibling.tagName !== 'DIV') return;
const revItems = sibling.querySelectorAll('[data-mw-revid]');
if (revItems.length === 0) return;
h4.style.display = Array.from(revItems).every(el => el.style.display === 'none') ? 'none' : '';
});
const enhanced = document.querySelector('table.mw-enhanced-rc');
if (enhanced) {
// Hide the group header row in each tbody if all other rows are hidden
document.querySelectorAll('table.mw-enhanced-rc tbody').forEach(tbody => {
const rows = tbody.querySelectorAll('tr');
if (rows.length < 2) return;
rows[0].style.display = (!isAll && Array.from(rows).slice(1)
.every(tr => tr.style.display === 'none')) ? 'none' : '';
});
// Expand grouped sections while a specific filter is active, collapse on
// "all". Only on the transition, so switching between two filters leaves
// the expanded state alone.
const wasAll = lastAppliedFilter === 'all';
if (wasAll !== isAll) {
document.querySelectorAll('table.mw-enhanced-rc').forEach(table => {
const checkbox = groupToggleCheckbox(table);
if (checkbox && checkbox.checked === isAll) {
checkbox.checked = !isAll;
checkbox.dispatchEvent(new Event('change'));
}
});
}
}
showNoMatchingEdits(!isAll && Array.from(
document.querySelectorAll('[data-mw-revid]:not(.mw-userlink)')
).every(el => el.style.display === 'none'));
lastAppliedFilter = filterId;
}
/**
* Shows or removes the "No matching edits" message under the filter widget.
*
* @param {boolean} show
*/
function showNoMatchingEdits(show) {
const existing = document.getElementById('cw-no-matching-edits');
if (existing) existing.remove();
if (!show) return;
const msg = makeElement('p', {
text: noMatchingEditsLabel, attrs: { id: 'cw-no-matching-edits' },
style: { margin: '10px 0', fontStyle: 'italic' }
});
const widget = document.getElementById('cw-filter-widget');
if (widget && widget.nextSibling) {
widget.parentNode.insertBefore(msg, widget.nextSibling);
}
}
/**
* Returns the hidden checkbox that expands/collapses an enhanced-RC group.
*
* @param {Element} table - A table.mw-enhanced-rc element.
* @returns {HTMLInputElement|null}
*/
function groupToggleCheckbox(table) {
const arrow = table.querySelector('.mw-enhancedchanges-arrow');
const checkboxId = arrow && arrow.getAttribute('for');
return checkboxId ? document.getElementById(checkboxId) : null;
}
/**
* If any configuration pages failed to load, appends a warning glyph (with
* the details in its tooltip) to every filter-widget label on the page.
* Safe to call more than once.
*/
function renderLoadWarnings() {
const messages = SHOW_LINT_IN_WIDGET ? [...loadWarnings, ...listLint] : loadWarnings;
if (messages.length === 0) return;
document.querySelectorAll('.cw-filter-label').forEach(label => {
if (label.querySelector('.cw-load-warning')) return;
label.appendChild(makeElement('span', {
className: 'cw-load-warning', text: ' ' + loadWarningGlyph,
title: loadWarningPrefix + '\n' + messages.join('\n'), style: { cursor: 'help' }
}));
});
}
/**
* Whether the single document-level outside-click handler has been installed.
* One delegated listener replaces the former one-listener-per-gutter design.
*/
let outsideClickHandlerInstalled = false;
/**
* Installs a single document-level click handler that hides every open popup
* whose owning gutter does not contain the click target.
*/
function installOutsideClickHandler() {
if (outsideClickHandlerInstalled) return;
document.addEventListener('click', (ev) => {
document.querySelectorAll('.cw-popup').forEach(p => {
if (p.style.display === 'none' || p.style.display === '') return;
const owner = p.parentElement;
if (owner && !owner.contains(ev.target)) {
p.style.display = 'none';
}
});
});
outsideClickHandlerInstalled = true;
}
/**
* Installs hover, click, touch, and outside-click handlers on a gutter element
* to show/hide its popup.
*
* @param {HTMLElement} gutter - The gutter element containing a .cw-popup child.
*/
function installPopupHandlers(gutter) {
if (gutter._cwHandlersInstalled) return;
let hideTimer = null;
const positionPopup = () => {
const p = gutter.querySelector('.cw-popup');
if (!gutter._cwEnablePopup || !p) return;
p.style.display = 'block';
p.style.top = '1.4em';
p.style.left = '0px';
const rect = p.getBoundingClientRect();
if (rect.right > window.innerWidth - 10) {
const overflow = rect.right - (window.innerWidth - 10);
p.style.left = Math.max(0, (parseFloat(p.style.left) || 0) - overflow) + 'px';
}
};
const show = () => {
if (!gutter._cwEnablePopup) return;
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
positionPopup();
};
const scheduleHide = () => {
hideTimer = setTimeout(() => {
const p = gutter.querySelector('.cw-popup');
if (p) p.style.display = 'none';
}, 150);
};
gutter.addEventListener('mouseenter', show);
gutter.addEventListener('mouseleave', scheduleHide);
const pInit = gutter.querySelector('.cw-popup');
if (pInit) {
pInit.addEventListener('mouseenter', () => {
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
});
pInit.addEventListener('mouseleave', scheduleHide);
}
const toggle = (e) => {
if (!gutter._cwEnablePopup) return;
e.stopPropagation();
const p = gutter.querySelector('.cw-popup');
if (!p) return;
if (p.style.display === 'none' || p.style.display === '') {
show();
} else {
p.style.display = 'none';
}
};
gutter.addEventListener('click', toggle);
gutter.addEventListener('touchstart', toggle, {
passive: true
});
installOutsideClickHandler();
gutter._cwHandlersInstalled = true;
}
/**
* Watches for the change list being replaced by MediaWiki's live filters
* ("View new changes", filter edits on Special:Watchlist / RecentChanges)
* and annotates the new rows. The existing widget sits outside the replaced
* element and survives; its radios are refreshed after each pass.
*/
function installLiveReloadObserver() {
if (typeof MutationObserver === 'undefined') return;
let timer = null;
let running = false;
let rerun = false;
const run = async () => {
if (running) {
rerun = true;
return;
}
running = true;
try {
await annotateChangeList(document, false);
} catch (e) {
console.warn('Citation Watchlist: live reload pass failed:', e);
} finally {
running = false;
if (rerun) {
rerun = false;
run();
}
}
};
const observer = new MutationObserver(mutations => {
const relevant = mutations.some(m => Array.from(m.addedNodes).some(node =>
node.nodeType === Node.ELEMENT_NODE &&
(node.matches('[data-mw-revid], .mw-changeslist, .mw-changeslist-line') ||
node.querySelector('[data-mw-revid], .mw-changeslist-line'))
));
if (!relevant) return;
clearTimeout(timer);
timer = setTimeout(run, LIVE_RELOAD_DEBOUNCE_MS);
});
observer.observe(document.body, { childList: true, subtree: true });
}
const enabledNsStrings = new Set(enabledNamespaces.filter(n => n >= 0).map(String));
const namespaces = Object.entries(mw.config.get('wgFormattedNamespaces'))
.filter(([num, name]) => num !== '0' && !enabledNsStrings.has(num))
.map(([_, name]) => name.replace(/ /g, '_') + ':');
/**
* Pre-pads an element with empty emoji gutters to prevent layout shift.
*
* @param {Element} element - The DOM element to pre-pad.
*/
function prePadElement(element) {
try {
renderTwoColumnEmojis(element, null, false, [], []);
} catch (e) {
/* ignore */
}
}
/**
* ==========================================================================
* 3. Builders and Extractors
* ==========================================================================
*/
/**
* Constructs a URL for viewing a diff between two revisions.
*
* @param {string} title - The page title.
* @param {string} fromId - The older revision ID.
* @param {string} toId - The newer revision ID.
* @returns {string} The diff URL, or empty string on error.
*/
function buildDiffUrl(title, fromId, toId) {
try {
const script = mw.config.get('wgScript') || '/w/index.php';
return `${script}` +
`?title=${encodeURIComponent(title)}` +
`&diff=${encodeURIComponent(toId)}` +
`&oldid=${encodeURIComponent(fromId)}`;
} catch (e) {
return '';
}
}
/**
* Constructs a permalink URL for a specific revision.
*
* @param {string} revId - The revision ID.
* @returns {string} The permalink URL, or empty string on error.
*/
function buildPermalinkUrl(revId) {
try {
const articlePath = mw.config.get('wgArticlePath');
if (articlePath && articlePath.includes('$1')) {
return articlePath.replace('$1',
`Special:Permalink/${encodeURIComponent(revId)}`);
}
const script = mw.config.get('wgScript') || '/w/index.php';
return `${script}?oldid=${encodeURIComponent(revId)}`;
} catch (e) {
return '';
}
}
/**
* Strips all HTML comments from a string.
*
* @param {string} text - Input text potentially containing HTML comments.
* @returns {string} Text with all HTML comments removed.
*/
function stripHTMLComments(text) {
if (!text) return text;
return text.replace(/<!--[\s\S]*?-->/g, '');
}
/**
* Extracts all HTTP(S) URLs from a given wikitext string. The scheme is
* matched case-insensitively (MediaWiki links HTTPS:// just as it does
* https://), and protocol-relative links ("[//example.com ...]") are
* normalized to https://.
*
* @param {string} wikitext - Raw wikitext revision content.
* @returns {string[]} List of valid extracted URLs.
*/
function extractURLs(wikitext) {
const urls = [];
if (!wikitext) return urls;
const urlRegex = // eslint-disable-next-line max-len
/(?:https?:\/\/|(?<=^|[\s\[|=<>"'])\/\/)(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)/gi;
let match;
while ((match = urlRegex.exec(wikitext)) !== null) {
const raw = match[0].startsWith('//') ? 'https:' + match[0] : match[0];
try {
const url = new URL(raw);
if (/^https?:$/.test(url.protocol)) urls.push(url.href);
} catch (error) {
console.error(`Invalid URL rejected: ${match[0]}`, error.message);
}
}
return urls;
}
/**
* Web archive services whose URLs embed the original URL. Each entry's
* `extract` returns the embedded URL string or null.
*/
/** Restores "https:/example.com" (slash-collapsed by some archives) to "https://example.com". */
const fixScheme = s => s.replace(/^(https?:)\/+/i, '$1//');
const archiveUnwrappers = [
{
// https://web.archive.org/web/20200101000000/https://example.com/page
// (the embedded scheme is sometimes collapsed to "https:/")
test: h => h === 'web.archive.org' || h === 'wayback.archive.org',
extract: u => {
const m = u.pathname.match(/^\/web\/\d{1,14}[a-z_]*\/(https?:\/{1,2}.+)$/i);
return m ? fixScheme(m[1]) : null;
}
},
{
// https://archive.blah/2020.01.01-000000/https://example.com/page
test: h => /^archive\.(ph|today|is|li|vn|fo|md)$/i.test(h),
extract: u => {
const m = u.pathname.match(/^\/[^/]+\/(https?:\/{1,2}.+)$/i);
return m ? fixScheme(m[1]) : null;
}
},
{
// https://webcache.googleusercontent.com/search?q=cache:https://example.com/page
test: h => h === 'webcache.googleusercontent.com',
extract: u => {
const q = u.searchParams.get('q') || '';
const m = q.match(/^cache:(?:[A-Za-z0-9_-]+:)?(.+)$/);
if (!m) return null;
return /^https?:\/\//i.test(m[1]) ? m[1] : 'https://' + m[1];
}
}
];
/**
* Resolves a URL to the hostname that should be screened. If the URL is a
* web-archive capture of another page, the captured page's host is used.
*
* @param {string} url
* @returns {{hostname: string, archiveHost: string|null}|null} Null if unparsable.
*/
function screenedHost(url) {
let current;
try {
current = new URL(url);
} catch (e) {
return null;
}
let archiveHost = null;
for (let depth = 0; depth < 3 && UNWRAP_ARCHIVES; depth++) {
const unwrapper = archiveUnwrappers.find(a => a.test(current.hostname));
if (!unwrapper) break;
const inner = unwrapper.extract(current);
if (!inner) break;
try {
const innerUrl = new URL(inner);
if (!/^https?:$/.test(innerUrl.protocol)) break;
archiveHost = archiveHost || current.hostname;
current = innerUrl;
} catch (e) {
break;
}
}
return { hostname: current.hostname, archiveHost };
}
/**
* Resolves URLs to screened domains and list indicators.
*
* @param {string[]} urls
* @returns {Array<{url: string, domain: string, indicator: string|null, archiveHost: string|null}>}
*/
function resolveURLs(urls) {
const out = [];
for (const url of urls || []) {
const host = screenedHost(url);
if (!host) continue;
const domain = getRootDomain(host.hostname, publicSuffixSet);
if (!domain) continue;
out.push({ url, domain, indicator: highestIndicatorFor(domain), archiveHost: host.archiveHost });
}
return out;
}
/**
* Returns the highest-priority list indicator that contains a domain.
*
* @param {string} domain
* @returns {string|null}
*/
function highestIndicatorFor(domain) {
let chosen = null;
for (const type of listedTypes) {
if (indicators[type].list.has(domain) &&
(!chosen || indicators[type].priority > indicators[chosen].priority)) {
chosen = type;
}
}
return chosen;
}
/**
* Returns the highest-priority type among a list of indicator types.
*
* @param {Array<string|null>} types
* @returns {string|null}
*/
function highestPriority(types) {
let chosen = null;
for (const t of types) {
if (t && (!chosen || indicators[t].priority > indicators[chosen].priority)) chosen = t;
}
return chosen;
}
/**
* Extracts the top-level domain from a full hostname using a public suffix set.
*
* @param {string} hostname - Full hostname (e.g., sub.example.co.uk).
* @param {Set<string>} publicSuffixSet - Set of known public suffixes.
* @returns {string} The top-level domain (e.g., example.co.uk).
*/
function getRootDomain(hostname, publicSuffixSet) {
// Handle empty or invalid hostnames
if (!hostname || typeof hostname !== 'string') {
console.warn('Invalid hostname provided to getRootDomain:', hostname);
return '';
}
// Check if this is an IP address (simple check for IPv4)
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(hostname)) {
return hostname; // Return IP addresses as-is
}
const domainParts = hostname.split('.');
// Handle hostnames that are too short
if (domainParts.length < 2) {
return hostname; // Return as-is if it's a single-part hostname
}
// Try to find a matching public suffix
for (let i = 0; i < domainParts.length; i++) {
const candidate = domainParts.slice(i).join('.');
// Check both normal and exception (prefixed with !) entries
if (publicSuffixSet.has(candidate) || publicSuffixSet.has(`!${candidate}`)) {
// If we found a match, return the domain part plus the public suffix
// But make sure we don't go out of bounds
if (i > 0) {
return domainParts.slice(i - 1).join('.');
} else {
// Edge case: the entire hostname is a public suffix
return hostname;
}
}
}
// If no match in public suffix list, use a simple fallback:
// For hostnames with 2 parts, return the whole thing
// For hostnames with >2 parts, return the last 2 parts
if (domainParts.length === 2) {
return hostname;
} else {
return domainParts.slice(-2).join('.');
}
}
/**
* Extracts the first page object from MediaWiki API query response.
*
* @param {Object} data - MediaWiki API response.
* @returns {Object|null} The first page object or null if unavailable.
*/
function getFirstPage(data) {
if (!data || !data.query || !data.query.pages) return null;
return Object.values(data.query.pages)[0] || null;
}
/**
* Iterates every revision object in an API response, across all pages.
* (A revids= query spanning several articles returns several page objects.)
*
* @param {Object|null} data - MediaWiki API response.
* @returns {Object[]} Revision objects.
*/
function allRevisionsIn(data) {
if (!data || !data.query || !data.query.pages) return [];
const out = [];
for (const page of Object.values(data.query.pages)) {
if (page && Array.isArray(page.revisions)) out.push(...page.revisions);
}
return out;
}
/**
* Returns the main-slot wikitext of a revision object, or null if the
* revision is missing, hidden, or malformed.
*
* @param {Object} rev - Revision object from the API.
* @returns {string|null}
*/
function revisionText(rev) {
if (!rev || rev.texthidden !== undefined) return null;
const slot = rev.slots && rev.slots.main;
if (!slot || typeof slot['*'] !== 'string') return null;
return slot['*'];
}
/**
* Returns true if an API page object represents a page that cannot be
* read (missing, invalid, or without an accessible revision).
*
* @param {Object} page - Page object from the API.
* @returns {boolean}
*/
function isUnreadablePage(page) {
return !page || page.missing !== undefined || page.invalid !== undefined ||
!Array.isArray(page.revisions) || page.revisions.length === 0;
}
/**
* ==========================================================================
* 4. Validation
* ==========================================================================
*/
/**
* Validates that a string is a well-formed domain name.
*
* @param {string} str - The string to validate.
* @returns {boolean} True if the string is a valid domain.
*/
function isValidDomain(str) {
if (!str || str.length === 0 || str.length > 253) return false;
if (!/^[a-zA-Z0-9._-]+$/.test(str)) return false;
if (!str.includes('.')) return false;
if (/\.\./.test(str) || str.startsWith('.') || str.endsWith('.')) return false;
const labels = str.split('.');
if (labels.some(l => l.length === 0 || l.length > 63)) return false;
return true;
}
/**
* Validates that a string is a well-formed public suffix list entry.
* Allows wildcard entries (e.g., *.ck) and exception entries (e.g., !www.ck).
*
* @param {string} str - The string to validate.
* @returns {boolean} True if the string is a valid PSL entry.
*/
function isValidSuffixEntry(str) {
const cleaned = str.replace(/^[!*.]/, '');
return /^[a-zA-Z0-9._-]+$/.test(cleaned) && cleaned.length > 0 && cleaned.length <= 253;
}
/**
* Validates that a value is a string parseable as an absolute http(s) URL.
* Used to re-validate anything read back from localStorage before it is
* assigned to an href.
*
* @param {*} u - The value to validate.
* @returns {boolean} True if the value is an http(s) URL.
*/
function isValidHttpUrl(u) {
if (typeof u !== 'string') return false;
try {
return /^https?:$/i.test(new URL(u).protocol);
} catch (e) {
return false;
}
}
/**
* Validates that a value is a plausible revision ID (a positive integer
* string). Used before an ID is put into an API request or a cache key.
*
* @param {*} id - The value to validate.
* @returns {boolean}
*/
function isRevisionId(id) {
return typeof id === 'string' && /^[1-9][0-9]{0,15}$/.test(id);
}
/**
* Validates that a string is a plausible MediaWiki page title.
* Rejects titles with invalid characters, excessive length, or suspicious patterns.
*
* @param {string} title - The page title to validate.
* @returns {boolean} True if the title looks like a valid page link.
*/
function isValidPageTitle(title) {
if (typeof title !== 'string') return false;
if (title.length === 0 || title.length > 255) return false;
if (/[\[\]{}|#<>\n\r\t]/.test(title)) return false;
if (title.includes('..')) return false;
if (title.startsWith(':') || title.startsWith(' ') || title.endsWith(' ')) return false;
return true;
}
/**
* Determines whether a given page title does *not* belong to the main or draft namespaces.
* A missing title is treated as "not an article" so the row is skipped rather
* than crashing the run.
*
* @param {string} pageTitle - The title of the page.
* @returns {boolean} True if not an article namespace.
*/
function isNotArticle(pageTitle) {
if (typeof pageTitle !== 'string') return true;
return namespaces.some(namespace => pageTitle.startsWith(namespace));
}
/**
* Checks whether an element matches the specified filter.
*
* @param {Element} element - The DOM element to test.
* @param {string} filterId - The active filter ID.
* @returns {boolean} True if the element matches the filter.
*/
function elementMatchesFilter(element, filterId) {
if (filterId === 'linkchange') {
return element.hasAttribute('data-cw-linkchange') ||
listedTypes.some(type => element.hasAttribute(indicators[type].dataAttr));
}
if (indicators[filterId] && indicators[filterId].dataAttr) {
return element.hasAttribute(indicators[filterId].dataAttr);
}
return false;
}
/**
* ==========================================================================
* 5. Fetch
* ==========================================================================
*/
/**
* Fetches revisions by ID and returns them keyed by revision ID (as strings),
* across all pages in the response.
*
* @param {string[]} revIds
* @param {string[]} rvprop - Properties to request (always includes 'ids').
* @returns {Promise<Map<string, Object>|null>} Map of id -> revision object,
* or null if the request failed.
*/
async function fetchRevisionsById(revIds, rvprop) {
const data = await fetchRevisionData({
revids: revIds,
rvprop: unique(['ids', ...rvprop]),
rvslots: ['main']
});
if (!data) return null;
const out = new Map();
for (const rev of allRevisionsIn(data)) {
if (rev && rev.revid !== undefined) out.set(String(rev.revid), rev);
}
return out;
}
/**
* Fetches wikitext for a chunk of revision IDs in one request.
*
* @param {string[]} revIds - Revision IDs (at most CONTENT_CHUNK_SIZE).
* @returns {Promise<Map<string, string|null>|null>} Map of id -> wikitext
* (null for a revision that exists but is unreadable), or null if the
* request itself failed. IDs the API did not return are absent.
*/
async function fetchRevisionTexts(revIds) {
const revs = await fetchRevisionsById(revIds, ['content']);
if (!revs) return null;
return new Map(Array.from(revs, ([id, rev]) => [id, revisionText(rev)]));
}
/**
* Fetches parent IDs and sizes for a list of revision IDs, chunked at the
* API maximum and fetched concurrently.
*
* @param {string[]} revisionIds - Revision IDs.
* @returns {Promise<Object>} Map of id -> {parentid, size}; IDs that could
* not be resolved are absent.
*/
async function fetchRevisionMetadata(revisionIds) {
const result = {};
const chunks = [];
for (let i = 0; i < revisionIds.length; i += METADATA_CHUNK_SIZE) {
chunks.push(revisionIds.slice(i, i + METADATA_CHUNK_SIZE));
}
await runPool(chunks.map(chunk => async () => {
const revs = await fetchRevisionsById(chunk, ['size']);
for (const [id, rev] of revs || []) {
result[id] = {
parentid: rev.parentid || 0,
size: typeof rev.size === 'number' ? rev.size : undefined
};
}
}), CONTENT_CONCURRENCY);
return result;
}
/**
* Fetches a page's current wikitext (main slot) by title.
*
* @param {string} pageTitle
* @returns {Promise<string|null>} Null if the page is missing, unreadable, or the request failed.
*/
async function fetchPageWikitext(pageTitle) {
if (!isValidPageTitle(pageTitle)) return null;
const page = getFirstPage(await fetchRevisionData({
titles: [pageTitle],
rvprop: ['content'],
rvslots: ['main']
}));
return isUnreadablePage(page) ? null : revisionText(page.revisions[0]);
}
/**
* Fetches the ID of the first revision of a page.
*
* @param {string} pageTitle - The page title to look up.
* @returns {number|null} Revision ID or null.
*/
async function fetchFirstRevisionId(pageTitle) {
if (!isValidPageTitle(pageTitle)) return null;
const page = getFirstPage(await fetchRevisionData({
titles: [pageTitle],
rvlimit: 1,
rvdir: 'newer',
rvprop: ['ids'],
}));
if (isUnreadablePage(page)) return null;
return page.revisions[0].revid || null;
}
/**
* Fetches the list of subpages from the list of lists, parses wikilinks, caches
* the result, and returns list of subpage titles.
*
* @param {string} pageName - Title of the list-of-lists page.
* @returns {Promise<string[]>} List of subpage titles (empty on failure).
*/
async function fetchDomainListPages(pageName) {
const cacheKey = `citationWatchlistFetchDomainListPages_${pageName}`;
const cachedContent = readCache(cacheKey);
if (Array.isArray(cachedContent) && cachedContent.length > 0) {
console.log('Loaded list of lists from cache');
return cachedContent.filter(title => isValidPageTitle(title));
}
const content = await fetchPageWikitext(pageName);
if (content === null) {
console.error(`Citation Watchlist: list of lists "${pageName}" could not be loaded`);
return [];
}
const pageTitles = [];
for (const line of content.split('\n')) {
if (!line.startsWith('* [[')) continue;
const match = line.match(/\[\[([^\]]+)\]\]/); // first [[Page Title]]
if (!match) continue;
const title = match[1];
if (isValidPageTitle(title)) {
pageTitles.push(title);
} else {
console.warn(`Rejected invalid page title: "${title}"`);
}
}
if (pageTitles.length > 0) {
writeCache(cacheKey, pageTitles);
console.log('Loaded from API and stored in cache');
}
return pageTitles;
}
/**
* Parses one domain list page into per-indicator domain arrays.
* Entries that are not bare domains are collected as lint. A leading "www."
* is stripped (with a lint note), since root domains never carry it.
*
* @param {string} title - Page title, for lint messages.
* @param {string} content - Page wikitext.
* @returns {Object} {warning: string[], caution: string[], inspect: string[], lint: string[]}
*/
function parseDomainList(title, content) {
const parsed = { lint: [] };
for (const type of listedTypes) parsed[type] = [];
let current = null;
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (/^==/.test(trimmed)) {
// Any heading ends the current section; only recognized ones open a new one.
current = listedTypes.find(t => indicators[t].section === trimmed) || null;
continue;
}
if (!line.startsWith('*') || !current) continue;
let domain = line.substring(1).trim().toLowerCase();
if (!domain) continue;
if (/^www\./.test(domain) && isValidDomain(domain.slice(4))) {
parsed.lint.push(`${title}: "${domain}" has a www. prefix; matched as "${domain.slice(4)}"`);
domain = domain.slice(4);
}
if (isValidDomain(domain)) {
if (parsed[current].length < MAX_DOMAINS_PER_LIST) {
parsed[current].push(domain);
} else {
parsed.lint.push(`${title}: cap of ${MAX_DOMAINS_PER_LIST} reached in ${current}`);
}
} else {
parsed.lint.push(`${title}: "${domain}" is not a bare domain (paths, wildcards and ` +
`bare TLDs are not supported) and was ignored`);
}
}
return parsed;
}
/**
* Loads domain lists from a set of pages (parsed form cached per page) and
* populates the `Set` of each indicator. Pages that are missing or fail to
* load are skipped and recorded in loadWarnings; the remaining lists still load.
*
* @param {string[]} pageNames - List of page titles to fetch.
*/
async function fetchAndOrganizeDomainLists(pageNames) {
const parsedLists = [];
const pagesToFetch = [];
const isParsed = p => p && typeof p === 'object' && listedTypes.every(t => Array.isArray(p[t]));
for (const title of pageNames) {
const cached = readCache(`domainList:${location.hostname}:${title}`);
if (isParsed(cached)) {
parsedLists.push(cached);
} else {
pagesToFetch.push(title);
}
}
if (pagesToFetch.length > 0) {
const apiData = await fetchRevisionData({
titles: pagesToFetch,
rvprop: ['content'],
rvslots: ['main'],
});
const pages = (apiData && apiData.query && apiData.query.pages) || null;
if (!pages) {
loadWarnings.push(`API request for ${pagesToFetch.length} list page(s) failed`);
console.error('Citation Watchlist: domain list fetch failed for', pagesToFetch);
} else {
// The API normalizes titles (underscores -> spaces); cache under the
// requested spelling so the next load hits.
const requestedFor = {};
for (const t of pagesToFetch) requestedFor[t.replace(/_/g, ' ')] = t;
for (const page of Object.values(pages)) {
const title = page.title || '?';
if (isUnreadablePage(page)) {
loadWarnings.push(`List page "${title}" is missing or unreadable`);
console.warn(`Citation Watchlist: skipping list page "${title}"`);
continue;
}
const content = revisionText(page.revisions[0]);
if (content === null) {
loadWarnings.push(`List page "${title}" has no readable content`);
continue;
}
const parsed = parseDomainList(title, content);
parsedLists.push(parsed);
const cacheTitle = requestedFor[title] || title;
writeCache(`domainList:${location.hostname}:${cacheTitle}`, parsed);
}
}
}
for (const parsed of parsedLists) {
for (const type of listedTypes) {
for (const domain of parsed[type]) {
if (isValidDomain(domain)) indicators[type].list.add(domain);
}
}
if (Array.isArray(parsed.lint)) {
listLint.push(...parsed.lint.filter(l => typeof l === 'string'));
}
}
}
/**
* Fetches and caches the public suffix list used to identify top-level domains.
* The cache holds the validated entries, so cached loads skip parsing.
*
* @returns {Promise<Set<string>>} Set of public suffixes.
*/
async function fetchPublicSuffixList() {
const cacheKey = 'publicSuffixListCache';
const cached = readCache(cacheKey);
if (Array.isArray(cached) && cached.length >= MIN_PSL_ENTRIES) {
console.log('Using cached public suffix list');
return new Set(cached.filter(e => typeof e === 'string' && isValidSuffixEntry(e)));
}
const pslUrl = mw.config.get('wgArticlePath').replace('$1', publicSuffixList) +
'?action=raw';
console.log(`Raw page text request: ${pslUrl}`);
const content = await safeFetch(fetch, pslUrl).then(response => response ?
response.text() : null);
if (!content) return new Set();
const entries = [];
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith('//') && isValidSuffixEntry(trimmed)) {
entries.push(trimmed);
}
}
if (entries.length < MIN_PSL_ENTRIES) {
console.error(`Public suffix list suspiciously small (${entries.length} entries), rejecting`);
return new Set();
}
// Only cache a list that passed validation
writeCache(cacheKey, entries);
return new Set(entries);
}
/**
* Makes a MediaWiki API call to fetch revision metadata or content.
*
* @param {Object} data - Options for the API call, such as `revids`, `titles`, `rvprop`, etc.
* @returns {Promise<Object|null>} MediaWiki API query result, or null on failure.
*/
async function fetchRevisionData(data) {
const paramKeys = ['rvprop', 'revids', 'titles', 'rvslots'];
const params = {
action: 'query',
prop: 'revisions',
format: 'json',
rvdir: data.rvdir || 'older',
origin: '*'
};
if (data.rvlimit) {
params.rvlimit = data.rvlimit;
}
paramKeys.forEach(key => {
if (data[key]) {
params[key] = Array.isArray(data[key]) ? data[key].join('|') : data[key];
}
});
const api = new mw.Api();
return await safeFetch(api.get.bind(api), params);
}
/**
* Wraps any asynchronous fetch function with retry logic and error handling.
*
* @param {Function} fn - The function to execute (usually an API call).
* @param {...any} args - Arguments to pass to the fetch function.
* @returns {Promise<any|null>} Result of the fetch or null on failure.
*/
async function safeFetch(fn, ...args) {
const retries = 2;
const retryDelay = 1000;
const maxAttempts = retries + 1;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const result = await fn(...args);
if (result === null || result === undefined) {
throw new Error('Received null or undefined response');
}
if (typeof result.ok === 'boolean' && !result.ok) {
throw new Error(`HTTP error ${result.status}: ${result.statusText || 'Unknown error'}`);
}
return result;
} catch (error) {
if (attempt < maxAttempts) {
console.warn(
`Error during ${fn.name || 'fetch operation'} (attempt ${attempt}/${maxAttempts}):`,
error.message || error);
await new Promise(resolve => setTimeout(resolve, retryDelay));
} else {
console.error(
`All ${maxAttempts} attempts failed for ${fn.name || 'fetch operation'}:`,
error.message || error);
}
}
}
return null;
}
/**
* ==========================================================================
* 6. Cache
* ==========================================================================
*/
/**
* Returns every localStorage key with the given prefix. Snapshotting the keys
* first means callers can remove entries without the index shifting under them.
*
* @param {string} prefix - Key prefix to match.
* @returns {string[]}
*/
function cacheKeysWithPrefix(prefix) {
const keys = [];
try {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith(prefix)) keys.push(key);
}
} catch (e) {
/* storage unavailable */
}
return keys;
}
/**
* Removes every localStorage key with the given prefix.
*
* @param {string} prefix
*/
function removeKeysWithPrefix(prefix) {
for (const key of cacheKeysWithPrefix(prefix)) localStorage.removeItem(key);
}
/**
* Runs all cache maintenance: retired prefixes, version invalidation, and
* (at most once per CACHE_PURGE_INTERVAL) expiry and eviction.
*/
function purgeCaches() {
try {
purgeStaleVersionedCaches();
const lastKey = 'citationWatchlist_lastPurge';
const last = parseInt(localStorage.getItem(lastKey), 10);
if (isNaN(last) || Date.now() - last > CACHE_PURGE_INTERVAL) {
purgeExpiredCache();
localStorage.setItem(lastKey, Date.now().toString());
}
} catch (e) {
console.warn('Citation Watchlist: cache maintenance failed:', e);
}
}
/**
* Removes caches from retired prefixes and purges cached entries whose stored
* version is older than the current version. Each cache type tracks its own
* version independently via a localStorage key.
*/
function purgeStaleVersionedCaches() {
for (const prefix of retiredCachePrefixes) {
removeKeysWithPrefix(prefix);
localStorage.removeItem(`citationWatchlist_cacheVersion:${prefix}`);
}
for (const cache of cacheConfig) {
const versionKey = `citationWatchlist_cacheVersion:${cache.prefix}`;
const storedVersion = parseInt(localStorage.getItem(versionKey), 10);
const effectiveVersion = isNaN(storedVersion) ? 0 : storedVersion;
if (effectiveVersion < cache.version) {
removeKeysWithPrefix(cache.prefix);
try {
localStorage.setItem(versionKey, cache.version.toString());
} catch (e) {
console.warn('Cache version write error:', e);
}
console.log(`Cache invalidated for "${cache.prefix}": version ${effectiveVersion} → ${cache.version}`);
}
}
}
/**
* Removes expired localStorage cache entries and enforces per-cache entry
* caps by evicting the oldest entries.
*/
function purgeExpiredCache() {
const now = Date.now();
const stats = { checked: 0, expired: 0, evicted: 0, errors: 0 };
for (const cache of cacheConfig) {
// Snapshot keys first so removals do not shift the iteration index
const survivors = []; // { key, timestamp }
for (const key of cacheKeysWithPrefix(cache.prefix)) {
stats.checked++;
try {
const value = localStorage.getItem(key);
let parsed = null;
try {
parsed = value ? JSON.parse(value) : null;
} catch (jsonError) {
stats.errors++;
}
if (!parsed || typeof parsed.timestamp !== 'number' || now - parsed.timestamp > cache.ttl) {
localStorage.removeItem(key);
stats.expired++;
continue;
}
survivors.push({ key, timestamp: parsed.timestamp });
} catch (itemError) {
stats.errors++;
try {
localStorage.removeItem(key);
} catch (e) { /* ignore */ }
}
}
if (cache.maxEntries && survivors.length > cache.maxEntries) {
survivors.sort((a, b) => a.timestamp - b.timestamp);
const excess = survivors.length - cache.maxEntries;
for (let i = 0; i < excess; i++) {
localStorage.removeItem(survivors[i].key);
stats.evicted++;
}
}
}
if (stats.checked > 0) {
console.log(`Cache cleanup: checked ${stats.checked}, expired ${stats.expired}, ` +
`evicted ${stats.evicted}, errors ${stats.errors}`);
}
}
/**
* Looks up the cache configuration whose prefix matches a key.
*
* @param {string} key - A cache key or prefix.
* @returns {Object|undefined}
*/
function cacheConfigFor(key) {
return cacheConfig.find(c => key.startsWith(c.prefix));
}
/**
* Reads a JSON cache entry from localStorage, returning the stored content
* if it exists and has not expired (TTL taken from the key's cacheConfig
* entry). Callers must validate the shape of the returned content.
*
* @param {string} key - The localStorage key.
* @returns {*|null} The cached content, or null if missing/expired/invalid.
*/
function readCache(key) {
const config = cacheConfigFor(key);
let raw;
try {
raw = config ? localStorage.getItem(key) : null;
} catch (e) {
return null;
}
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed.timestamp === 'number' &&
(Date.now() - parsed.timestamp < config.ttl) && parsed.content !== undefined) {
return parsed.content;
}
} catch (e) {
console.warn('Cache read error:', e);
}
return null;
}
/**
* Writes a JSON cache entry to localStorage with an embedded timestamp.
* Failures (e.g. quota exceeded) are logged and otherwise ignored; the
* cache is an optimization, never a dependency.
*
* @param {string} key - The localStorage key.
* @param {*} content - The data to cache.
*/
function writeCache(key, content) {
try {
localStorage.setItem(key, JSON.stringify({ timestamp: Date.now(), content }));
} catch (e) {
console.warn('Cache write error:', e);
}
}
/**
* ==========================================================================
* 7. Entry Point
* ==========================================================================
*/
analyzeView().catch(e => {
console.error('Citation Watchlist: unrecoverable error:', e);
loadWarnings.push('Citation Watchlist stopped early due to an error (see console)');
finishChangeListPass();
}).then(() => {
console.log('Citation Watchlist script finished executing');
// On enhanced RC pages, hide the summary gutter on a group's header row
// while the group is expanded (each nested row then shows its own).
document.querySelectorAll('table.mw-enhanced-rc').forEach(table => {
const checkbox = groupToggleCheckbox(table);
if (!checkbox) return;
checkbox.addEventListener('change', () => {
const topRow = table.querySelector('tr');
const gutter = topRow && topRow.querySelector('.cw-gutter');
if (!gutter) return;
const host = gutter.parentElement;
gutter.style.display = checkbox.checked ? 'none' : 'inline-flex';
if (host) host.style.paddingLeft = checkbox.checked ? '' : 'calc(2em + 0.5em)';
});
});
});
})();
ak8yx1k7vetaufc6h8g1ksu1qhi1f3r
781271
781270
2026-10-27T23:38:07Z
Harej
7994
Adding date
781271
javascript
text/javascript
/**
*
* Citation Watchlist
* https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist
*
*/
(function() {
'use strict';
/**
* ==========================================================================
* LOCALIZATION
* ==========================================================================
*/
/* Labeling tool in the user interface */
const toolName = 'Citation Watchlist';
const aboutLink = 'https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist';
/* Revision inspection popup */
const addedLabel = 'Added';
const removedLabel = 'Removed';
const viewDiffLabel = 'View Diff';
const viewRevisionLabel = 'View Revision';
const aboutLabel = 'About Citation Watchlist';
const archivedViaLabel = 'Archived copy hosted at';
/* Filter widget */
const showEditsLabel = 'Show edits:';
const loadingLabel = 'Loading...';
const allLabel = 'All';
const linkChangesLabel = 'Link Changes';
const linkChangesTooltip = 'Edits that add or remove links in articles';
const noMatchingEditsLabel = 'No matching edits';
/* Load-failure indicator */
const loadWarningGlyph = '⚠';
const loadWarningPrefix = 'Some Citation Watchlist lists could not be loaded:';
/**
* ==========================================================================
* DOMAIN LIST CONFIGURATION
* ==========================================================================
*
* Citation Watchlist requires the following wiki pages to function:
*
* 1. Public Suffix List
* - A local copy of the public suffix list, used for domain parsing.
* - Copy the contents of:
* https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist/Public_Suffix_List
* to a page on your own wiki.
* - Update the `publicSuffixList` variable below to reflect your page title.
*
* 2. List of Lists
* - A page linking to one or more domain list pages.
* - Format as a bullet list: "* [[Page Title]]" (space after asterisk).
* - Reference formatting example:
* https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist/Lists
* - Update the `listOfLists` variable below accordingly.
*
* 3+. Domain List Pages
* - One or more pages listing suspicious or noteworthy domains.
* - Each page must contain section headers that match the `indicators` config
* below (e.g., "==Warn==", "==Caution==").
* - Under each section, list domains in the format: "* example.com"
* - Do not use link formatting—just plain text.
* - Entries with paths ("example.com/opinion"), no dot ("gov"), or a
* leading "www." are not supported and are reported in the console.
*/
const publicSuffixList = 'Wikipedia:Citation_Watchlist/Public_Suffix_List';
const listOfLists = 'Wikipedia:Citation_Watchlist/Lists';
const enabledNamespaces = [-1, 0, 118];
const enabledSectionHeaders = ['References'];
/**
* Detection options.
* - UNWRAP_ARCHIVES: screen the original URL embedded in a web archive link
* (web.archive.org, archive.today, Google cache) rather than the archive
* host. The popup still links to the cited archive URL.
* - SHOW_LINT_IN_WIDGET: also show domain-list lint (ignored entries) in the
* filter widget's warning tooltip. Lint always goes to the console.
*/
const UNWRAP_ARCHIVES = true;
const SHOW_LINT_IN_WIDGET = false;
/**
* ==========================================================================
* INDICATOR CONFIGURATION
* ==========================================================================
*
* Defines metadata for domain indicators used in the watchlist UI.
* Each indicator is associated with a level of urgency and a unique symbol.
*
* Fields:
* - msg: Display label for the level (e.g., "Warning", "Caution").
* - emoji: Unicode character for the visual indicator (escaped as `\uXXXX`).
* - section: Must exactly match the section headers in the domain list pages.
* - priority: Higher values override lower ones for conflicting domain matches.
* Priority scale: 1 (lowest) to N (highest).
* - list: Defined as "new Set()" for all indicator types.
*
* If a domain appears in multiple lists, the one with the highest priority
* takes precedence.
*/
const indicators = {
warning: {
msg: 'Warning',
emoji: '❗',
section: '==Warn==',
explanation: 'This domain is classified as an unreliable source',
priority: 3,
dataAttr: 'data-cw-linkwarn',
list: new Set()
},
caution: {
msg: 'Caution',
emoji: '✋',
section: '==Caution==',
explanation: 'This source may be unreliable depending on context',
priority: 2,
dataAttr: 'data-cw-linkcaution',
list: new Set()
},
inspect: {
msg: 'Inspect',
emoji: '🔎',
section: '==Inspect==',
explanation: 'Take a closer look at this particular source',
priority: 1,
dataAttr: 'data-cw-linkinspect',
list: new Set()
},
added: {
msg: addedLabel,
emoji: '\u{1F517}',
section: null,
explanation: 'All added links',
priority: 0,
list: new Set()
},
removed: {
msg: removedLabel,
emoji: '\u{1F5D1}',
section: null,
explanation: 'All removed links',
priority: -1,
list: new Set()
}
};
/** Indicator types that come from the domain lists (priority > 0). */
const listedTypes = Object.keys(indicators).filter(t => indicators[t].priority > 0);
/**
* Citation Watchlist
*
* Highlights potentially questionable citations added in Wikipedia revisions
* based on community-built lists of domain names
*
*
* Documentation: https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist
*
* Author: James Hare
* License: GNU General Public License v3.0 (GPL-3.0)
*
* @version 2.5
* @since 2026-10-27
*/
/**
* IF YOU WANT TO MODIFY any part of the script below this point, please submit
* your edits to https://test.wikipedia.org/wiki/User:Harej/citation-watchlist.js
* so that your modifications can be tested.
*/
let publicSuffixSet = new Set();
const MAX_DOMAINS_PER_LIST = 50000;
const MIN_PSL_ENTRIES = 1000;
/**
* Revisions larger than this are not scanned. MediaWiki's own page-size
* limit is 2 MiB, so this only trips on pathological cases; it bounds the
* download and regex work done per row.
*/
const MAX_REVISION_CHARS = 2 * 1024 * 1024;
/**
* Revision fetching strategy. Rows are never fetched one at a time: revision
* IDs are grouped into small chunks (so the first chunk returns quickly and
* its rows render while the rest are still in flight), several chunks are
* kept in flight at once, and rows are annotated the moment their chunk
* arrives. Concurrency is capped because origin=* requests are anonymous and
* therefore per-IP rate limited (see release notes for 1.5/1.6).
*/
const CONTENT_CHUNK_SIZE = 8; // max revisions per content request
const CONTENT_CHUNK_BYTES = 400 * 1024; // soft byte budget per chunk when sizes are known
const CONTENT_CONCURRENCY = 4; // simultaneous content requests
const METADATA_CHUNK_SIZE = 50; // revids per ids|size request (API max for users)
const ASSUMED_REVISION_BYTES = 40 * 1024; // chunking estimate when size is unknown
const LIVE_RELOAD_DEBOUNCE_MS = 250; // settle time after the change list is replaced
/**
* Non-fatal problems encountered while loading configuration (missing list
* pages, API failures). Surfaced in the filter widget so list maintainers
* notice quickly, instead of the tool silently running with partial data.
*/
const loadWarnings = [];
/**
* Domain-list lint: entries that were ignored because they are not bare
* domains. Always logged; shown in the widget only if SHOW_LINT_IN_WIDGET.
*/
const listLint = [];
/**
* Unified cache configuration. Each entry defines a cache key prefix, its TTL,
* version number, an optional cap on the number of entries kept, and a
* human-readable description.
*
* - revURLs: per-revision list of extracted URLs. Revisions are immutable,
* so the TTL and cap exist only to bound storage.
* - sectionURLs: per-revision, per-section URL map from rendered article HTML.
* - domainList: parsed domain lists ({warning: [...], caution: [...], ...}).
* - publicSuffixListCache: validated PSL entries as an array.
* - citationWatchlistFetchDomainListPages_: list-of-lists page titles.
*/
const cacheConfig = [
{
prefix: 'revURLs:',
ttl: 30 * 24 * 60 * 60 * 1000,
version: 1,
maxEntries: 4000,
description: 'Revision URL cache'
},
{
prefix: 'sectionURLs:',
ttl: 30 * 24 * 60 * 60 * 1000,
version: 1,
maxEntries: 500,
description: 'Article section URL cache'
},
{
prefix: 'domainList:',
ttl: 6 * 60 * 60 * 1000,
version: 1,
description: 'Domain list cache'
},
{
prefix: 'publicSuffixListCache',
ttl: 24 * 60 * 60 * 1000,
version: 1,
description: 'Public suffix list cache'
},
{
prefix: 'citationWatchlistFetchDomainListPages_',
ttl: 4 * 60 * 60 * 1000,
version: 0,
description: 'Domain list pages cache'
}
];
/** Cache prefixes from earlier versions, removed on startup. */
const retiredCachePrefixes = ['revisionDiff:'];
/** How often (ms) the full localStorage expiry/eviction pass runs. */
const CACHE_PURGE_INTERVAL = 60 * 60 * 1000;
/**
* ==========================================================================
* 1. Primary Workflow
* ==========================================================================
*/
/** Resolves once the PSL and domain lists are loaded (true) or failed (false). */
let configReady = null;
/** Rows already handed to processRevisions(), so live reloads only pick up new ones. */
const seenRows = new WeakSet();
/**
* Loads the public suffix list and the domain lists, in parallel. Never
* throws; failures are recorded in loadWarnings.
*
* @returns {Promise<boolean>} True if the tool can screen domains.
*/
async function loadConfiguration() {
try {
const [psl, listPages] = await Promise.all([
fetchPublicSuffixList(),
fetchDomainListPages(listOfLists)
]);
publicSuffixSet = psl;
if (publicSuffixSet.size === 0) {
console.error('Public Suffix List loading failed');
loadWarnings.push('Public Suffix List could not be loaded');
return false;
}
console.log('Welcome to Citation Watchlist');
if (listPages && listPages.length > 0) {
await fetchAndOrganizeDomainLists(listPages);
} else {
loadWarnings.push(`Could not load list of lists (${listOfLists})`);
}
if (listLint.length > 0) {
console.warn(`Citation Watchlist: ${listLint.length} domain-list entries were ignored:\n` +
listLint.join('\n'));
}
return true;
} catch (e) {
console.error('Citation Watchlist: configuration load failed:', e);
loadWarnings.push('Configuration could not be loaded (see console)');
return false;
}
}
/**
* Function called by the entry point for Citation Watchlist.
* Determines if the current page should be analyzed, starts loading the
* configuration, collects the revisions behind each change-list row, and
* fetches and annotates them while the configuration loads.
*/
async function analyzeView() {
purgeCaches();
const ns = mw.config.get('wgNamespaceNumber');
const inspectEnabled = enabledNamespaces.includes(ns);
// Start the configuration load immediately; nothing below waits on it
// until the first row is ready to render.
if (inspectEnabled) {
configReady = loadConfiguration();
}
// Article surface: scan enabled sections for watchlisted domains
const action = mw.config.get('wgAction');
if (action === 'view' && ns >= 0 && inspectEnabled) {
if (await configReady) {
await analyzeArticleSections();
}
return;
}
// Pre-pad change log lines immediately on Special:RecentChanges for visual stability
if (ns === -1) {
document.querySelectorAll('li.mw-changeslist-line, .mw-changeslist-line')
.forEach(line => prePadElement(line));
flushGutterPadding();
}
if (!inspectEnabled) {
return;
}
createChangeListFilterWidget();
await annotateChangeList(document, true);
installLiveReloadObserver();
}
/**
* Collects and annotates every unseen change-list row under `root`.
*
* @param {Document|Element} root - Where to look for rows.
* @param {boolean} allowFirstRevisionFallback - On a page history with no
* diff links (single-revision page), analyze the page's first revision.
*/
async function annotateChangeList(root, allowFirstRevisionFallback) {
const revisions = [];
let noLinks = true;
// Prefer iterating per-change rows when available (JS-enhanced watchlist/RC);
// fall back to link containers on non-enhanced layouts.
const unseen = selector => Array.from(root.querySelectorAll(selector)).filter(el => !seenRows.has(el));
let entries = unseen('[data-mw-revid]:not(.mw-userlink), .mw-changeslist-line');
let prePadCallback = null;
if (entries.length > 0) {
entries.forEach(line => prePadElement(line));
flushGutterPadding();
} else {
entries = unseen('.mw-changeslist-links');
prePadCallback = (_el, link) => {
const target = link.parentNode.parentNode;
prePadElement(target);
flushGutterPadding();
return target;
};
}
for (const entry of entries) {
seenRows.add(entry);
const result = await extractEntry(entry, entry, prePadCallback);
if (result.hasLink) noLinks = false;
if (!result.skip && result.revision) revisions.push(result.revision);
}
// If no links were found, extract the first revision ID
if (allowFirstRevisionFallback && noLinks && entries.length > 0) {
try {
const firstID = await fetchFirstRevisionId(mw.config.get('wgTitle'));
if (firstID) {
revisions.push({
oldrevision: String(firstID),
element: entries[0],
viewHref: buildPermalinkUrl(firstID),
viewLabel: viewRevisionLabel
});
}
} catch (e) {
console.warn('Citation Watchlist: single-revision fallback failed:', e);
}
}
await processRevisions(revisions);
finishChangeListPass();
}
/**
* Extracts the revision behind one change-list entry, isolating failures so
* that a single malformed row does not abort the remaining rows.
*
* @param {Element} container - The DOM element to query for revision links.
* @param {Element} element - The DOM element to attach to the revision object.
* @param {Function|null} prePadCallback - See extractRevisionFromLinks.
* @returns {Promise<{revision: Object|null, hasLink: boolean, skip: boolean}>}
*/
async function extractEntry(container, element, prePadCallback) {
try {
return await extractRevisionFromLinks(container, element, prePadCallback);
} catch (e) {
console.warn('Citation Watchlist: skipping entry due to error:', e);
// Report hasLink=true so a row-level failure never triggers the
// single-revision fallback for the whole page.
return { revision: null, hasLink: true, skip: true };
}
}
/**
* Annotates a set of revisions with as little latency as possible:
*
* 1. Parent IDs (for diff=prev and history rows) and sizes are resolved in
* one metadata request, only when any row needs them.
* 2. Per-revision URL lists already in the cache are used directly.
* 3. Remaining revision IDs are deduplicated (history rows share revisions),
* chunked in DOM order, fetched with bounded concurrency, and each row is
* rendered as soon as every revision it depends on is available. Fetching
* overlaps the configuration load; only rendering waits for it.
*
* @param {Object[]} revisions - Revision objects from extractRevisionFromLinks.
*/
async function processRevisions(revisions) {
if (revisions.length === 0) return;
// --- 1. Resolve parents / sizes ---------------------------------------
const parentLookups = revisions.filter(r => r.needsParentOf).map(r => r.needsParentOf);
let metadata = {};
if (parentLookups.length > 0) {
metadata = await fetchRevisionMetadata(unique(parentLookups));
for (const revision of revisions) {
if (!revision.needsParentOf) continue;
const currentId = revision.needsParentOf;
const meta = metadata[currentId];
delete revision.needsParentOf;
if (meta && meta.parentid) {
revision.oldrevision = String(meta.parentid);
revision.newrevision = currentId;
if (revision.pageTitle && !revision.viewHref) {
revision.viewHref = buildDiffUrl(revision.pageTitle,
revision.oldrevision, revision.newrevision);
}
} else if (meta) {
// First revision of the page: everything in it was "added"
revision.oldrevision = currentId;
revision.newrevision = null;
revision.viewHref = buildPermalinkUrl(currentId);
revision.viewLabel = viewRevisionLabel;
} else {
markSkipped(revision, 'fetch-failed');
revision.oldrevision = null;
}
}
}
// --- 2. Per-revision URL lists from cache; queue the rest --------------
const urlLists = new Map(); // id -> string[] | null (null = unreadable)
const pending = []; // { revision, needs: string[] }
for (const revision of revisions) {
if (revision.skipped) continue;
if (!isRevisionId(revision.oldrevision) ||
(revision.newrevision && !isRevisionId(revision.newrevision))) {
markSkipped(revision, 'invalid');
continue;
}
const needs = [revision.oldrevision];
if (revision.newrevision) needs.push(revision.newrevision);
for (const id of needs) {
if (urlLists.has(id)) continue;
const cached = readCache(`revURLs:${location.hostname}:${id}`);
if (Array.isArray(cached)) urlLists.set(id, cached.filter(isValidHttpUrl));
}
pending.push({ revision, needs });
}
if (pending.length === 0) return;
// --- 3. Fetch missing content in chunks; render as data arrives --------
const neededIds = unique(pending.flatMap(p => p.needs)).filter(id => !urlLists.has(id));
const oversized = new Set();
for (const id of neededIds) {
const meta = metadata[id];
if (meta && typeof meta.size === 'number' && meta.size > MAX_REVISION_CHARS) {
oversized.add(id);
urlLists.set(id, null);
}
}
const chunks = chunkRevisionIds(neededIds.filter(id => !oversized.has(id)), metadata);
let canRender = false;
const settle = () => {
if (!canRender) return;
for (let i = pending.length - 1; i >= 0; i--) {
const item = pending[i];
if (!item.needs.every(id => urlLists.has(id))) continue;
pending.splice(i, 1);
if (item.needs.some(id => oversized.has(id))) {
markSkipped(item.revision, 'oversized');
continue;
}
const oldList = urlLists.get(item.revision.oldrevision);
const newList = item.revision.newrevision ? urlLists.get(item.revision.newrevision) : null;
if (oldList === null || (item.revision.newrevision && newList === null)) {
markSkipped(item.revision, 'fetch-failed');
continue;
}
const diff = computeURLDiff(oldList, newList, !!item.revision.newrevision);
renderRevision(item.revision, diff.addedURLs, diff.removedURLs);
}
flushGutterPadding();
};
const onText = (id, text) => {
if (text === null) {
urlLists.set(id, null);
return;
}
if (text.length > MAX_REVISION_CHARS) {
oversized.add(id);
urlLists.set(id, null);
return;
}
const urls = extractURLs(stripHTMLComments(text));
urlLists.set(id, urls);
writeCache(`revURLs:${location.hostname}:${id}`, urls);
};
// Dispatch content fetches now; they run while the lists are still loading.
const fetching = runPool(chunks.map(chunk => async () => {
await fetchChunkIsolating(chunk, onText);
settle();
}), CONTENT_CONCURRENCY);
if (!(await configReady)) {
await fetching;
return; // no lists: nothing to screen against
}
canRender = true;
settle(); // cache hits and anything that landed while the lists loaded
await fetching;
// Anything still pending had an id the API never returned
for (const item of pending) {
markSkipped(item.revision, 'fetch-failed');
}
}
/**
* Fetches a chunk of revisions; if the request fails outright, splits the
* chunk in half and retries each half, so one problematic revision only
* costs itself rather than its neighbours. Each fetch already carries
* safeFetch's retries, so this only runs in genuine failure cases.
*
* @param {string[]} chunk - Revision IDs.
* @param {Function} onText - Called with (id, text|null) for every id.
*/
async function fetchChunkIsolating(chunk, onText) {
const result = await fetchRevisionTexts(chunk);
if (result) {
for (const id of chunk) {
onText(id, result.has(id) ? result.get(id) : null);
}
return;
}
if (chunk.length === 1) {
onText(chunk[0], null);
return;
}
const mid = Math.ceil(chunk.length / 2);
await fetchChunkIsolating(chunk.slice(0, mid), onText);
await fetchChunkIsolating(chunk.slice(mid), onText);
}
/**
* Groups revision IDs into fetch chunks in order, bounded by count and, when
* sizes are known, by an approximate byte budget so one huge article does not
* delay its neighbours.
*
* @param {string[]} ids - Revision IDs in the order they should be fetched.
* @param {Object} metadata - Map of id -> {parentid, size} (may be sparse).
* @returns {string[][]}
*/
function chunkRevisionIds(ids, metadata) {
const chunks = [];
let current = [];
let currentBytes = 0;
for (const id of ids) {
const meta = metadata[id];
const bytes = (meta && typeof meta.size === 'number') ? meta.size : ASSUMED_REVISION_BYTES;
if (current.length > 0 &&
(current.length >= CONTENT_CHUNK_SIZE || currentBytes + bytes > CONTENT_CHUNK_BYTES)) {
chunks.push(current);
current = [];
currentBytes = 0;
}
current.push(id);
currentBytes += bytes;
}
if (current.length > 0) chunks.push(current);
return chunks;
}
/**
* Runs async task factories with at most `concurrency` in flight, in order.
*
* @param {Array<() => Promise<*>>} tasks
* @param {number} concurrency
* @returns {Promise<void>}
*/
async function runPool(tasks, concurrency) {
let next = 0;
const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, async () => {
while (next < tasks.length) {
const task = tasks[next++];
try {
await task();
} catch (e) {
console.warn('Citation Watchlist: chunk failed:', e);
}
}
});
await Promise.all(workers);
}
/**
* Returns the elements of an array with duplicates removed, first occurrence wins.
*
* @param {Array} arr
* @returns {Array}
*/
function unique(arr) {
return Array.from(new Set(arr));
}
/**
* Marks a revision's row as skipped with a reason, without annotating it.
*
* @param {Object} revision
* @param {string} reason - 'fetch-failed', 'oversized' or 'invalid'.
*/
function markSkipped(revision, reason) {
revision.skipped = reason;
try {
revisionTargetElement(revision).setAttribute('data-cw-skipped', reason);
} catch (e) {
/* ignore */
}
console.warn(`Citation Watchlist: skipped ${revision.oldrevision || '?'}` +
`${revision.newrevision ? ' -> ' + revision.newrevision : ''} (${reason})`);
}
/**
* Finds the row-level element that carries data attributes for a revision.
*
* @param {Object} revision
* @returns {Element}
*/
function revisionTargetElement(revision) {
const el = revision.element;
return el.closest('[data-mw-revid]:not(.mw-userlink)') ||
el.closest('li') || el.closest('.mw-changeslist-line, tr') || el;
}
/**
* Scans the article page for sections whose H2 headers match enabledSectionHeaders,
* then analyzes each matching section for watchlisted domains.
*/
async function analyzeArticleSections() {
const headings = document.querySelectorAll('.mw-heading2 h2, h2');
for (const h2 of headings) {
const headerText = h2.textContent.trim();
if (!enabledSectionHeaders.includes(headerText)) continue;
// Collect all content between this heading and the next heading
const headingContainer = h2.closest('.mw-heading') || h2;
let sibling = headingContainer.nextElementSibling;
const sectionElements = [];
while (sibling && !sibling.classList.contains('mw-heading')) {
sectionElements.push(sibling);
sibling = sibling.nextElementSibling;
}
try {
analyzeSection(headerText, sectionElements);
createArticleFilterWidget(headingContainer);
} catch (e) {
console.warn(`Citation Watchlist: failed to analyze section "${headerText}":`, e);
}
}
}
/**
* Analyzes a single article section: extracts URLs from each <li>, screens them
* against domain lists, and prepends indicator icons to matching list items.
*
* @param {string} sectionName - The section header text (e.g. "References").
* @param {Element[]} sectionElements - DOM elements between this heading and the next.
*/
function analyzeSection(sectionName, sectionElements) {
const revisionId = mw.config.get('wgRevisionId');
const cacheKey = `sectionURLs:${location.hostname}:${revisionId}:${sectionName}`;
// Map: array of { liId, urls }. Anything read back from localStorage is
// re-validated before it can reach an href.
let liURLMap = null;
const cachedMap = readCache(cacheKey);
if (Array.isArray(cachedMap)) {
liURLMap = cachedMap
.filter(entry => entry && typeof entry.liId === 'string' && Array.isArray(entry.urls))
.map(entry => ({ liId: entry.liId, urls: entry.urls.filter(isValidHttpUrl) }));
}
const citeNotes = sectionElements.flatMap(el => Array.from(el.querySelectorAll('li[id^="cite_note"]')));
// Build from DOM if not cached
if (!liURLMap) {
liURLMap = citeNotes.map(li => ({
liId: li.id,
urls: Array.from(li.querySelectorAll('a.external[href]'))
.map(a => a.getAttribute('href'))
.filter(href => /^https?:\/\//i.test(href))
}));
writeCache(cacheKey, liURLMap);
}
// Add padding to all list items so citation text is flush with icons
for (const li of citeNotes) li.style.paddingLeft = '1.5em';
// Screen each li's URLs against domain lists and prepend icons with popups
for (const entry of liURLMap) {
const li = document.getElementById(entry.liId);
if (!li) continue;
const matched = resolveURLs(entry.urls).filter(r => r.indicator);
if (matched.length === 0) continue;
const highest = highestPriority(matched.map(m => m.indicator));
const wrapper = makeElement('span', {
style: { position: 'relative', cursor: 'pointer', marginLeft: '-1.5em',
display: 'inline-block', width: '1.5em' }
});
wrapper.appendChild(makeElement('span', { text: indicators[highest].emoji }));
attachPopup(wrapper, matched.map(m => popupLine(m.indicator, [m], false)), {});
li.prepend(wrapper);
li.dataset.cwIndicator = highest;
}
}
/**
* Extracts revision information from diff/hist/prev/cur links within a container.
* Parent IDs are not fetched here; rows that need one set `needsParentOf` and
* processRevisions() resolves them in a single batched request.
*
* @param {Element} container - The DOM element to query for revision links.
* @param {Element} element - The DOM element to attach to the revision object.
* @param {Function|null} prePadCallback - Optional callback invoked with (element, link) before
* processing each branch. In fallback mode this derives the target element and pre-pads it.
* @returns {Promise<{revision: Object|null, hasLink: boolean, skip: boolean}>}
* - revision: the extracted revision object, or null if no usable link was found.
* - hasLink: true if any recognized link was present (even if skipped).
* - skip: true if the entry should be skipped (non-article after pre-padding).
*/
async function extractRevisionFromLinks(container, element, prePadCallback) {
const kinds = [
{ selector: 'a.mw-changeslist-diff', kind: 'diff' },
{ selector: 'a.mw-changeslist-history', kind: 'hist' },
{ selector: 'a.mw-history-histlinks-previous', kind: 'prev' },
{ selector: 'a.mw-history-histlinks-current', kind: 'cur' }
];
let link = null;
let kind = null;
for (const k of kinds) {
link = container.querySelector(k.selector);
if (link) {
kind = k.kind;
break;
}
}
if (!link) {
return { revision: null, hasLink: false, skip: false };
}
const params = new URL(link.href).searchParams;
const pageTitle = params.get('title');
const target = prePadCallback ? prePadCallback(element, link) : element;
// The diff and hist links appear on watchlist/RC/contribs rows that can be
// for any namespace; history prev/cur links are always for the page itself.
if ((kind === 'diff' || kind === 'hist') && isNotArticle(pageTitle)) {
return { revision: null, hasLink: true, skip: true };
}
const revision = {
oldrevision: null,
newrevision: null,
element: target,
viewHref: '',
viewLabel: viewDiffLabel,
pageTitle: pageTitle || undefined
};
switch (kind) {
case 'diff': {
// MediaWiki convention: diff = newer, oldid = older
const diffParam = params.get('diff');
const oldidParam = params.get('oldid');
revision.viewHref = link.href;
if (diffParam === 'prev' && oldidParam) {
// Contributions: oldid is the current revision; parent resolved later
revision.needsParentOf = oldidParam;
} else {
revision.oldrevision = oldidParam;
revision.newrevision = diffParam;
}
break;
}
case 'hist': {
// New page: analyze its first revision
const firstID = await fetchFirstRevisionId(pageTitle);
if (!firstID) {
return { revision: null, hasLink: true, skip: false };
}
revision.oldrevision = String(firstID);
revision.viewHref = buildPermalinkUrl(firstID);
revision.viewLabel = viewRevisionLabel;
break;
}
case 'prev': {
// viewHref is filled in once the parent is known
revision.needsParentOf = params.get('oldid') || null;
break;
}
case 'cur': {
revision.oldrevision = params.get('oldid');
revision.viewHref = buildPermalinkUrl(revision.oldrevision);
revision.viewLabel = viewRevisionLabel;
break;
}
}
return { revision, hasLink: true, skip: false };
}
/**
* Computes the URLs added and removed between two revisions' URL lists.
* Multiset semantics: a URL that appears twice in the new revision and once
* in the old counts as one addition.
*
* @param {string[]} fromList - URLs in the older revision.
* @param {string[]|null} toList - URLs in the newer revision (ignored if !hasNew).
* @param {boolean} hasNew - False for a page's first revision, in which case
* every URL in fromList is considered added.
* @returns {{addedURLs: string[], removedURLs: string[]}}
*/
function computeURLDiff(fromList, toList, hasNew) {
if (!hasNew) {
// For first revision, all URLs are considered added
return { addedURLs: fromList.slice(), removedURLs: [] };
}
const count = arr => arr.reduce((m, u) => {
m[u] = (m[u] || 0) + 1;
return m;
}, {});
const fromMap = count(fromList);
const toMap = count(toList || []);
const addedURLs = [];
const removedURLs = [];
for (const key of new Set([...Object.keys(fromMap), ...Object.keys(toMap)])) {
const addCount = Math.max(0, (toMap[key] || 0) - (fromMap[key] || 0));
const remCount = Math.max(0, (fromMap[key] || 0) - (toMap[key] || 0));
for (let i = 0; i < addCount; i++) addedURLs.push(key);
for (let i = 0; i < remCount; i++) removedURLs.push(key);
}
return { addedURLs, removedURLs };
}
/**
* Screens a revision's added/removed URLs against the domain lists and
* annotates its row (gutter icons, popup, filter data attributes).
* Synchronous: all network work happens in processRevisions().
*
* @param {Object} revision - Object containing oldrevision, optional
* newrevision, and DOM element.
* @param {string[]} addedURLs
* @param {string[]} removedURLs
*/
function renderRevision(revision, addedURLs, removedURLs) {
const targetElement = revisionTargetElement(revision);
targetElement.setAttribute('data-cw-processed', 'true');
const added = resolveURLs(addedURLs);
const removed = resolveURLs(removedURLs);
// Highest-priority indicator among added links (lists only), for the gutter
const addType = highestPriority(added.map(a => a.indicator)) ||
(added.length > 0 ? 'added' : null);
try {
if (revision.viewHref) revision.element.dataset.cwViewHref = revision.viewHref;
if (revision.viewLabel) revision.element.dataset.cwViewLabel = revision.viewLabel;
} catch (e) {
/* ignore */
}
// Render two-column emojis: left = addition (highest-priority), right = removal (bin)
renderTwoColumnEmojis(revision.element, addType, removed.length > 0, added, removed);
// Data attributes drive the filter widget
const changedDomains = unique([...added, ...removed].map(r => r.domain)).sort();
if (changedDomains.length > 0) {
targetElement.dataset.cwLinkchange = changedDomains.join('|');
}
for (const type of listedTypes) {
const domains = unique([...added, ...removed]
.filter(r => r.indicator === type).map(r => r.domain)).sort();
if (domains.length > 0) {
targetElement.setAttribute(indicators[type].dataAttr, domains.join('|'));
}
}
}
/**
* ==========================================================================
* 2. User Interface
* ==========================================================================
*/
/**
* Pending hosts that need gutter padding applied. Collected during
* renderTwoColumnEmojis and flushed in a single batch via flushGutterPadding()
* so that all rows shift at the same time instead of one at a time.
*/
const pendingGutterPadding = [];
/**
* Applies the gutter padding to a single host element, reserving horizontal
* space for the two-column emoji gutter.
*
* @param {HTMLElement} host - The element to pad.
*/
function applyGutterPadding(host) {
const pad = 'calc(2em + 0.5em)';
try {
const cs = window.getComputedStyle(host);
const existing = parseFloat(cs.paddingLeft || '0');
const fontSizePx = parseFloat(cs.fontSize || '16');
const desiredPx = 2.5 * fontSizePx; // 2em + 0.5em
if (isNaN(existing) || existing < desiredPx - 0.5) {
host.style.paddingLeft = pad;
}
} catch (e) {
host.style.paddingLeft = pad;
}
}
/**
* Applies gutter padding to all collected hosts at once, then clears the
* queue. Call this after a batch of renderTwoColumnEmojis calls to avoid
* one-at-a-time layout shifts.
*/
function flushGutterPadding() {
for (const host of pendingGutterPadding) {
applyGutterPadding(host);
}
pendingGutterPadding.length = 0;
}
/**
* Finds the element that hosts the gutter for a change-list element:
* the list container or, for table rows, the content cell.
*
* @param {HTMLElement} element
* @returns {HTMLElement|null}
*/
function gutterHostFor(element) {
const container =
element.closest('[data-mw-revid]:not(.mw-userlink)') ||
element.closest('li') ||
element.closest('.mw-changeslist-line, tr') || element.parentNode || element;
if (!container) return null;
let host = container;
if (host.tagName && host.tagName.toUpperCase() === 'TR') {
// For nested enhanced RC rows, place the gutter in the content cell so
// icons align with singleton rows that use .mw-changeslist-line-inner.
const nestedContent = host.querySelector('td.mw-enhanced-rc-nested[data-target-page]');
host = nestedContent || host.querySelector('td, th') || host;
}
// Prefer inner change-line wrappers on Watchlist/RC if present
const innerHost = host.querySelector('.mw-changeslist-line-inner, .mw-changeslist-line-main');
return innerHost || host;
}
/**
* Renders exactly two fixed-width emoji columns before a revision entry:
* - Left column: highest-priority addition indicator
* (warning/caution/inspect/added), or spacer if none.
* - Right column: removal indicator if removals exist, otherwise spacer.
*
* This preserves consistent alignment even when no links are added/removed.
*
* @param {HTMLElement} element - The revision list DOM element.
* @param {string|null} addType - Indicator type for the left column, or null.
* @param {boolean} hasRemovals - Whether to show the removal icon.
* @param {Object[]} added - Resolved added links ({url, domain, indicator, ...}).
* @param {Object[]} removed - Resolved removed links.
*/
function renderTwoColumnEmojis(element, addType, hasRemovals, added = [], removed = []) {
if (!element) return;
const host = gutterHostFor(element);
if (!host) return;
const cell = (char, className) => makeElement('span', {
className, text: char,
style: { display: 'inline-block', width: '1em', textAlign: 'center', userSelect: 'none' }
});
const addChar = addType ? indicators[addType].emoji : ' ';
const remChar = hasRemovals ? indicators.removed.emoji : ' ';
// Propagate dataset for view link from source element to host
try {
if (element.dataset.cwViewHref) host.dataset.cwViewHref = element.dataset.cwViewHref;
if (element.dataset.cwViewLabel) host.dataset.cwViewLabel = element.dataset.cwViewLabel;
} catch (e) {
/* ignore */
}
// Ensure a single gutter exists
let gutter = host.querySelector('.cw-gutter');
if (gutter) {
gutter.querySelector('.cw-add').textContent = addChar;
gutter.querySelector('.cw-rem').textContent = remChar;
} else {
// Ensure the host can anchor absolutely positioned children
if (!host.style.position) host.style.position = 'relative';
// Create a left gutter that does not participate in text wrapping
gutter = makeElement('span', {
className: 'cw-gutter',
style: {
position: 'absolute', left: '0', top: '0', display: 'inline-flex', gap: '0.25em',
width: 'calc(2em + 0.25em)', alignItems: 'flex-start', userSelect: 'none'
}
});
gutter.appendChild(cell(addChar, 'cw-add'));
gutter.appendChild(cell(remChar, 'cw-rem'));
// Insert the gutter into the host; padding is deferred to flushGutterPadding()
host.insertBefore(gutter, host.firstChild);
pendingGutterPadding.push(host);
}
attachPopup(gutter, [popupLine('added', added, true), popupLine('removed', removed, true)], {
viewHref: host.dataset.cwViewHref || '',
viewLabel: host.dataset.cwViewLabel || ''
});
host.setAttribute('data-processed-twocol', 'true');
}
/**
* Fills a popup with a close button, a two-column grid of labelled link
* lines, and a footer. Idempotent: safe to call again to refresh content.
*
* @param {HTMLElement} popupEl - The popup element.
* @param {Array<{labelText: string, labelTitle: string, items: Object[]}>} lines
* Each item is a resolved link ({url, domain, indicator, archiveHost}).
* @param {Object} footerOptions - See createPopupFooter.
*/
function buildPopupContent(popupEl, lines, footerOptions) {
if (!popupEl.querySelector('.cw-close')) {
popupEl.appendChild(createCloseButton(popupEl));
}
let contentWrap = popupEl.querySelector('.cw-popup-content');
if (!contentWrap) {
contentWrap = makeElement('div', { className: 'cw-popup-content' });
popupEl.appendChild(contentWrap);
}
// Grid layout so labels are flush-aligned with content
Object.assign(contentWrap.style, {
display: 'grid', gridTemplateColumns: 'max-content 1fr', columnGap: '0.5em', rowGap: '2px'
});
contentWrap.innerHTML = '';
for (const line of lines) {
if (!line.items || line.items.length === 0) continue;
contentWrap.appendChild(makeElement('strong', {
text: `${line.labelText}:`, title: line.labelTitle,
ariaLabel: `${line.labelText}: ${line.labelTitle}`,
style: { alignSelf: 'start', justifySelf: 'end', textAlign: 'right', whiteSpace: 'nowrap' }
}));
const content = makeElement('div', { style: { display: 'inline' } });
line.items.forEach((item, idx) => {
content.appendChild(makeLinkItem(item));
if (idx < line.items.length - 1) content.appendChild(makeElement('span', { text: ' • ' }));
});
contentWrap.appendChild(content);
}
const footer = popupEl.querySelector('.cw-popup-footer');
if (footer) footer.remove();
popupEl.appendChild(createPopupFooter(footerOptions));
}
/**
* Creates (or refreshes) the popup under a host element and wires its
* hover/click handlers. With no lines, any existing popup is hidden.
*
* @param {HTMLElement} host - The gutter or icon wrapper that owns the popup.
* @param {Array} lines - See buildPopupContent.
* @param {Object} footerOptions - See createPopupFooter.
*/
function attachPopup(host, lines, footerOptions) {
let popup = host.querySelector('.cw-popup');
const hasContent = lines.some(l => l.items && l.items.length > 0);
if (!hasContent) {
if (popup) popup.style.display = 'none';
host._cwEnablePopup = false;
return;
}
if (!popup) {
popup = createPopupElement();
host.appendChild(popup);
}
buildPopupContent(popup, lines, footerOptions);
host._cwEnablePopup = true;
installPopupHandlers(host);
}
/**
* Builds a popup line for an indicator type over a set of resolved links.
*
* @param {string} type - Indicator type (added, removed, warning, ...).
* @param {Object[]} items - Resolved links.
* @param {boolean} withEmoji - Prefix the label with the type's emoji.
* @returns {{labelText: string, labelTitle: string, items: Object[]}}
*/
function popupLine(type, items, withEmoji) {
const ind = indicators[type];
return {
labelText: withEmoji ? `${ind.emoji} ${ind.msg}` : ind.msg,
labelTitle: ind.explanation,
items
};
}
/**
* Builds the popup representation of one resolved link: an optional
* indicator emoji followed by a link whose text is the screened domain.
*
* @param {Object} item - {url, domain, indicator, archiveHost}.
* @returns {HTMLSpanElement}
*/
function makeLinkItem(item) {
const span = makeElement('span', { style: { whiteSpace: 'nowrap', display: 'inline-block' } });
if (item.indicator) {
const ind = indicators[item.indicator];
span.appendChild(makeElement('span', {
className: 'cw-emoji', text: ind.emoji + ' ',
title: ind.explanation, ariaLabel: `${ind.msg}: ${ind.explanation}`
}));
}
const a = makeExternalLink(item.url, item.domain);
if (item.archiveHost) a.title = `${archivedViaLabel} ${item.archiveHost}`;
span.appendChild(a);
return span;
}
/**
* Creates a link with the attributes every outbound link in this script
* uses. The single place an attacker-influenced URL becomes an href, so the
* URL check lives here: absolute http(s) only, plus same-origin paths when
* `allowPath` is set (used for diff/permalink footers).
*
* @param {string} url
* @param {string} text
* @param {boolean} [allowPath=false]
* @returns {HTMLAnchorElement}
*/
function makeExternalLink(url, text, allowPath = false) {
const a = makeElement('a', {
text, attrs: { rel: 'nofollow noopener noreferrer', target: '_blank' },
style: { whiteSpace: 'nowrap' }
});
if (isValidHttpUrl(url) || (allowPath && /^\/(?!\/)/.test(url))) a.href = url;
return a;
}
/**
* Creates a DOM element with class, text, title, inline styles and
* attributes in one call. Text is always set through textContent.
*
* @param {string} tag
* @param {Object} [opts] - {className, text, title, ariaLabel, style, attrs}
* @returns {HTMLElement}
*/
function makeElement(tag, opts = {}) {
const el = document.createElement(tag);
if (opts.className) el.className = opts.className;
if (opts.text !== undefined) el.textContent = opts.text;
if (opts.title) {
el.title = opts.title;
el.setAttribute('aria-label', opts.ariaLabel || opts.title);
}
if (opts.style) Object.assign(el.style, opts.style);
for (const [k, v] of Object.entries(opts.attrs || {})) el.setAttribute(k, v);
return el;
}
/**
* Creates a styled popup element for displaying domain information.
*
* @returns {HTMLDivElement} The configured popup element.
*/
function createPopupElement() {
return makeElement('div', {
className: 'mwe-popups cw-popup',
style: {
position: 'absolute', minWidth: '260px', maxWidth: '420px', zIndex: '1000',
display: 'none', background: 'var(--background-color, #fff)',
border: '1px solid rgba(0,0,0,.15)', boxShadow: '0 2px 8px rgba(0,0,0,.2)',
padding: '8px 26px 8px 10px', borderRadius: '4px', fontSize: '0.92em'
}
});
}
/**
* Creates a styled close button for popups.
*
* @param {HTMLElement} popupEl - The popup element to hide when the button is clicked.
* @returns {HTMLButtonElement} The configured close button.
*/
function createCloseButton(popupEl) {
const closeBtn = makeElement('button', {
className: 'cw-close', text: '×', title: 'Close', attrs: { type: 'button' },
style: {
position: 'absolute', top: '4px', right: '6px', border: 'none',
background: 'transparent', cursor: 'pointer', fontSize: '14px',
lineHeight: '1', padding: '0', margin: '0'
}
});
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
popupEl.style.display = 'none';
});
return closeBtn;
}
/**
* Creates a popup footer element with an optional primary link and the
* "About Citation Watchlist" link.
*
* @param {Object} [options] - Optional configuration.
* @param {string} [options.viewHref] - URL for the primary link (e.g., diff or revision).
* @param {string} [options.viewLabel] - Label text for the primary link.
* @returns {HTMLDivElement} The configured footer element.
*/
function createPopupFooter(options) {
const footer = makeElement('div', {
className: 'cw-popup-footer', style: { marginTop: '10px', color: 'inherit' }
});
if (options && options.viewHref && options.viewLabel) {
const viewA = makeExternalLink(options.viewHref, options.viewLabel, true);
if (viewA.href) {
const strong = makeElement('strong');
strong.appendChild(viewA);
footer.appendChild(strong);
footer.appendChild(makeElement('span', { text: ' • ' }));
}
}
footer.appendChild(makeExternalLink(aboutLink, aboutLabel));
return footer;
}
/**
* Builds the shared filter widget container with radio buttons and an about link.
*
* @param {Object} config - Widget configuration.
* @param {string} config.containerId - The ID for the container element.
* @param {string} config.radioName - The name attribute for the radio button group.
* @param {Array<Object>} config.filters - Array of filter option objects
* ({id, label, checked?, tooltip?}).
* @param {Function} config.onFilter - Callback invoked with the selected filter ID.
* @param {string} [config.initialLabel] - Label text; defaults to showEditsLabel.
* @returns {HTMLDivElement} The configured filter widget container.
*/
function buildFilterWidgetContainer(config) {
const container = makeElement('div', {
attrs: { id: config.containerId },
style: {
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
border: '1px solid black', borderRadius: '25px', padding: '10px 20px',
margin: '10px 0', width: 'fit-content'
}
});
const leftSection = makeElement('div', {
style: { display: 'flex', alignItems: 'center', gap: '15px' }
});
leftSection.appendChild(makeElement('span', {
className: 'cw-filter-label', text: config.initialLabel || showEditsLabel,
style: { fontWeight: '500' }
}));
for (const filter of config.filters) {
const wrapper = makeElement('label', {
style: { display: 'flex', alignItems: 'center', gap: '5px', cursor: 'pointer' }
});
const radio = makeElement('input', {
attrs: { type: 'radio', name: config.radioName, value: filter.id }, style: { margin: '0' }
});
radio.checked = !!filter.checked;
radio.addEventListener('change', () => {
if (radio.checked) config.onFilter(filter.id);
});
const text = makeElement('span', { text: filter.label });
if (filter.tooltip) {
text.title = filter.tooltip;
Object.assign(text.style, { borderBottom: '1px dotted', cursor: 'help' });
}
wrapper.appendChild(radio);
wrapper.appendChild(text);
leftSection.appendChild(wrapper);
}
container.appendChild(leftSection);
const rightSection = makeElement('div', { style: { marginLeft: '4em' } });
const link = makeElement('a', {
text: toolName, attrs: { href: aboutLink },
style: { textDecoration: 'underline', color: '#0645ad' }
});
rightSection.appendChild(link);
container.appendChild(rightSection);
return container;
}
/**
* Builds the indicator filter list from the indicators config, sorted by
* descending priority and excluding non-positive priorities.
*
* @returns {Array<{id: string, label: string, tooltip: string}>}
*/
function buildIndicatorFilters() {
return listedTypes
.slice()
.sort((a, b) => indicators[b].priority - indicators[a].priority)
.map(id => ({
id,
label: `${indicators[id].emoji} ${indicators[id].msg}`,
tooltip: indicators[id].explanation
}));
}
/**
* Enables or greys out each radio in a filter widget according to whether
* any row currently matches it.
*
* @param {string} radioName - The radio group name.
* @param {Function} hasResults - (filterId) => boolean.
*/
function updateFilterRadios(radioName, hasResults) {
document.querySelectorAll(`input[name="${radioName}"]`).forEach(radio => {
const enabled = radio.value === 'all' || hasResults(radio.value);
const label = radio.closest('label');
radio.disabled = !enabled;
if (label) {
label.style.cursor = enabled ? 'pointer' : 'default';
label.style.color = enabled ? '' : 'gray';
}
});
}
/**
* Creates and inserts the change-list filter widget (watchlist, recent
* changes, history, contributions). Radios start disabled and are enabled by
* finishChangeListPass() once rows are annotated.
*
* @returns {HTMLElement|null} The created filter container or null if not created.
*/
function createChangeListFilterWidget() {
const ns = mw.config.get('wgNamespaceNumber');
const pageName = mw.config.get('wgPageName');
let target = document.querySelector('.mw-changeslist') ||
document.getElementById('mw-history-compare');
if (!target && ns === -1 && pageName.startsWith('Special:Contributions')) {
target = document.querySelector('.mw-pager-body');
}
if (!target) return null;
const container = buildFilterWidgetContainer({
containerId: 'cw-filter-widget',
radioName: 'cw-filter',
filters: [
{ id: 'all', label: allLabel, checked: true },
{ id: 'linkchange', label: linkChangesLabel, tooltip: linkChangesTooltip },
...buildIndicatorFilters()
],
onFilter: applyChangeListFilter,
initialLabel: loadingLabel
});
target.parentNode.insertBefore(container, target);
updateFilterRadios('cw-filter', () => false); // Disabled until annotation is complete
return container;
}
/**
* Creates and inserts a filter widget for the article surface, placed after
* the section heading. Excludes the "Link Changes" option.
*
* @param {Element} headingContainer - The heading element to insert the widget after.
*/
function createArticleFilterWidget(headingContainer) {
const container = buildFilterWidgetContainer({
containerId: 'cw-article-filter-widget',
radioName: 'cw-article-filter',
filters: [
{ id: 'all', label: allLabel, checked: true },
...buildIndicatorFilters()
],
onFilter: applyArticleFilter
});
headingContainer.after(container);
updateFilterRadios('cw-article-filter', (filterId) =>
!!document.querySelector(`li[id^="cite_note"][data-cw-indicator="${filterId}"]`));
renderLoadWarnings();
}
/**
* Called after each annotation pass on a change list: restores the widget
* label, renders any load warnings, and enables the radios that have matches.
*/
function finishChangeListPass() {
const filterLabel = document.querySelector('#cw-filter-widget .cw-filter-label');
if (filterLabel && filterLabel.firstChild &&
filterLabel.firstChild.nodeType === Node.TEXT_NODE) {
filterLabel.firstChild.textContent = showEditsLabel;
}
renderLoadWarnings();
const rows = document.querySelectorAll('[data-mw-revid]:not(.mw-userlink)');
updateFilterRadios('cw-filter', (filterId) =>
Array.from(rows).some(row => rowMatchesFilter(row, filterId)));
// Re-apply the active filter so newly loaded rows respect it
const active = document.querySelector('input[name="cw-filter"]:checked');
if (active && active.value !== 'all') applyChangeListFilter(active.value);
}
/**
* Whether a change-list row (or any nested revision inside it) matches a filter.
*
* @param {Element} row
* @param {string} filterId
* @returns {boolean}
*/
function rowMatchesFilter(row, filterId) {
if (elementMatchesFilter(row, filterId)) return true;
for (const nested of row.querySelectorAll('li[data-cw-processed="true"]')) {
if (elementMatchesFilter(nested, filterId)) return true;
}
return false;
}
/**
* Applies the selected filter to article section list items.
*
* @param {string} filterId - 'all' or an indicator type.
*/
function applyArticleFilter(filterId) {
document.querySelectorAll('li[id^="cite_note"]').forEach(li => {
li.style.display = (filterId === 'all' || li.dataset.cwIndicator === filterId) ? '' : 'none';
});
}
/**
* Applies the selected filter to the revision list.
*
* @param {string} filterId - 'all', 'linkchange', or an indicator type.
*/
let lastAppliedFilter = 'all';
function applyChangeListFilter(filterId) {
const isAll = filterId === 'all';
const revisions = document.querySelectorAll(
'[data-mw-revid]:not(.mw-userlink), [data-mw-logid]'
);
revisions.forEach(container => {
if (isAll) {
container.style.display = '';
return;
}
// Log entries (data-mw-logid) don't have revision-based attributes to
// match against filters — hide them when any specific filter is active.
const show = container.hasAttribute('data-mw-revid') && rowMatchesFilter(container, filterId);
container.style.display = show ? '' : 'none';
});
// Hide h4 date headers when all their rows are hidden
document.querySelectorAll('div.mw-changeslist h4').forEach(h4 => {
if (isAll) {
h4.style.display = '';
return;
}
const sibling = h4.nextElementSibling;
if (!sibling || sibling.tagName !== 'DIV') return;
const revItems = sibling.querySelectorAll('[data-mw-revid]');
if (revItems.length === 0) return;
h4.style.display = Array.from(revItems).every(el => el.style.display === 'none') ? 'none' : '';
});
const enhanced = document.querySelector('table.mw-enhanced-rc');
if (enhanced) {
// Hide the group header row in each tbody if all other rows are hidden
document.querySelectorAll('table.mw-enhanced-rc tbody').forEach(tbody => {
const rows = tbody.querySelectorAll('tr');
if (rows.length < 2) return;
rows[0].style.display = (!isAll && Array.from(rows).slice(1)
.every(tr => tr.style.display === 'none')) ? 'none' : '';
});
// Expand grouped sections while a specific filter is active, collapse on
// "all". Only on the transition, so switching between two filters leaves
// the expanded state alone.
const wasAll = lastAppliedFilter === 'all';
if (wasAll !== isAll) {
document.querySelectorAll('table.mw-enhanced-rc').forEach(table => {
const checkbox = groupToggleCheckbox(table);
if (checkbox && checkbox.checked === isAll) {
checkbox.checked = !isAll;
checkbox.dispatchEvent(new Event('change'));
}
});
}
}
showNoMatchingEdits(!isAll && Array.from(
document.querySelectorAll('[data-mw-revid]:not(.mw-userlink)')
).every(el => el.style.display === 'none'));
lastAppliedFilter = filterId;
}
/**
* Shows or removes the "No matching edits" message under the filter widget.
*
* @param {boolean} show
*/
function showNoMatchingEdits(show) {
const existing = document.getElementById('cw-no-matching-edits');
if (existing) existing.remove();
if (!show) return;
const msg = makeElement('p', {
text: noMatchingEditsLabel, attrs: { id: 'cw-no-matching-edits' },
style: { margin: '10px 0', fontStyle: 'italic' }
});
const widget = document.getElementById('cw-filter-widget');
if (widget && widget.nextSibling) {
widget.parentNode.insertBefore(msg, widget.nextSibling);
}
}
/**
* Returns the hidden checkbox that expands/collapses an enhanced-RC group.
*
* @param {Element} table - A table.mw-enhanced-rc element.
* @returns {HTMLInputElement|null}
*/
function groupToggleCheckbox(table) {
const arrow = table.querySelector('.mw-enhancedchanges-arrow');
const checkboxId = arrow && arrow.getAttribute('for');
return checkboxId ? document.getElementById(checkboxId) : null;
}
/**
* If any configuration pages failed to load, appends a warning glyph (with
* the details in its tooltip) to every filter-widget label on the page.
* Safe to call more than once.
*/
function renderLoadWarnings() {
const messages = SHOW_LINT_IN_WIDGET ? [...loadWarnings, ...listLint] : loadWarnings;
if (messages.length === 0) return;
document.querySelectorAll('.cw-filter-label').forEach(label => {
if (label.querySelector('.cw-load-warning')) return;
label.appendChild(makeElement('span', {
className: 'cw-load-warning', text: ' ' + loadWarningGlyph,
title: loadWarningPrefix + '\n' + messages.join('\n'), style: { cursor: 'help' }
}));
});
}
/**
* Whether the single document-level outside-click handler has been installed.
* One delegated listener replaces the former one-listener-per-gutter design.
*/
let outsideClickHandlerInstalled = false;
/**
* Installs a single document-level click handler that hides every open popup
* whose owning gutter does not contain the click target.
*/
function installOutsideClickHandler() {
if (outsideClickHandlerInstalled) return;
document.addEventListener('click', (ev) => {
document.querySelectorAll('.cw-popup').forEach(p => {
if (p.style.display === 'none' || p.style.display === '') return;
const owner = p.parentElement;
if (owner && !owner.contains(ev.target)) {
p.style.display = 'none';
}
});
});
outsideClickHandlerInstalled = true;
}
/**
* Installs hover, click, touch, and outside-click handlers on a gutter element
* to show/hide its popup.
*
* @param {HTMLElement} gutter - The gutter element containing a .cw-popup child.
*/
function installPopupHandlers(gutter) {
if (gutter._cwHandlersInstalled) return;
let hideTimer = null;
const positionPopup = () => {
const p = gutter.querySelector('.cw-popup');
if (!gutter._cwEnablePopup || !p) return;
p.style.display = 'block';
p.style.top = '1.4em';
p.style.left = '0px';
const rect = p.getBoundingClientRect();
if (rect.right > window.innerWidth - 10) {
const overflow = rect.right - (window.innerWidth - 10);
p.style.left = Math.max(0, (parseFloat(p.style.left) || 0) - overflow) + 'px';
}
};
const show = () => {
if (!gutter._cwEnablePopup) return;
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
positionPopup();
};
const scheduleHide = () => {
hideTimer = setTimeout(() => {
const p = gutter.querySelector('.cw-popup');
if (p) p.style.display = 'none';
}, 150);
};
gutter.addEventListener('mouseenter', show);
gutter.addEventListener('mouseleave', scheduleHide);
const pInit = gutter.querySelector('.cw-popup');
if (pInit) {
pInit.addEventListener('mouseenter', () => {
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
});
pInit.addEventListener('mouseleave', scheduleHide);
}
const toggle = (e) => {
if (!gutter._cwEnablePopup) return;
e.stopPropagation();
const p = gutter.querySelector('.cw-popup');
if (!p) return;
if (p.style.display === 'none' || p.style.display === '') {
show();
} else {
p.style.display = 'none';
}
};
gutter.addEventListener('click', toggle);
gutter.addEventListener('touchstart', toggle, {
passive: true
});
installOutsideClickHandler();
gutter._cwHandlersInstalled = true;
}
/**
* Watches for the change list being replaced by MediaWiki's live filters
* ("View new changes", filter edits on Special:Watchlist / RecentChanges)
* and annotates the new rows. The existing widget sits outside the replaced
* element and survives; its radios are refreshed after each pass.
*/
function installLiveReloadObserver() {
if (typeof MutationObserver === 'undefined') return;
let timer = null;
let running = false;
let rerun = false;
const run = async () => {
if (running) {
rerun = true;
return;
}
running = true;
try {
await annotateChangeList(document, false);
} catch (e) {
console.warn('Citation Watchlist: live reload pass failed:', e);
} finally {
running = false;
if (rerun) {
rerun = false;
run();
}
}
};
const observer = new MutationObserver(mutations => {
const relevant = mutations.some(m => Array.from(m.addedNodes).some(node =>
node.nodeType === Node.ELEMENT_NODE &&
(node.matches('[data-mw-revid], .mw-changeslist, .mw-changeslist-line') ||
node.querySelector('[data-mw-revid], .mw-changeslist-line'))
));
if (!relevant) return;
clearTimeout(timer);
timer = setTimeout(run, LIVE_RELOAD_DEBOUNCE_MS);
});
observer.observe(document.body, { childList: true, subtree: true });
}
const enabledNsStrings = new Set(enabledNamespaces.filter(n => n >= 0).map(String));
const namespaces = Object.entries(mw.config.get('wgFormattedNamespaces'))
.filter(([num, name]) => num !== '0' && !enabledNsStrings.has(num))
.map(([_, name]) => name.replace(/ /g, '_') + ':');
/**
* Pre-pads an element with empty emoji gutters to prevent layout shift.
*
* @param {Element} element - The DOM element to pre-pad.
*/
function prePadElement(element) {
try {
renderTwoColumnEmojis(element, null, false, [], []);
} catch (e) {
/* ignore */
}
}
/**
* ==========================================================================
* 3. Builders and Extractors
* ==========================================================================
*/
/**
* Constructs a URL for viewing a diff between two revisions.
*
* @param {string} title - The page title.
* @param {string} fromId - The older revision ID.
* @param {string} toId - The newer revision ID.
* @returns {string} The diff URL, or empty string on error.
*/
function buildDiffUrl(title, fromId, toId) {
try {
const script = mw.config.get('wgScript') || '/w/index.php';
return `${script}` +
`?title=${encodeURIComponent(title)}` +
`&diff=${encodeURIComponent(toId)}` +
`&oldid=${encodeURIComponent(fromId)}`;
} catch (e) {
return '';
}
}
/**
* Constructs a permalink URL for a specific revision.
*
* @param {string} revId - The revision ID.
* @returns {string} The permalink URL, or empty string on error.
*/
function buildPermalinkUrl(revId) {
try {
const articlePath = mw.config.get('wgArticlePath');
if (articlePath && articlePath.includes('$1')) {
return articlePath.replace('$1',
`Special:Permalink/${encodeURIComponent(revId)}`);
}
const script = mw.config.get('wgScript') || '/w/index.php';
return `${script}?oldid=${encodeURIComponent(revId)}`;
} catch (e) {
return '';
}
}
/**
* Strips all HTML comments from a string.
*
* @param {string} text - Input text potentially containing HTML comments.
* @returns {string} Text with all HTML comments removed.
*/
function stripHTMLComments(text) {
if (!text) return text;
return text.replace(/<!--[\s\S]*?-->/g, '');
}
/**
* Extracts all HTTP(S) URLs from a given wikitext string. The scheme is
* matched case-insensitively (MediaWiki links HTTPS:// just as it does
* https://), and protocol-relative links ("[//example.com ...]") are
* normalized to https://.
*
* @param {string} wikitext - Raw wikitext revision content.
* @returns {string[]} List of valid extracted URLs.
*/
function extractURLs(wikitext) {
const urls = [];
if (!wikitext) return urls;
const urlRegex = // eslint-disable-next-line max-len
/(?:https?:\/\/|(?<=^|[\s\[|=<>"'])\/\/)(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)/gi;
let match;
while ((match = urlRegex.exec(wikitext)) !== null) {
const raw = match[0].startsWith('//') ? 'https:' + match[0] : match[0];
try {
const url = new URL(raw);
if (/^https?:$/.test(url.protocol)) urls.push(url.href);
} catch (error) {
console.error(`Invalid URL rejected: ${match[0]}`, error.message);
}
}
return urls;
}
/**
* Web archive services whose URLs embed the original URL. Each entry's
* `extract` returns the embedded URL string or null.
*/
/** Restores "https:/example.com" (slash-collapsed by some archives) to "https://example.com". */
const fixScheme = s => s.replace(/^(https?:)\/+/i, '$1//');
const archiveUnwrappers = [
{
// https://web.archive.org/web/20200101000000/https://example.com/page
// (the embedded scheme is sometimes collapsed to "https:/")
test: h => h === 'web.archive.org' || h === 'wayback.archive.org',
extract: u => {
const m = u.pathname.match(/^\/web\/\d{1,14}[a-z_]*\/(https?:\/{1,2}.+)$/i);
return m ? fixScheme(m[1]) : null;
}
},
{
// https://archive.blah/2020.01.01-000000/https://example.com/page
test: h => /^archive\.(ph|today|is|li|vn|fo|md)$/i.test(h),
extract: u => {
const m = u.pathname.match(/^\/[^/]+\/(https?:\/{1,2}.+)$/i);
return m ? fixScheme(m[1]) : null;
}
},
{
// https://webcache.googleusercontent.com/search?q=cache:https://example.com/page
test: h => h === 'webcache.googleusercontent.com',
extract: u => {
const q = u.searchParams.get('q') || '';
const m = q.match(/^cache:(?:[A-Za-z0-9_-]+:)?(.+)$/);
if (!m) return null;
return /^https?:\/\//i.test(m[1]) ? m[1] : 'https://' + m[1];
}
}
];
/**
* Resolves a URL to the hostname that should be screened. If the URL is a
* web-archive capture of another page, the captured page's host is used.
*
* @param {string} url
* @returns {{hostname: string, archiveHost: string|null}|null} Null if unparsable.
*/
function screenedHost(url) {
let current;
try {
current = new URL(url);
} catch (e) {
return null;
}
let archiveHost = null;
for (let depth = 0; depth < 3 && UNWRAP_ARCHIVES; depth++) {
const unwrapper = archiveUnwrappers.find(a => a.test(current.hostname));
if (!unwrapper) break;
const inner = unwrapper.extract(current);
if (!inner) break;
try {
const innerUrl = new URL(inner);
if (!/^https?:$/.test(innerUrl.protocol)) break;
archiveHost = archiveHost || current.hostname;
current = innerUrl;
} catch (e) {
break;
}
}
return { hostname: current.hostname, archiveHost };
}
/**
* Resolves URLs to screened domains and list indicators.
*
* @param {string[]} urls
* @returns {Array<{url: string, domain: string, indicator: string|null, archiveHost: string|null}>}
*/
function resolveURLs(urls) {
const out = [];
for (const url of urls || []) {
const host = screenedHost(url);
if (!host) continue;
const domain = getRootDomain(host.hostname, publicSuffixSet);
if (!domain) continue;
out.push({ url, domain, indicator: highestIndicatorFor(domain), archiveHost: host.archiveHost });
}
return out;
}
/**
* Returns the highest-priority list indicator that contains a domain.
*
* @param {string} domain
* @returns {string|null}
*/
function highestIndicatorFor(domain) {
let chosen = null;
for (const type of listedTypes) {
if (indicators[type].list.has(domain) &&
(!chosen || indicators[type].priority > indicators[chosen].priority)) {
chosen = type;
}
}
return chosen;
}
/**
* Returns the highest-priority type among a list of indicator types.
*
* @param {Array<string|null>} types
* @returns {string|null}
*/
function highestPriority(types) {
let chosen = null;
for (const t of types) {
if (t && (!chosen || indicators[t].priority > indicators[chosen].priority)) chosen = t;
}
return chosen;
}
/**
* Extracts the top-level domain from a full hostname using a public suffix set.
*
* @param {string} hostname - Full hostname (e.g., sub.example.co.uk).
* @param {Set<string>} publicSuffixSet - Set of known public suffixes.
* @returns {string} The top-level domain (e.g., example.co.uk).
*/
function getRootDomain(hostname, publicSuffixSet) {
// Handle empty or invalid hostnames
if (!hostname || typeof hostname !== 'string') {
console.warn('Invalid hostname provided to getRootDomain:', hostname);
return '';
}
// Check if this is an IP address (simple check for IPv4)
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(hostname)) {
return hostname; // Return IP addresses as-is
}
const domainParts = hostname.split('.');
// Handle hostnames that are too short
if (domainParts.length < 2) {
return hostname; // Return as-is if it's a single-part hostname
}
// Try to find a matching public suffix
for (let i = 0; i < domainParts.length; i++) {
const candidate = domainParts.slice(i).join('.');
// Check both normal and exception (prefixed with !) entries
if (publicSuffixSet.has(candidate) || publicSuffixSet.has(`!${candidate}`)) {
// If we found a match, return the domain part plus the public suffix
// But make sure we don't go out of bounds
if (i > 0) {
return domainParts.slice(i - 1).join('.');
} else {
// Edge case: the entire hostname is a public suffix
return hostname;
}
}
}
// If no match in public suffix list, use a simple fallback:
// For hostnames with 2 parts, return the whole thing
// For hostnames with >2 parts, return the last 2 parts
if (domainParts.length === 2) {
return hostname;
} else {
return domainParts.slice(-2).join('.');
}
}
/**
* Extracts the first page object from MediaWiki API query response.
*
* @param {Object} data - MediaWiki API response.
* @returns {Object|null} The first page object or null if unavailable.
*/
function getFirstPage(data) {
if (!data || !data.query || !data.query.pages) return null;
return Object.values(data.query.pages)[0] || null;
}
/**
* Iterates every revision object in an API response, across all pages.
* (A revids= query spanning several articles returns several page objects.)
*
* @param {Object|null} data - MediaWiki API response.
* @returns {Object[]} Revision objects.
*/
function allRevisionsIn(data) {
if (!data || !data.query || !data.query.pages) return [];
const out = [];
for (const page of Object.values(data.query.pages)) {
if (page && Array.isArray(page.revisions)) out.push(...page.revisions);
}
return out;
}
/**
* Returns the main-slot wikitext of a revision object, or null if the
* revision is missing, hidden, or malformed.
*
* @param {Object} rev - Revision object from the API.
* @returns {string|null}
*/
function revisionText(rev) {
if (!rev || rev.texthidden !== undefined) return null;
const slot = rev.slots && rev.slots.main;
if (!slot || typeof slot['*'] !== 'string') return null;
return slot['*'];
}
/**
* Returns true if an API page object represents a page that cannot be
* read (missing, invalid, or without an accessible revision).
*
* @param {Object} page - Page object from the API.
* @returns {boolean}
*/
function isUnreadablePage(page) {
return !page || page.missing !== undefined || page.invalid !== undefined ||
!Array.isArray(page.revisions) || page.revisions.length === 0;
}
/**
* ==========================================================================
* 4. Validation
* ==========================================================================
*/
/**
* Validates that a string is a well-formed domain name.
*
* @param {string} str - The string to validate.
* @returns {boolean} True if the string is a valid domain.
*/
function isValidDomain(str) {
if (!str || str.length === 0 || str.length > 253) return false;
if (!/^[a-zA-Z0-9._-]+$/.test(str)) return false;
if (!str.includes('.')) return false;
if (/\.\./.test(str) || str.startsWith('.') || str.endsWith('.')) return false;
const labels = str.split('.');
if (labels.some(l => l.length === 0 || l.length > 63)) return false;
return true;
}
/**
* Validates that a string is a well-formed public suffix list entry.
* Allows wildcard entries (e.g., *.ck) and exception entries (e.g., !www.ck).
*
* @param {string} str - The string to validate.
* @returns {boolean} True if the string is a valid PSL entry.
*/
function isValidSuffixEntry(str) {
const cleaned = str.replace(/^[!*.]/, '');
return /^[a-zA-Z0-9._-]+$/.test(cleaned) && cleaned.length > 0 && cleaned.length <= 253;
}
/**
* Validates that a value is a string parseable as an absolute http(s) URL.
* Used to re-validate anything read back from localStorage before it is
* assigned to an href.
*
* @param {*} u - The value to validate.
* @returns {boolean} True if the value is an http(s) URL.
*/
function isValidHttpUrl(u) {
if (typeof u !== 'string') return false;
try {
return /^https?:$/i.test(new URL(u).protocol);
} catch (e) {
return false;
}
}
/**
* Validates that a value is a plausible revision ID (a positive integer
* string). Used before an ID is put into an API request or a cache key.
*
* @param {*} id - The value to validate.
* @returns {boolean}
*/
function isRevisionId(id) {
return typeof id === 'string' && /^[1-9][0-9]{0,15}$/.test(id);
}
/**
* Validates that a string is a plausible MediaWiki page title.
* Rejects titles with invalid characters, excessive length, or suspicious patterns.
*
* @param {string} title - The page title to validate.
* @returns {boolean} True if the title looks like a valid page link.
*/
function isValidPageTitle(title) {
if (typeof title !== 'string') return false;
if (title.length === 0 || title.length > 255) return false;
if (/[\[\]{}|#<>\n\r\t]/.test(title)) return false;
if (title.includes('..')) return false;
if (title.startsWith(':') || title.startsWith(' ') || title.endsWith(' ')) return false;
return true;
}
/**
* Determines whether a given page title does *not* belong to the main or draft namespaces.
* A missing title is treated as "not an article" so the row is skipped rather
* than crashing the run.
*
* @param {string} pageTitle - The title of the page.
* @returns {boolean} True if not an article namespace.
*/
function isNotArticle(pageTitle) {
if (typeof pageTitle !== 'string') return true;
return namespaces.some(namespace => pageTitle.startsWith(namespace));
}
/**
* Checks whether an element matches the specified filter.
*
* @param {Element} element - The DOM element to test.
* @param {string} filterId - The active filter ID.
* @returns {boolean} True if the element matches the filter.
*/
function elementMatchesFilter(element, filterId) {
if (filterId === 'linkchange') {
return element.hasAttribute('data-cw-linkchange') ||
listedTypes.some(type => element.hasAttribute(indicators[type].dataAttr));
}
if (indicators[filterId] && indicators[filterId].dataAttr) {
return element.hasAttribute(indicators[filterId].dataAttr);
}
return false;
}
/**
* ==========================================================================
* 5. Fetch
* ==========================================================================
*/
/**
* Fetches revisions by ID and returns them keyed by revision ID (as strings),
* across all pages in the response.
*
* @param {string[]} revIds
* @param {string[]} rvprop - Properties to request (always includes 'ids').
* @returns {Promise<Map<string, Object>|null>} Map of id -> revision object,
* or null if the request failed.
*/
async function fetchRevisionsById(revIds, rvprop) {
const data = await fetchRevisionData({
revids: revIds,
rvprop: unique(['ids', ...rvprop]),
rvslots: ['main']
});
if (!data) return null;
const out = new Map();
for (const rev of allRevisionsIn(data)) {
if (rev && rev.revid !== undefined) out.set(String(rev.revid), rev);
}
return out;
}
/**
* Fetches wikitext for a chunk of revision IDs in one request.
*
* @param {string[]} revIds - Revision IDs (at most CONTENT_CHUNK_SIZE).
* @returns {Promise<Map<string, string|null>|null>} Map of id -> wikitext
* (null for a revision that exists but is unreadable), or null if the
* request itself failed. IDs the API did not return are absent.
*/
async function fetchRevisionTexts(revIds) {
const revs = await fetchRevisionsById(revIds, ['content']);
if (!revs) return null;
return new Map(Array.from(revs, ([id, rev]) => [id, revisionText(rev)]));
}
/**
* Fetches parent IDs and sizes for a list of revision IDs, chunked at the
* API maximum and fetched concurrently.
*
* @param {string[]} revisionIds - Revision IDs.
* @returns {Promise<Object>} Map of id -> {parentid, size}; IDs that could
* not be resolved are absent.
*/
async function fetchRevisionMetadata(revisionIds) {
const result = {};
const chunks = [];
for (let i = 0; i < revisionIds.length; i += METADATA_CHUNK_SIZE) {
chunks.push(revisionIds.slice(i, i + METADATA_CHUNK_SIZE));
}
await runPool(chunks.map(chunk => async () => {
const revs = await fetchRevisionsById(chunk, ['size']);
for (const [id, rev] of revs || []) {
result[id] = {
parentid: rev.parentid || 0,
size: typeof rev.size === 'number' ? rev.size : undefined
};
}
}), CONTENT_CONCURRENCY);
return result;
}
/**
* Fetches a page's current wikitext (main slot) by title.
*
* @param {string} pageTitle
* @returns {Promise<string|null>} Null if the page is missing, unreadable, or the request failed.
*/
async function fetchPageWikitext(pageTitle) {
if (!isValidPageTitle(pageTitle)) return null;
const page = getFirstPage(await fetchRevisionData({
titles: [pageTitle],
rvprop: ['content'],
rvslots: ['main']
}));
return isUnreadablePage(page) ? null : revisionText(page.revisions[0]);
}
/**
* Fetches the ID of the first revision of a page.
*
* @param {string} pageTitle - The page title to look up.
* @returns {number|null} Revision ID or null.
*/
async function fetchFirstRevisionId(pageTitle) {
if (!isValidPageTitle(pageTitle)) return null;
const page = getFirstPage(await fetchRevisionData({
titles: [pageTitle],
rvlimit: 1,
rvdir: 'newer',
rvprop: ['ids'],
}));
if (isUnreadablePage(page)) return null;
return page.revisions[0].revid || null;
}
/**
* Fetches the list of subpages from the list of lists, parses wikilinks, caches
* the result, and returns list of subpage titles.
*
* @param {string} pageName - Title of the list-of-lists page.
* @returns {Promise<string[]>} List of subpage titles (empty on failure).
*/
async function fetchDomainListPages(pageName) {
const cacheKey = `citationWatchlistFetchDomainListPages_${pageName}`;
const cachedContent = readCache(cacheKey);
if (Array.isArray(cachedContent) && cachedContent.length > 0) {
console.log('Loaded list of lists from cache');
return cachedContent.filter(title => isValidPageTitle(title));
}
const content = await fetchPageWikitext(pageName);
if (content === null) {
console.error(`Citation Watchlist: list of lists "${pageName}" could not be loaded`);
return [];
}
const pageTitles = [];
for (const line of content.split('\n')) {
if (!line.startsWith('* [[')) continue;
const match = line.match(/\[\[([^\]]+)\]\]/); // first [[Page Title]]
if (!match) continue;
const title = match[1];
if (isValidPageTitle(title)) {
pageTitles.push(title);
} else {
console.warn(`Rejected invalid page title: "${title}"`);
}
}
if (pageTitles.length > 0) {
writeCache(cacheKey, pageTitles);
console.log('Loaded from API and stored in cache');
}
return pageTitles;
}
/**
* Parses one domain list page into per-indicator domain arrays.
* Entries that are not bare domains are collected as lint. A leading "www."
* is stripped (with a lint note), since root domains never carry it.
*
* @param {string} title - Page title, for lint messages.
* @param {string} content - Page wikitext.
* @returns {Object} {warning: string[], caution: string[], inspect: string[], lint: string[]}
*/
function parseDomainList(title, content) {
const parsed = { lint: [] };
for (const type of listedTypes) parsed[type] = [];
let current = null;
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (/^==/.test(trimmed)) {
// Any heading ends the current section; only recognized ones open a new one.
current = listedTypes.find(t => indicators[t].section === trimmed) || null;
continue;
}
if (!line.startsWith('*') || !current) continue;
let domain = line.substring(1).trim().toLowerCase();
if (!domain) continue;
if (/^www\./.test(domain) && isValidDomain(domain.slice(4))) {
parsed.lint.push(`${title}: "${domain}" has a www. prefix; matched as "${domain.slice(4)}"`);
domain = domain.slice(4);
}
if (isValidDomain(domain)) {
if (parsed[current].length < MAX_DOMAINS_PER_LIST) {
parsed[current].push(domain);
} else {
parsed.lint.push(`${title}: cap of ${MAX_DOMAINS_PER_LIST} reached in ${current}`);
}
} else {
parsed.lint.push(`${title}: "${domain}" is not a bare domain (paths, wildcards and ` +
`bare TLDs are not supported) and was ignored`);
}
}
return parsed;
}
/**
* Loads domain lists from a set of pages (parsed form cached per page) and
* populates the `Set` of each indicator. Pages that are missing or fail to
* load are skipped and recorded in loadWarnings; the remaining lists still load.
*
* @param {string[]} pageNames - List of page titles to fetch.
*/
async function fetchAndOrganizeDomainLists(pageNames) {
const parsedLists = [];
const pagesToFetch = [];
const isParsed = p => p && typeof p === 'object' && listedTypes.every(t => Array.isArray(p[t]));
for (const title of pageNames) {
const cached = readCache(`domainList:${location.hostname}:${title}`);
if (isParsed(cached)) {
parsedLists.push(cached);
} else {
pagesToFetch.push(title);
}
}
if (pagesToFetch.length > 0) {
const apiData = await fetchRevisionData({
titles: pagesToFetch,
rvprop: ['content'],
rvslots: ['main'],
});
const pages = (apiData && apiData.query && apiData.query.pages) || null;
if (!pages) {
loadWarnings.push(`API request for ${pagesToFetch.length} list page(s) failed`);
console.error('Citation Watchlist: domain list fetch failed for', pagesToFetch);
} else {
// The API normalizes titles (underscores -> spaces); cache under the
// requested spelling so the next load hits.
const requestedFor = {};
for (const t of pagesToFetch) requestedFor[t.replace(/_/g, ' ')] = t;
for (const page of Object.values(pages)) {
const title = page.title || '?';
if (isUnreadablePage(page)) {
loadWarnings.push(`List page "${title}" is missing or unreadable`);
console.warn(`Citation Watchlist: skipping list page "${title}"`);
continue;
}
const content = revisionText(page.revisions[0]);
if (content === null) {
loadWarnings.push(`List page "${title}" has no readable content`);
continue;
}
const parsed = parseDomainList(title, content);
parsedLists.push(parsed);
const cacheTitle = requestedFor[title] || title;
writeCache(`domainList:${location.hostname}:${cacheTitle}`, parsed);
}
}
}
for (const parsed of parsedLists) {
for (const type of listedTypes) {
for (const domain of parsed[type]) {
if (isValidDomain(domain)) indicators[type].list.add(domain);
}
}
if (Array.isArray(parsed.lint)) {
listLint.push(...parsed.lint.filter(l => typeof l === 'string'));
}
}
}
/**
* Fetches and caches the public suffix list used to identify top-level domains.
* The cache holds the validated entries, so cached loads skip parsing.
*
* @returns {Promise<Set<string>>} Set of public suffixes.
*/
async function fetchPublicSuffixList() {
const cacheKey = 'publicSuffixListCache';
const cached = readCache(cacheKey);
if (Array.isArray(cached) && cached.length >= MIN_PSL_ENTRIES) {
console.log('Using cached public suffix list');
return new Set(cached.filter(e => typeof e === 'string' && isValidSuffixEntry(e)));
}
const pslUrl = mw.config.get('wgArticlePath').replace('$1', publicSuffixList) +
'?action=raw';
console.log(`Raw page text request: ${pslUrl}`);
const content = await safeFetch(fetch, pslUrl).then(response => response ?
response.text() : null);
if (!content) return new Set();
const entries = [];
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith('//') && isValidSuffixEntry(trimmed)) {
entries.push(trimmed);
}
}
if (entries.length < MIN_PSL_ENTRIES) {
console.error(`Public suffix list suspiciously small (${entries.length} entries), rejecting`);
return new Set();
}
// Only cache a list that passed validation
writeCache(cacheKey, entries);
return new Set(entries);
}
/**
* Makes a MediaWiki API call to fetch revision metadata or content.
*
* @param {Object} data - Options for the API call, such as `revids`, `titles`, `rvprop`, etc.
* @returns {Promise<Object|null>} MediaWiki API query result, or null on failure.
*/
async function fetchRevisionData(data) {
const paramKeys = ['rvprop', 'revids', 'titles', 'rvslots'];
const params = {
action: 'query',
prop: 'revisions',
format: 'json',
rvdir: data.rvdir || 'older',
origin: '*'
};
if (data.rvlimit) {
params.rvlimit = data.rvlimit;
}
paramKeys.forEach(key => {
if (data[key]) {
params[key] = Array.isArray(data[key]) ? data[key].join('|') : data[key];
}
});
const api = new mw.Api();
return await safeFetch(api.get.bind(api), params);
}
/**
* Wraps any asynchronous fetch function with retry logic and error handling.
*
* @param {Function} fn - The function to execute (usually an API call).
* @param {...any} args - Arguments to pass to the fetch function.
* @returns {Promise<any|null>} Result of the fetch or null on failure.
*/
async function safeFetch(fn, ...args) {
const retries = 2;
const retryDelay = 1000;
const maxAttempts = retries + 1;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const result = await fn(...args);
if (result === null || result === undefined) {
throw new Error('Received null or undefined response');
}
if (typeof result.ok === 'boolean' && !result.ok) {
throw new Error(`HTTP error ${result.status}: ${result.statusText || 'Unknown error'}`);
}
return result;
} catch (error) {
if (attempt < maxAttempts) {
console.warn(
`Error during ${fn.name || 'fetch operation'} (attempt ${attempt}/${maxAttempts}):`,
error.message || error);
await new Promise(resolve => setTimeout(resolve, retryDelay));
} else {
console.error(
`All ${maxAttempts} attempts failed for ${fn.name || 'fetch operation'}:`,
error.message || error);
}
}
}
return null;
}
/**
* ==========================================================================
* 6. Cache
* ==========================================================================
*/
/**
* Returns every localStorage key with the given prefix. Snapshotting the keys
* first means callers can remove entries without the index shifting under them.
*
* @param {string} prefix - Key prefix to match.
* @returns {string[]}
*/
function cacheKeysWithPrefix(prefix) {
const keys = [];
try {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith(prefix)) keys.push(key);
}
} catch (e) {
/* storage unavailable */
}
return keys;
}
/**
* Removes every localStorage key with the given prefix.
*
* @param {string} prefix
*/
function removeKeysWithPrefix(prefix) {
for (const key of cacheKeysWithPrefix(prefix)) localStorage.removeItem(key);
}
/**
* Runs all cache maintenance: retired prefixes, version invalidation, and
* (at most once per CACHE_PURGE_INTERVAL) expiry and eviction.
*/
function purgeCaches() {
try {
purgeStaleVersionedCaches();
const lastKey = 'citationWatchlist_lastPurge';
const last = parseInt(localStorage.getItem(lastKey), 10);
if (isNaN(last) || Date.now() - last > CACHE_PURGE_INTERVAL) {
purgeExpiredCache();
localStorage.setItem(lastKey, Date.now().toString());
}
} catch (e) {
console.warn('Citation Watchlist: cache maintenance failed:', e);
}
}
/**
* Removes caches from retired prefixes and purges cached entries whose stored
* version is older than the current version. Each cache type tracks its own
* version independently via a localStorage key.
*/
function purgeStaleVersionedCaches() {
for (const prefix of retiredCachePrefixes) {
removeKeysWithPrefix(prefix);
localStorage.removeItem(`citationWatchlist_cacheVersion:${prefix}`);
}
for (const cache of cacheConfig) {
const versionKey = `citationWatchlist_cacheVersion:${cache.prefix}`;
const storedVersion = parseInt(localStorage.getItem(versionKey), 10);
const effectiveVersion = isNaN(storedVersion) ? 0 : storedVersion;
if (effectiveVersion < cache.version) {
removeKeysWithPrefix(cache.prefix);
try {
localStorage.setItem(versionKey, cache.version.toString());
} catch (e) {
console.warn('Cache version write error:', e);
}
console.log(`Cache invalidated for "${cache.prefix}": version ${effectiveVersion} → ${cache.version}`);
}
}
}
/**
* Removes expired localStorage cache entries and enforces per-cache entry
* caps by evicting the oldest entries.
*/
function purgeExpiredCache() {
const now = Date.now();
const stats = { checked: 0, expired: 0, evicted: 0, errors: 0 };
for (const cache of cacheConfig) {
// Snapshot keys first so removals do not shift the iteration index
const survivors = []; // { key, timestamp }
for (const key of cacheKeysWithPrefix(cache.prefix)) {
stats.checked++;
try {
const value = localStorage.getItem(key);
let parsed = null;
try {
parsed = value ? JSON.parse(value) : null;
} catch (jsonError) {
stats.errors++;
}
if (!parsed || typeof parsed.timestamp !== 'number' || now - parsed.timestamp > cache.ttl) {
localStorage.removeItem(key);
stats.expired++;
continue;
}
survivors.push({ key, timestamp: parsed.timestamp });
} catch (itemError) {
stats.errors++;
try {
localStorage.removeItem(key);
} catch (e) { /* ignore */ }
}
}
if (cache.maxEntries && survivors.length > cache.maxEntries) {
survivors.sort((a, b) => a.timestamp - b.timestamp);
const excess = survivors.length - cache.maxEntries;
for (let i = 0; i < excess; i++) {
localStorage.removeItem(survivors[i].key);
stats.evicted++;
}
}
}
if (stats.checked > 0) {
console.log(`Cache cleanup: checked ${stats.checked}, expired ${stats.expired}, ` +
`evicted ${stats.evicted}, errors ${stats.errors}`);
}
}
/**
* Looks up the cache configuration whose prefix matches a key.
*
* @param {string} key - A cache key or prefix.
* @returns {Object|undefined}
*/
function cacheConfigFor(key) {
return cacheConfig.find(c => key.startsWith(c.prefix));
}
/**
* Reads a JSON cache entry from localStorage, returning the stored content
* if it exists and has not expired (TTL taken from the key's cacheConfig
* entry). Callers must validate the shape of the returned content.
*
* @param {string} key - The localStorage key.
* @returns {*|null} The cached content, or null if missing/expired/invalid.
*/
function readCache(key) {
const config = cacheConfigFor(key);
let raw;
try {
raw = config ? localStorage.getItem(key) : null;
} catch (e) {
return null;
}
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed.timestamp === 'number' &&
(Date.now() - parsed.timestamp < config.ttl) && parsed.content !== undefined) {
return parsed.content;
}
} catch (e) {
console.warn('Cache read error:', e);
}
return null;
}
/**
* Writes a JSON cache entry to localStorage with an embedded timestamp.
* Failures (e.g. quota exceeded) are logged and otherwise ignored; the
* cache is an optimization, never a dependency.
*
* @param {string} key - The localStorage key.
* @param {*} content - The data to cache.
*/
function writeCache(key, content) {
try {
localStorage.setItem(key, JSON.stringify({ timestamp: Date.now(), content }));
} catch (e) {
console.warn('Cache write error:', e);
}
}
/**
* ==========================================================================
* 7. Entry Point
* ==========================================================================
*/
analyzeView().catch(e => {
console.error('Citation Watchlist: unrecoverable error:', e);
loadWarnings.push('Citation Watchlist stopped early due to an error (see console)');
finishChangeListPass();
}).then(() => {
console.log('Citation Watchlist script finished executing');
// On enhanced RC pages, hide the summary gutter on a group's header row
// while the group is expanded (each nested row then shows its own).
document.querySelectorAll('table.mw-enhanced-rc').forEach(table => {
const checkbox = groupToggleCheckbox(table);
if (!checkbox) return;
checkbox.addEventListener('change', () => {
const topRow = table.querySelector('tr');
const gutter = topRow && topRow.querySelector('.cw-gutter');
if (!gutter) return;
const host = gutter.parentElement;
gutter.style.display = checkbox.checked ? 'none' : 'inline-flex';
if (host) host.style.paddingLeft = checkbox.checked ? '' : 'calc(2em + 0.5em)';
});
});
});
})();
sikba0s311o9rl4es3t75tbkkz1cem1
781272
781271
2026-10-27T23:53:15Z
Harej
7994
Better handling of multiple links to the same domain + archive links
781272
javascript
text/javascript
/**
*
* Citation Watchlist
* https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist
*
*/
(function() {
'use strict';
/**
* ==========================================================================
* LOCALIZATION
* ==========================================================================
*/
/* Labeling tool in the user interface */
const toolName = 'Citation Watchlist';
const aboutLink = 'https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist';
/* Revision inspection popup */
const addedLabel = 'Added';
const removedLabel = 'Removed';
const viewDiffLabel = 'View Diff';
const viewRevisionLabel = 'View Revision';
const aboutLabel = 'About Citation Watchlist';
const archivedViaLabel = 'Archived copy hosted at';
const archiveOnlyLabel = 'Added (archive only)';
const archivedLabel = 'Archived';
const swappedLabel = 'Swapped to archive';
const showAllLinksLabel = 'Show all $1 links';
/* Filter widget */
const showEditsLabel = 'Show edits:';
const loadingLabel = 'Loading...';
const allLabel = 'All';
const linkChangesLabel = 'Link Changes';
const linkChangesTooltip = 'Edits that add or remove links in articles';
const noMatchingEditsLabel = 'No matching edits';
/* Load-failure indicator */
const loadWarningGlyph = '⚠';
const loadWarningPrefix = 'Some Citation Watchlist lists could not be loaded:';
/**
* ==========================================================================
* DOMAIN LIST CONFIGURATION
* ==========================================================================
*
* Citation Watchlist requires the following wiki pages to function:
*
* 1. Public Suffix List
* - A local copy of the public suffix list, used for domain parsing.
* - Copy the contents of:
* https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist/Public_Suffix_List
* to a page on your own wiki.
* - Update the `publicSuffixList` variable below to reflect your page title.
*
* 2. List of Lists
* - A page linking to one or more domain list pages.
* - Format as a bullet list: "* [[Page Title]]" (space after asterisk).
* - Reference formatting example:
* https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist/Lists
* - Update the `listOfLists` variable below accordingly.
*
* 3+. Domain List Pages
* - One or more pages listing suspicious or noteworthy domains.
* - Each page must contain section headers that match the `indicators` config
* below (e.g., "==Warn==", "==Caution==").
* - Under each section, list domains in the format: "* example.com"
* - Do not use link formatting—just plain text.
* - Entries with paths ("example.com/opinion"), no dot ("gov"), or a
* leading "www." are not supported and are reported in the console.
*/
const publicSuffixList = 'Wikipedia:Citation_Watchlist/Public_Suffix_List';
const listOfLists = 'Wikipedia:Citation_Watchlist/Lists';
const enabledNamespaces = [-1, 0, 118];
const enabledSectionHeaders = ['References'];
/**
* Detection options.
* - UNWRAP_ARCHIVES: screen the original URL embedded in a web archive link
* (web.archive.org, archive.today, Google cache) rather than the archive
* host. The popup still links to the cited archive URL.
* - SHOW_LINT_IN_WIDGET: also show domain-list lint (ignored entries) in the
* filter widget's warning tooltip. Lint always goes to the console.
*/
const UNWRAP_ARCHIVES = true;
const SHOW_LINT_IN_WIDGET = false;
/**
* ==========================================================================
* INDICATOR CONFIGURATION
* ==========================================================================
*
* Defines metadata for domain indicators used in the watchlist UI.
* Each indicator is associated with a level of urgency and a unique symbol.
*
* Fields:
* - msg: Display label for the level (e.g., "Warning", "Caution").
* - emoji: Unicode character for the visual indicator (escaped as `\uXXXX`).
* - section: Must exactly match the section headers in the domain list pages.
* - priority: Higher values override lower ones for conflicting domain matches.
* Priority scale: 1 (lowest) to N (highest).
* - list: Defined as "new Set()" for all indicator types.
*
* If a domain appears in multiple lists, the one with the highest priority
* takes precedence.
*/
const indicators = {
warning: {
msg: 'Warning',
emoji: '❗',
section: '==Warn==',
explanation: 'This domain is classified as an unreliable source',
priority: 3,
dataAttr: 'data-cw-linkwarn',
list: new Set()
},
caution: {
msg: 'Caution',
emoji: '✋',
section: '==Caution==',
explanation: 'This source may be unreliable depending on context',
priority: 2,
dataAttr: 'data-cw-linkcaution',
list: new Set()
},
inspect: {
msg: 'Inspect',
emoji: '🔎',
section: '==Inspect==',
explanation: 'Take a closer look at this particular source',
priority: 1,
dataAttr: 'data-cw-linkinspect',
list: new Set()
},
added: {
msg: addedLabel,
emoji: '\u{1F517}',
section: null,
explanation: 'All added links',
priority: 0,
list: new Set()
},
removed: {
msg: removedLabel,
emoji: '\u{1F5D1}',
section: null,
explanation: 'All removed links',
priority: -1,
list: new Set()
}
};
/** Indicator types that come from the domain lists (priority > 0). */
const listedTypes = Object.keys(indicators).filter(t => indicators[t].priority > 0);
/**
* Kinds of link change between two revisions, in popup display order.
* `counts` says whether the kind counts as a source being added or removed
* (and so drives the gutter icon and list indicators); archive supplements
* and swaps are shown but not counted, since the source was already cited.
*/
const changeKinds = {
added: {
msg: addedLabel, emoji: indicators.added.emoji,
explanation: 'Links to sources not previously cited', counts: 'added'
},
archiveOnly: {
msg: archiveOnlyLabel, emoji: '\u{1F4E6}',
explanation: 'An archived copy was cited without the original link', counts: 'added'
},
archived: {
msg: archivedLabel, emoji: '\u{1F4E6}',
explanation: 'An archive link was added alongside an existing citation', counts: null
},
swapped: {
msg: swappedLabel, emoji: '\u{1F501}',
explanation: 'An existing link was replaced by its archived copy', counts: null
},
removed: {
msg: removedLabel, emoji: indicators.removed.emoji,
explanation: indicators.removed.explanation, counts: 'removed'
}
};
/** Popups with more links than this start collapsed. */
const POPUP_COLLAPSE_AFTER = 8;
/**
* Citation Watchlist
*
* Highlights potentially questionable citations added in Wikipedia revisions
* based on community-built lists of domain names
*
*
* Documentation: https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist
*
* Author: James Hare
* License: GNU General Public License v3.0 (GPL-3.0)
*
* @version 2.5
* @since 2026-10-28
*/
/**
* IF YOU WANT TO MODIFY any part of the script below this point, please submit
* your edits to https://test.wikipedia.org/wiki/User:Harej/citation-watchlist.js
* so that your modifications can be tested.
*/
let publicSuffixSet = new Set();
const MAX_DOMAINS_PER_LIST = 50000;
const MIN_PSL_ENTRIES = 1000;
/**
* Revisions larger than this are not scanned. MediaWiki's own page-size
* limit is 2 MiB, so this only trips on pathological cases; it bounds the
* download and regex work done per row.
*/
const MAX_REVISION_CHARS = 2 * 1024 * 1024;
/**
* Revision fetching strategy. Rows are never fetched one at a time: revision
* IDs are grouped into small chunks (so the first chunk returns quickly and
* its rows render while the rest are still in flight), several chunks are
* kept in flight at once, and rows are annotated the moment their chunk
* arrives. Concurrency is capped because origin=* requests are anonymous and
* therefore per-IP rate limited (see release notes for 1.5/1.6).
*/
const CONTENT_CHUNK_SIZE = 8; // max revisions per content request
const CONTENT_CHUNK_BYTES = 400 * 1024; // soft byte budget per chunk when sizes are known
const CONTENT_CONCURRENCY = 4; // simultaneous content requests
const METADATA_CHUNK_SIZE = 50; // revids per ids|size request (API max for users)
const ASSUMED_REVISION_BYTES = 40 * 1024; // chunking estimate when size is unknown
const LIVE_RELOAD_DEBOUNCE_MS = 250; // settle time after the change list is replaced
/**
* Non-fatal problems encountered while loading configuration (missing list
* pages, API failures). Surfaced in the filter widget so list maintainers
* notice quickly, instead of the tool silently running with partial data.
*/
const loadWarnings = [];
/**
* Domain-list lint: entries that were ignored because they are not bare
* domains. Always logged; shown in the widget only if SHOW_LINT_IN_WIDGET.
*/
const listLint = [];
/**
* Unified cache configuration. Each entry defines a cache key prefix, its TTL,
* version number, an optional cap on the number of entries kept, and a
* human-readable description.
*
* - revURLs: per-revision list of extracted URLs. Revisions are immutable,
* so the TTL and cap exist only to bound storage.
* - sectionURLs: per-revision, per-section URL map from rendered article HTML.
* - domainList: parsed domain lists ({warning: [...], caution: [...], ...}).
* - publicSuffixListCache: validated PSL entries as an array.
* - citationWatchlistFetchDomainListPages_: list-of-lists page titles.
*/
const cacheConfig = [
{
prefix: 'revURLs:',
ttl: 30 * 24 * 60 * 60 * 1000,
version: 1,
maxEntries: 4000,
description: 'Revision URL cache'
},
{
prefix: 'sectionURLs:',
ttl: 30 * 24 * 60 * 60 * 1000,
version: 1,
maxEntries: 500,
description: 'Article section URL cache'
},
{
prefix: 'domainList:',
ttl: 6 * 60 * 60 * 1000,
version: 1,
description: 'Domain list cache'
},
{
prefix: 'publicSuffixListCache',
ttl: 24 * 60 * 60 * 1000,
version: 1,
description: 'Public suffix list cache'
},
{
prefix: 'citationWatchlistFetchDomainListPages_',
ttl: 4 * 60 * 60 * 1000,
version: 0,
description: 'Domain list pages cache'
}
];
/** Cache prefixes from earlier versions, removed on startup. */
const retiredCachePrefixes = ['revisionDiff:'];
/** How often (ms) the full localStorage expiry/eviction pass runs. */
const CACHE_PURGE_INTERVAL = 60 * 60 * 1000;
/**
* ==========================================================================
* 1. Primary Workflow
* ==========================================================================
*/
/** Resolves once the PSL and domain lists are loaded (true) or failed (false). */
let configReady = null;
/** Rows already handed to processRevisions(), so live reloads only pick up new ones. */
const seenRows = new WeakSet();
/**
* Loads the public suffix list and the domain lists, in parallel. Never
* throws; failures are recorded in loadWarnings.
*
* @returns {Promise<boolean>} True if the tool can screen domains.
*/
async function loadConfiguration() {
try {
const [psl, listPages] = await Promise.all([
fetchPublicSuffixList(),
fetchDomainListPages(listOfLists)
]);
publicSuffixSet = psl;
if (publicSuffixSet.size === 0) {
console.error('Public Suffix List loading failed');
loadWarnings.push('Public Suffix List could not be loaded');
return false;
}
console.log('Welcome to Citation Watchlist');
if (listPages && listPages.length > 0) {
await fetchAndOrganizeDomainLists(listPages);
} else {
loadWarnings.push(`Could not load list of lists (${listOfLists})`);
}
if (listLint.length > 0) {
console.warn(`Citation Watchlist: ${listLint.length} domain-list entries were ignored:\n` +
listLint.join('\n'));
}
return true;
} catch (e) {
console.error('Citation Watchlist: configuration load failed:', e);
loadWarnings.push('Configuration could not be loaded (see console)');
return false;
}
}
/**
* Function called by the entry point for Citation Watchlist.
* Determines if the current page should be analyzed, starts loading the
* configuration, collects the revisions behind each change-list row, and
* fetches and annotates them while the configuration loads.
*/
async function analyzeView() {
purgeCaches();
const ns = mw.config.get('wgNamespaceNumber');
const inspectEnabled = enabledNamespaces.includes(ns);
// Start the configuration load immediately; nothing below waits on it
// until the first row is ready to render.
if (inspectEnabled) {
configReady = loadConfiguration();
}
// Article surface: scan enabled sections for watchlisted domains
const action = mw.config.get('wgAction');
if (action === 'view' && ns >= 0 && inspectEnabled) {
if (await configReady) {
await analyzeArticleSections();
}
return;
}
// Pre-pad change log lines immediately on Special:RecentChanges for visual stability
if (ns === -1) {
document.querySelectorAll('li.mw-changeslist-line, .mw-changeslist-line')
.forEach(line => prePadElement(line));
flushGutterPadding();
}
if (!inspectEnabled) {
return;
}
createChangeListFilterWidget();
await annotateChangeList(document, true);
installLiveReloadObserver();
}
/**
* Collects and annotates every unseen change-list row under `root`.
*
* @param {Document|Element} root - Where to look for rows.
* @param {boolean} allowFirstRevisionFallback - On a page history with no
* diff links (single-revision page), analyze the page's first revision.
*/
async function annotateChangeList(root, allowFirstRevisionFallback) {
const revisions = [];
let noLinks = true;
// Prefer iterating per-change rows when available (JS-enhanced watchlist/RC);
// fall back to link containers on non-enhanced layouts.
const unseen = selector => Array.from(root.querySelectorAll(selector)).filter(el => !seenRows.has(el));
let entries = unseen('[data-mw-revid]:not(.mw-userlink), .mw-changeslist-line');
let prePadCallback = null;
if (entries.length > 0) {
entries.forEach(line => prePadElement(line));
flushGutterPadding();
} else {
entries = unseen('.mw-changeslist-links');
prePadCallback = (_el, link) => {
const target = link.parentNode.parentNode;
prePadElement(target);
flushGutterPadding();
return target;
};
}
for (const entry of entries) {
seenRows.add(entry);
const result = await extractEntry(entry, entry, prePadCallback);
if (result.hasLink) noLinks = false;
if (!result.skip && result.revision) revisions.push(result.revision);
}
// If no links were found, extract the first revision ID
if (allowFirstRevisionFallback && noLinks && entries.length > 0) {
try {
const firstID = await fetchFirstRevisionId(mw.config.get('wgTitle'));
if (firstID) {
revisions.push({
oldrevision: String(firstID),
element: entries[0],
viewHref: buildPermalinkUrl(firstID),
viewLabel: viewRevisionLabel
});
}
} catch (e) {
console.warn('Citation Watchlist: single-revision fallback failed:', e);
}
}
await processRevisions(revisions);
finishChangeListPass();
}
/**
* Extracts the revision behind one change-list entry, isolating failures so
* that a single malformed row does not abort the remaining rows.
*
* @param {Element} container - The DOM element to query for revision links.
* @param {Element} element - The DOM element to attach to the revision object.
* @param {Function|null} prePadCallback - See extractRevisionFromLinks.
* @returns {Promise<{revision: Object|null, hasLink: boolean, skip: boolean}>}
*/
async function extractEntry(container, element, prePadCallback) {
try {
return await extractRevisionFromLinks(container, element, prePadCallback);
} catch (e) {
console.warn('Citation Watchlist: skipping entry due to error:', e);
// Report hasLink=true so a row-level failure never triggers the
// single-revision fallback for the whole page.
return { revision: null, hasLink: true, skip: true };
}
}
/**
* Annotates a set of revisions with as little latency as possible:
*
* 1. Parent IDs (for diff=prev and history rows) and sizes are resolved in
* one metadata request, only when any row needs them.
* 2. Per-revision URL lists already in the cache are used directly.
* 3. Remaining revision IDs are deduplicated (history rows share revisions),
* chunked in DOM order, fetched with bounded concurrency, and each row is
* rendered as soon as every revision it depends on is available. Fetching
* overlaps the configuration load; only rendering waits for it.
*
* @param {Object[]} revisions - Revision objects from extractRevisionFromLinks.
*/
async function processRevisions(revisions) {
if (revisions.length === 0) return;
// --- 1. Resolve parents / sizes ---------------------------------------
const parentLookups = revisions.filter(r => r.needsParentOf).map(r => r.needsParentOf);
let metadata = {};
if (parentLookups.length > 0) {
metadata = await fetchRevisionMetadata(unique(parentLookups));
for (const revision of revisions) {
if (!revision.needsParentOf) continue;
const currentId = revision.needsParentOf;
const meta = metadata[currentId];
delete revision.needsParentOf;
if (meta && meta.parentid) {
revision.oldrevision = String(meta.parentid);
revision.newrevision = currentId;
if (revision.pageTitle && !revision.viewHref) {
revision.viewHref = buildDiffUrl(revision.pageTitle,
revision.oldrevision, revision.newrevision);
}
} else if (meta) {
// First revision of the page: everything in it was "added"
revision.oldrevision = currentId;
revision.newrevision = null;
revision.viewHref = buildPermalinkUrl(currentId);
revision.viewLabel = viewRevisionLabel;
} else {
markSkipped(revision, 'fetch-failed');
revision.oldrevision = null;
}
}
}
// --- 2. Per-revision URL lists from cache; queue the rest --------------
const urlLists = new Map(); // id -> string[] | null (null = unreadable)
const pending = []; // { revision, needs: string[] }
for (const revision of revisions) {
if (revision.skipped) continue;
if (!isRevisionId(revision.oldrevision) ||
(revision.newrevision && !isRevisionId(revision.newrevision))) {
markSkipped(revision, 'invalid');
continue;
}
const needs = [revision.oldrevision];
if (revision.newrevision) needs.push(revision.newrevision);
for (const id of needs) {
if (urlLists.has(id)) continue;
const cached = readCache(`revURLs:${location.hostname}:${id}`);
if (Array.isArray(cached)) urlLists.set(id, cached.filter(isValidHttpUrl));
}
pending.push({ revision, needs });
}
if (pending.length === 0) return;
// --- 3. Fetch missing content in chunks; render as data arrives --------
const neededIds = unique(pending.flatMap(p => p.needs)).filter(id => !urlLists.has(id));
const oversized = new Set();
for (const id of neededIds) {
const meta = metadata[id];
if (meta && typeof meta.size === 'number' && meta.size > MAX_REVISION_CHARS) {
oversized.add(id);
urlLists.set(id, null);
}
}
const chunks = chunkRevisionIds(neededIds.filter(id => !oversized.has(id)), metadata);
let canRender = false;
const settle = () => {
if (!canRender) return;
for (let i = pending.length - 1; i >= 0; i--) {
const item = pending[i];
if (!item.needs.every(id => urlLists.has(id))) continue;
pending.splice(i, 1);
if (item.needs.some(id => oversized.has(id))) {
markSkipped(item.revision, 'oversized');
continue;
}
const oldList = urlLists.get(item.revision.oldrevision);
const newList = item.revision.newrevision ? urlLists.get(item.revision.newrevision) : null;
if (oldList === null || (item.revision.newrevision && newList === null)) {
markSkipped(item.revision, 'fetch-failed');
continue;
}
renderRevision(item.revision,
classifyLinkChanges(oldList, newList, !!item.revision.newrevision));
}
flushGutterPadding();
};
const onText = (id, text) => {
if (text === null) {
urlLists.set(id, null);
return;
}
if (text.length > MAX_REVISION_CHARS) {
oversized.add(id);
urlLists.set(id, null);
return;
}
const urls = extractURLs(stripHTMLComments(text));
urlLists.set(id, urls);
writeCache(`revURLs:${location.hostname}:${id}`, urls);
};
// Dispatch content fetches now; they run while the lists are still loading.
const fetching = runPool(chunks.map(chunk => async () => {
await fetchChunkIsolating(chunk, onText);
settle();
}), CONTENT_CONCURRENCY);
if (!(await configReady)) {
await fetching;
return; // no lists: nothing to screen against
}
canRender = true;
settle(); // cache hits and anything that landed while the lists loaded
await fetching;
// Anything still pending had an id the API never returned
for (const item of pending) {
markSkipped(item.revision, 'fetch-failed');
}
}
/**
* Fetches a chunk of revisions; if the request fails outright, splits the
* chunk in half and retries each half, so one problematic revision only
* costs itself rather than its neighbours. Each fetch already carries
* safeFetch's retries, so this only runs in genuine failure cases.
*
* @param {string[]} chunk - Revision IDs.
* @param {Function} onText - Called with (id, text|null) for every id.
*/
async function fetchChunkIsolating(chunk, onText) {
const result = await fetchRevisionTexts(chunk);
if (result) {
for (const id of chunk) {
onText(id, result.has(id) ? result.get(id) : null);
}
return;
}
if (chunk.length === 1) {
onText(chunk[0], null);
return;
}
const mid = Math.ceil(chunk.length / 2);
await fetchChunkIsolating(chunk.slice(0, mid), onText);
await fetchChunkIsolating(chunk.slice(mid), onText);
}
/**
* Groups revision IDs into fetch chunks in order, bounded by count and, when
* sizes are known, by an approximate byte budget so one huge article does not
* delay its neighbours.
*
* @param {string[]} ids - Revision IDs in the order they should be fetched.
* @param {Object} metadata - Map of id -> {parentid, size} (may be sparse).
* @returns {string[][]}
*/
function chunkRevisionIds(ids, metadata) {
const chunks = [];
let current = [];
let currentBytes = 0;
for (const id of ids) {
const meta = metadata[id];
const bytes = (meta && typeof meta.size === 'number') ? meta.size : ASSUMED_REVISION_BYTES;
if (current.length > 0 &&
(current.length >= CONTENT_CHUNK_SIZE || currentBytes + bytes > CONTENT_CHUNK_BYTES)) {
chunks.push(current);
current = [];
currentBytes = 0;
}
current.push(id);
currentBytes += bytes;
}
if (current.length > 0) chunks.push(current);
return chunks;
}
/**
* Runs async task factories with at most `concurrency` in flight, in order.
*
* @param {Array<() => Promise<*>>} tasks
* @param {number} concurrency
* @returns {Promise<void>}
*/
async function runPool(tasks, concurrency) {
let next = 0;
const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, async () => {
while (next < tasks.length) {
const task = tasks[next++];
try {
await task();
} catch (e) {
console.warn('Citation Watchlist: chunk failed:', e);
}
}
});
await Promise.all(workers);
}
/**
* Returns the elements of an array with duplicates removed, first occurrence wins.
*
* @param {Array} arr
* @returns {Array}
*/
function unique(arr) {
return Array.from(new Set(arr));
}
/**
* Marks a revision's row as skipped with a reason, without annotating it.
*
* @param {Object} revision
* @param {string} reason - 'fetch-failed', 'oversized' or 'invalid'.
*/
function markSkipped(revision, reason) {
revision.skipped = reason;
try {
revisionTargetElement(revision).setAttribute('data-cw-skipped', reason);
} catch (e) {
/* ignore */
}
console.warn(`Citation Watchlist: skipped ${revision.oldrevision || '?'}` +
`${revision.newrevision ? ' -> ' + revision.newrevision : ''} (${reason})`);
}
/**
* Finds the row-level element that carries data attributes for a revision.
*
* @param {Object} revision
* @returns {Element}
*/
function revisionTargetElement(revision) {
const el = revision.element;
return el.closest('[data-mw-revid]:not(.mw-userlink)') ||
el.closest('li') || el.closest('.mw-changeslist-line, tr') || el;
}
/**
* Scans the article page for sections whose H2 headers match enabledSectionHeaders,
* then analyzes each matching section for watchlisted domains.
*/
async function analyzeArticleSections() {
const headings = document.querySelectorAll('.mw-heading2 h2, h2');
for (const h2 of headings) {
const headerText = h2.textContent.trim();
if (!enabledSectionHeaders.includes(headerText)) continue;
// Collect all content between this heading and the next heading
const headingContainer = h2.closest('.mw-heading') || h2;
let sibling = headingContainer.nextElementSibling;
const sectionElements = [];
while (sibling && !sibling.classList.contains('mw-heading')) {
sectionElements.push(sibling);
sibling = sibling.nextElementSibling;
}
try {
analyzeSection(headerText, sectionElements);
createArticleFilterWidget(headingContainer);
} catch (e) {
console.warn(`Citation Watchlist: failed to analyze section "${headerText}":`, e);
}
}
}
/**
* Analyzes a single article section: extracts URLs from each <li>, screens them
* against domain lists, and prepends indicator icons to matching list items.
*
* @param {string} sectionName - The section header text (e.g. "References").
* @param {Element[]} sectionElements - DOM elements between this heading and the next.
*/
function analyzeSection(sectionName, sectionElements) {
const revisionId = mw.config.get('wgRevisionId');
const cacheKey = `sectionURLs:${location.hostname}:${revisionId}:${sectionName}`;
// Map: array of { liId, urls }. Anything read back from localStorage is
// re-validated before it can reach an href.
let liURLMap = null;
const cachedMap = readCache(cacheKey);
if (Array.isArray(cachedMap)) {
liURLMap = cachedMap
.filter(entry => entry && typeof entry.liId === 'string' && Array.isArray(entry.urls))
.map(entry => ({ liId: entry.liId, urls: entry.urls.filter(isValidHttpUrl) }));
}
const citeNotes = sectionElements.flatMap(el => Array.from(el.querySelectorAll('li[id^="cite_note"]')));
// Build from DOM if not cached
if (!liURLMap) {
liURLMap = citeNotes.map(li => ({
liId: li.id,
urls: Array.from(li.querySelectorAll('a.external[href]'))
.map(a => a.getAttribute('href'))
.filter(href => /^https?:\/\//i.test(href))
}));
writeCache(cacheKey, liURLMap);
}
// Add padding to all list items so citation text is flush with icons
for (const li of citeNotes) li.style.paddingLeft = '1.5em';
// Screen each li's URLs against domain lists and prepend icons with popups
for (const entry of liURLMap) {
const li = document.getElementById(entry.liId);
if (!li) continue;
const matched = resolveURLs(entry.urls).filter(r => r.indicator);
if (matched.length === 0) continue;
const highest = highestPriority(matched.map(m => m.indicator));
const wrapper = makeElement('span', {
style: { position: 'relative', cursor: 'pointer', marginLeft: '-1.5em',
display: 'inline-block', width: '1.5em' }
});
wrapper.appendChild(makeElement('span', { text: indicators[highest].emoji }));
attachPopup(wrapper, matched.map(m => popupLine(indicators[m.indicator], [m], false)), {});
li.prepend(wrapper);
li.dataset.cwIndicator = highest;
}
}
/**
* Extracts revision information from diff/hist/prev/cur links within a container.
* Parent IDs are not fetched here; rows that need one set `needsParentOf` and
* processRevisions() resolves them in a single batched request.
*
* @param {Element} container - The DOM element to query for revision links.
* @param {Element} element - The DOM element to attach to the revision object.
* @param {Function|null} prePadCallback - Optional callback invoked with (element, link) before
* processing each branch. In fallback mode this derives the target element and pre-pads it.
* @returns {Promise<{revision: Object|null, hasLink: boolean, skip: boolean}>}
* - revision: the extracted revision object, or null if no usable link was found.
* - hasLink: true if any recognized link was present (even if skipped).
* - skip: true if the entry should be skipped (non-article after pre-padding).
*/
async function extractRevisionFromLinks(container, element, prePadCallback) {
const kinds = [
{ selector: 'a.mw-changeslist-diff', kind: 'diff' },
{ selector: 'a.mw-changeslist-history', kind: 'hist' },
{ selector: 'a.mw-history-histlinks-previous', kind: 'prev' },
{ selector: 'a.mw-history-histlinks-current', kind: 'cur' }
];
let link = null;
let kind = null;
for (const k of kinds) {
link = container.querySelector(k.selector);
if (link) {
kind = k.kind;
break;
}
}
if (!link) {
return { revision: null, hasLink: false, skip: false };
}
const params = new URL(link.href).searchParams;
const pageTitle = params.get('title');
const target = prePadCallback ? prePadCallback(element, link) : element;
// The diff and hist links appear on watchlist/RC/contribs rows that can be
// for any namespace; history prev/cur links are always for the page itself.
if ((kind === 'diff' || kind === 'hist') && isNotArticle(pageTitle)) {
return { revision: null, hasLink: true, skip: true };
}
const revision = {
oldrevision: null,
newrevision: null,
element: target,
viewHref: '',
viewLabel: viewDiffLabel,
pageTitle: pageTitle || undefined
};
switch (kind) {
case 'diff': {
// MediaWiki convention: diff = newer, oldid = older
const diffParam = params.get('diff');
const oldidParam = params.get('oldid');
revision.viewHref = link.href;
if (diffParam === 'prev' && oldidParam) {
// Contributions: oldid is the current revision; parent resolved later
revision.needsParentOf = oldidParam;
} else {
revision.oldrevision = oldidParam;
revision.newrevision = diffParam;
}
break;
}
case 'hist': {
// New page: analyze its first revision
const firstID = await fetchFirstRevisionId(pageTitle);
if (!firstID) {
return { revision: null, hasLink: true, skip: false };
}
revision.oldrevision = String(firstID);
revision.viewHref = buildPermalinkUrl(firstID);
revision.viewLabel = viewRevisionLabel;
break;
}
case 'prev': {
// viewHref is filled in once the parent is known
revision.needsParentOf = params.get('oldid') || null;
break;
}
case 'cur': {
revision.oldrevision = params.get('oldid');
revision.viewHref = buildPermalinkUrl(revision.oldrevision);
revision.viewLabel = viewRevisionLabel;
break;
}
}
return { revision, hasLink: true, skip: false };
}
/**
* Classifies the link changes between two revisions. Links are grouped by
* the page they ultimately point at (archive URLs are unwrapped), so an
* archive link added next to an existing citation is reported as
* "archived" rather than as a new source. Multiset semantics: a URL that
* appears twice in the new revision and once in the old counts as one
* addition.
*
* @param {string[]} fromList - URLs in the older revision.
* @param {string[]|null} toList - URLs in the newer revision (ignored if !hasNew).
* @param {boolean} hasNew - False for a page's first revision, in which case
* every URL in fromList is considered added.
* @returns {Object[]} Resolved links, each with a `kind` from changeKinds.
*/
function classifyLinkChanges(fromList, toList, hasNew) {
const tally = (urls) => {
const byTarget = new Map(); // target -> { direct: [], archive: [] }
for (const r of resolveURLs(urls)) {
if (!byTarget.has(r.target)) byTarget.set(r.target, { direct: [], archive: [] });
byTarget.get(r.target)[r.archiveHost ? 'archive' : 'direct'].push(r);
}
return byTarget;
};
const oldMap = hasNew ? tally(fromList) : new Map();
const newMap = tally(hasNew ? (toList || []) : fromList);
const changes = [];
const push = (kind, entries, n) => {
for (let i = 0; i < n && i < entries.length; i++) changes.push({ ...entries[i], kind });
};
for (const target of new Set([...oldMap.keys(), ...newMap.keys()])) {
const o = oldMap.get(target) || { direct: [], archive: [] };
const n = newMap.get(target) || { direct: [], archive: [] };
const oD = o.direct.length, oA = o.archive.length;
const nD = n.direct.length, nA = n.archive.length;
if (nD > oD) push('added', n.direct, nD - oD);
if (nA > oA) {
let kind = 'archiveOnly'; // archive cited, original never present
if (nD > 0) kind = 'archived'; // original still cited: supplement
else if (oD > 0) kind = 'swapped'; // original gone, archive took its place
push(kind, n.archive, nA - oA);
}
const swapped = oD > 0 && nD === 0 && nA > oA;
if (oD > nD && !swapped) push('removed', o.direct, oD - nD);
// An archive-only citation that disappeared entirely is a removal;
// dropping a supplementary archive while the original stays is not.
if (oA > nA && oD === 0 && nD === 0 && nA === 0) push('removed', o.archive, oA - nA);
}
return changes;
}
/**
* Screens a revision's link changes against the domain lists and annotates
* its row (gutter icons, popup, filter data attributes).
* Synchronous: all network work happens in processRevisions().
*
* @param {Object} revision - Object containing oldrevision, optional
* newrevision, and DOM element.
* @param {Object[]} changes - From classifyLinkChanges().
*/
function renderRevision(revision, changes) {
const targetElement = revisionTargetElement(revision);
targetElement.setAttribute('data-cw-processed', 'true');
const counted = changes.filter(c => changeKinds[c.kind].counts);
const added = counted.filter(c => changeKinds[c.kind].counts === 'added');
const removed = counted.filter(c => changeKinds[c.kind].counts === 'removed');
// Highest-priority indicator among added links (lists only), for the gutter
const addType = highestPriority(added.map(a => a.indicator)) ||
(added.length > 0 ? 'added' : null);
try {
if (revision.viewHref) revision.element.dataset.cwViewHref = revision.viewHref;
if (revision.viewLabel) revision.element.dataset.cwViewLabel = revision.viewLabel;
} catch (e) {
/* ignore */
}
// Render two-column emojis: left = addition (highest-priority), right = removal (bin)
renderTwoColumnEmojis(revision.element, addType, removed.length > 0, changes);
// Data attributes drive the filter widget. Every kind is a link change;
// only counted kinds carry list indicators.
const changedDomains = unique(changes.map(r => r.domain)).sort();
if (changedDomains.length > 0) {
targetElement.dataset.cwLinkchange = changedDomains.join('|');
}
for (const type of listedTypes) {
const domains = unique(counted.filter(r => r.indicator === type).map(r => r.domain)).sort();
if (domains.length > 0) {
targetElement.setAttribute(indicators[type].dataAttr, domains.join('|'));
}
}
}
/**
* ==========================================================================
* 2. User Interface
* ==========================================================================
*/
/**
* Pending hosts that need gutter padding applied. Collected during
* renderTwoColumnEmojis and flushed in a single batch via flushGutterPadding()
* so that all rows shift at the same time instead of one at a time.
*/
const pendingGutterPadding = [];
/**
* Applies the gutter padding to a single host element, reserving horizontal
* space for the two-column emoji gutter.
*
* @param {HTMLElement} host - The element to pad.
*/
function applyGutterPadding(host) {
const pad = 'calc(2em + 0.5em)';
try {
const cs = window.getComputedStyle(host);
const existing = parseFloat(cs.paddingLeft || '0');
const fontSizePx = parseFloat(cs.fontSize || '16');
const desiredPx = 2.5 * fontSizePx; // 2em + 0.5em
if (isNaN(existing) || existing < desiredPx - 0.5) {
host.style.paddingLeft = pad;
}
} catch (e) {
host.style.paddingLeft = pad;
}
}
/**
* Applies gutter padding to all collected hosts at once, then clears the
* queue. Call this after a batch of renderTwoColumnEmojis calls to avoid
* one-at-a-time layout shifts.
*/
function flushGutterPadding() {
for (const host of pendingGutterPadding) {
applyGutterPadding(host);
}
pendingGutterPadding.length = 0;
}
/**
* Finds the element that hosts the gutter for a change-list element:
* the list container or, for table rows, the content cell.
*
* @param {HTMLElement} element
* @returns {HTMLElement|null}
*/
function gutterHostFor(element) {
const container =
element.closest('[data-mw-revid]:not(.mw-userlink)') ||
element.closest('li') ||
element.closest('.mw-changeslist-line, tr') || element.parentNode || element;
if (!container) return null;
let host = container;
if (host.tagName && host.tagName.toUpperCase() === 'TR') {
// For nested enhanced RC rows, place the gutter in the content cell so
// icons align with singleton rows that use .mw-changeslist-line-inner.
const nestedContent = host.querySelector('td.mw-enhanced-rc-nested[data-target-page]');
host = nestedContent || host.querySelector('td, th') || host;
}
// Prefer inner change-line wrappers on Watchlist/RC if present
const innerHost = host.querySelector('.mw-changeslist-line-inner, .mw-changeslist-line-main');
return innerHost || host;
}
/**
* Renders exactly two fixed-width emoji columns before a revision entry:
* - Left column: highest-priority addition indicator
* (warning/caution/inspect/added), or spacer if none.
* - Right column: removal indicator if removals exist, otherwise spacer.
*
* This preserves consistent alignment even when no links are added/removed.
*
* @param {HTMLElement} element - The revision list DOM element.
* @param {string|null} addType - Indicator type for the left column, or null.
* @param {boolean} hasRemovals - Whether to show the removal icon.
* @param {Object[]} changes - Resolved link changes from classifyLinkChanges().
*/
function renderTwoColumnEmojis(element, addType, hasRemovals, changes = []) {
if (!element) return;
const host = gutterHostFor(element);
if (!host) return;
const cell = (char, className) => makeElement('span', {
className, text: char,
style: { display: 'inline-block', width: '1em', textAlign: 'center', userSelect: 'none' }
});
const addChar = addType ? indicators[addType].emoji : ' ';
const remChar = hasRemovals ? indicators.removed.emoji : ' ';
// Propagate dataset for view link from source element to host
try {
if (element.dataset.cwViewHref) host.dataset.cwViewHref = element.dataset.cwViewHref;
if (element.dataset.cwViewLabel) host.dataset.cwViewLabel = element.dataset.cwViewLabel;
} catch (e) {
/* ignore */
}
// Ensure a single gutter exists
let gutter = host.querySelector('.cw-gutter');
if (gutter) {
gutter.querySelector('.cw-add').textContent = addChar;
gutter.querySelector('.cw-rem').textContent = remChar;
} else {
// Ensure the host can anchor absolutely positioned children
if (!host.style.position) host.style.position = 'relative';
// Create a left gutter that does not participate in text wrapping
gutter = makeElement('span', {
className: 'cw-gutter',
style: {
position: 'absolute', left: '0', top: '0', display: 'inline-flex', gap: '0.25em',
width: 'calc(2em + 0.25em)', alignItems: 'flex-start', userSelect: 'none'
}
});
gutter.appendChild(cell(addChar, 'cw-add'));
gutter.appendChild(cell(remChar, 'cw-rem'));
// Insert the gutter into the host; padding is deferred to flushGutterPadding()
host.insertBefore(gutter, host.firstChild);
pendingGutterPadding.push(host);
}
const lines = Object.keys(changeKinds).map(kind =>
popupLine(changeKinds[kind], changes.filter(c => c.kind === kind), true));
attachPopup(gutter, lines, {
viewHref: host.dataset.cwViewHref || '',
viewLabel: host.dataset.cwViewLabel || ''
});
host.setAttribute('data-processed-twocol', 'true');
}
/**
* Fills a popup with a close button, a two-column grid of labelled link
* lines, and a footer. Within a line, links are grouped by domain:
* "example.com" for a single link, "example.com (1, 2, 3)" for several.
* Popups with more than POPUP_COLLAPSE_AFTER links start collapsed with a
* "Show all" toggle. Idempotent: safe to call again to refresh content.
*
* @param {HTMLElement} popupEl - The popup element.
* @param {Array<{labelText: string, labelTitle: string, items: Object[]}>} lines
* Each item is a resolved link ({url, domain, indicator, archiveHost}).
* @param {Object} footerOptions - See createPopupFooter.
*/
function buildPopupContent(popupEl, lines, footerOptions) {
if (!popupEl.querySelector('.cw-close')) {
popupEl.appendChild(createCloseButton(popupEl));
}
for (const stale of popupEl.querySelectorAll('.cw-popup-content, .cw-popup-more, .cw-popup-footer')) {
stale.remove();
}
const contentWrap = makeElement('div', {
className: 'cw-popup-content',
style: { display: 'grid', gridTemplateColumns: 'max-content 1fr', columnGap: '0.5em', rowGap: '2px' }
});
let totalLinks = 0;
for (const line of lines) {
if (!line.items || line.items.length === 0) continue;
totalLinks += line.items.length;
contentWrap.appendChild(makeElement('strong', {
text: `${line.labelText}:`, title: line.labelTitle,
ariaLabel: `${line.labelText}: ${line.labelTitle}`,
style: { alignSelf: 'start', justifySelf: 'end', textAlign: 'right', whiteSpace: 'nowrap' }
}));
const content = makeElement('div', { style: { display: 'inline' } });
const groups = groupByDomain(line.items);
groups.forEach((group, idx) => {
content.appendChild(makeDomainGroup(group));
if (idx < groups.length - 1) content.appendChild(makeElement('span', { text: ' • ' }));
});
contentWrap.appendChild(content);
}
popupEl.appendChild(contentWrap);
if (totalLinks > POPUP_COLLAPSE_AFTER) {
Object.assign(contentWrap.style, { maxHeight: '7.5em', overflow: 'hidden' });
const more = makeElement('a', {
className: 'cw-popup-more',
text: showAllLinksLabel.replace('$1', String(totalLinks)) + ' ▾',
attrs: { href: '#', role: 'button' },
style: { display: 'block', marginTop: '4px' }
});
more.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
Object.assign(contentWrap.style, { maxHeight: '', overflow: '' });
more.remove();
});
popupEl.appendChild(more);
}
popupEl.appendChild(createPopupFooter(footerOptions));
}
/**
* Groups resolved links by domain, preserving first-seen order.
*
* @param {Object[]} items
* @returns {Array<{domain: string, indicator: string|null, items: Object[]}>}
*/
function groupByDomain(items) {
const groups = new Map();
for (const item of items) {
if (!groups.has(item.domain)) {
groups.set(item.domain, { domain: item.domain, indicator: item.indicator, items: [] });
}
groups.get(item.domain).items.push(item);
}
return Array.from(groups.values());
}
/**
* Renders one domain group: an optional indicator emoji, then either a
* single link labelled with the domain or the domain followed by numbered
* links to each URL.
*
* @param {{domain: string, indicator: string|null, items: Object[]}} group
* @returns {HTMLSpanElement}
*/
function makeDomainGroup(group) {
const span = makeElement('span', { style: { whiteSpace: 'nowrap', display: 'inline-block' } });
if (group.indicator) {
const ind = indicators[group.indicator];
span.appendChild(makeElement('span', {
className: 'cw-emoji', text: ind.emoji + ' ',
title: ind.explanation, ariaLabel: `${ind.msg}: ${ind.explanation}`
}));
}
if (group.items.length === 1) {
span.appendChild(makeItemLink(group.items[0], group.domain));
return span;
}
span.appendChild(makeElement('span', { text: group.domain + ' (' }));
group.items.forEach((item, idx) => {
span.appendChild(makeItemLink(item, String(idx + 1)));
if (idx < group.items.length - 1) span.appendChild(makeElement('span', { text: ', ' }));
});
span.appendChild(makeElement('span', { text: ')' }));
return span;
}
/**
* Link to one resolved item, with an archive note in the tooltip if the
* cited URL is an archived copy.
*
* @param {Object} item
* @param {string} text
* @returns {HTMLAnchorElement}
*/
function makeItemLink(item, text) {
const a = makeExternalLink(item.url, text);
if (item.archiveHost) a.title = `${archivedViaLabel} ${item.archiveHost}`;
return a;
}
/**
* Creates (or refreshes) the popup under a host element and wires its
* hover/click handlers. With no lines, any existing popup is hidden.
*
* @param {HTMLElement} host - The gutter or icon wrapper that owns the popup.
* @param {Array} lines - See buildPopupContent.
* @param {Object} footerOptions - See createPopupFooter.
*/
function attachPopup(host, lines, footerOptions) {
let popup = host.querySelector('.cw-popup');
const hasContent = lines.some(l => l.items && l.items.length > 0);
if (!hasContent) {
if (popup) popup.style.display = 'none';
host._cwEnablePopup = false;
return;
}
if (!popup) {
popup = createPopupElement();
host.appendChild(popup);
}
buildPopupContent(popup, lines, footerOptions);
host._cwEnablePopup = true;
installPopupHandlers(host);
}
/**
* Builds a popup line from a kind/indicator descriptor over a set of links.
*
* @param {{msg: string, emoji: string, explanation: string}} desc
* @param {Object[]} items - Resolved links.
* @param {boolean} withEmoji - Prefix the label with the descriptor's emoji.
* @returns {{labelText: string, labelTitle: string, items: Object[]}}
*/
function popupLine(desc, items, withEmoji) {
return {
labelText: withEmoji ? `${desc.emoji} ${desc.msg}` : desc.msg,
labelTitle: desc.explanation,
items
};
}
/**
* Creates a link with the attributes every outbound link in this script
* uses. The single place an attacker-influenced URL becomes an href, so the
* URL check lives here: absolute http(s) only, plus same-origin paths when
* `allowPath` is set (used for diff/permalink footers).
*
* @param {string} url
* @param {string} text
* @param {boolean} [allowPath=false]
* @returns {HTMLAnchorElement}
*/
function makeExternalLink(url, text, allowPath = false) {
const a = makeElement('a', {
text, attrs: { rel: 'nofollow noopener noreferrer', target: '_blank' },
style: { whiteSpace: 'nowrap' }
});
if (isValidHttpUrl(url) || (allowPath && /^\/(?!\/)/.test(url))) a.href = url;
return a;
}
/**
* Creates a DOM element with class, text, title, inline styles and
* attributes in one call. Text is always set through textContent.
*
* @param {string} tag
* @param {Object} [opts] - {className, text, title, ariaLabel, style, attrs}
* @returns {HTMLElement}
*/
function makeElement(tag, opts = {}) {
const el = document.createElement(tag);
if (opts.className) el.className = opts.className;
if (opts.text !== undefined) el.textContent = opts.text;
if (opts.title) {
el.title = opts.title;
el.setAttribute('aria-label', opts.ariaLabel || opts.title);
}
if (opts.style) Object.assign(el.style, opts.style);
for (const [k, v] of Object.entries(opts.attrs || {})) el.setAttribute(k, v);
return el;
}
/**
* Creates a styled popup element for displaying domain information.
*
* @returns {HTMLDivElement} The configured popup element.
*/
function createPopupElement() {
return makeElement('div', {
className: 'mwe-popups cw-popup',
style: {
position: 'absolute', minWidth: '260px', maxWidth: '420px', zIndex: '1000',
display: 'none', background: 'var(--background-color, #fff)',
border: '1px solid rgba(0,0,0,.15)', boxShadow: '0 2px 8px rgba(0,0,0,.2)',
padding: '8px 26px 8px 10px', borderRadius: '4px', fontSize: '0.92em'
}
});
}
/**
* Creates a styled close button for popups.
*
* @param {HTMLElement} popupEl - The popup element to hide when the button is clicked.
* @returns {HTMLButtonElement} The configured close button.
*/
function createCloseButton(popupEl) {
const closeBtn = makeElement('button', {
className: 'cw-close', text: '×', title: 'Close', attrs: { type: 'button' },
style: {
position: 'absolute', top: '4px', right: '6px', border: 'none',
background: 'transparent', cursor: 'pointer', fontSize: '14px',
lineHeight: '1', padding: '0', margin: '0'
}
});
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
popupEl.style.display = 'none';
});
return closeBtn;
}
/**
* Creates a popup footer element with an optional primary link and the
* "About Citation Watchlist" link.
*
* @param {Object} [options] - Optional configuration.
* @param {string} [options.viewHref] - URL for the primary link (e.g., diff or revision).
* @param {string} [options.viewLabel] - Label text for the primary link.
* @returns {HTMLDivElement} The configured footer element.
*/
function createPopupFooter(options) {
const footer = makeElement('div', {
className: 'cw-popup-footer', style: { marginTop: '10px', color: 'inherit' }
});
if (options && options.viewHref && options.viewLabel) {
const viewA = makeExternalLink(options.viewHref, options.viewLabel, true);
if (viewA.href) {
const strong = makeElement('strong');
strong.appendChild(viewA);
footer.appendChild(strong);
footer.appendChild(makeElement('span', { text: ' • ' }));
}
}
footer.appendChild(makeExternalLink(aboutLink, aboutLabel));
return footer;
}
/**
* Builds the shared filter widget container with radio buttons and an about link.
*
* @param {Object} config - Widget configuration.
* @param {string} config.containerId - The ID for the container element.
* @param {string} config.radioName - The name attribute for the radio button group.
* @param {Array<Object>} config.filters - Array of filter option objects
* ({id, label, checked?, tooltip?}).
* @param {Function} config.onFilter - Callback invoked with the selected filter ID.
* @param {string} [config.initialLabel] - Label text; defaults to showEditsLabel.
* @returns {HTMLDivElement} The configured filter widget container.
*/
function buildFilterWidgetContainer(config) {
const container = makeElement('div', {
attrs: { id: config.containerId },
style: {
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
border: '1px solid black', borderRadius: '25px', padding: '10px 20px',
margin: '10px 0', width: 'fit-content'
}
});
const leftSection = makeElement('div', {
style: { display: 'flex', alignItems: 'center', gap: '15px' }
});
leftSection.appendChild(makeElement('span', {
className: 'cw-filter-label', text: config.initialLabel || showEditsLabel,
style: { fontWeight: '500' }
}));
for (const filter of config.filters) {
const wrapper = makeElement('label', {
style: { display: 'flex', alignItems: 'center', gap: '5px', cursor: 'pointer' }
});
const radio = makeElement('input', {
attrs: { type: 'radio', name: config.radioName, value: filter.id }, style: { margin: '0' }
});
radio.checked = !!filter.checked;
radio.addEventListener('change', () => {
if (radio.checked) config.onFilter(filter.id);
});
const text = makeElement('span', { text: filter.label });
if (filter.tooltip) {
text.title = filter.tooltip;
Object.assign(text.style, { borderBottom: '1px dotted', cursor: 'help' });
}
wrapper.appendChild(radio);
wrapper.appendChild(text);
leftSection.appendChild(wrapper);
}
container.appendChild(leftSection);
const rightSection = makeElement('div', { style: { marginLeft: '4em' } });
const link = makeElement('a', {
text: toolName, attrs: { href: aboutLink },
style: { textDecoration: 'underline', color: '#0645ad' }
});
rightSection.appendChild(link);
container.appendChild(rightSection);
return container;
}
/**
* Builds the indicator filter list from the indicators config, sorted by
* descending priority and excluding non-positive priorities.
*
* @returns {Array<{id: string, label: string, tooltip: string}>}
*/
function buildIndicatorFilters() {
return listedTypes
.slice()
.sort((a, b) => indicators[b].priority - indicators[a].priority)
.map(id => ({
id,
label: `${indicators[id].emoji} ${indicators[id].msg}`,
tooltip: indicators[id].explanation
}));
}
/**
* Enables or greys out each radio in a filter widget according to whether
* any row currently matches it.
*
* @param {string} radioName - The radio group name.
* @param {Function} hasResults - (filterId) => boolean.
*/
function updateFilterRadios(radioName, hasResults) {
document.querySelectorAll(`input[name="${radioName}"]`).forEach(radio => {
const enabled = radio.value === 'all' || hasResults(radio.value);
const label = radio.closest('label');
radio.disabled = !enabled;
if (label) {
label.style.cursor = enabled ? 'pointer' : 'default';
label.style.color = enabled ? '' : 'gray';
}
});
}
/**
* Creates and inserts the change-list filter widget (watchlist, recent
* changes, history, contributions). Radios start disabled and are enabled by
* finishChangeListPass() once rows are annotated.
*
* @returns {HTMLElement|null} The created filter container or null if not created.
*/
function createChangeListFilterWidget() {
const ns = mw.config.get('wgNamespaceNumber');
const pageName = mw.config.get('wgPageName');
let target = document.querySelector('.mw-changeslist') ||
document.getElementById('mw-history-compare');
if (!target && ns === -1 && pageName.startsWith('Special:Contributions')) {
target = document.querySelector('.mw-pager-body');
}
if (!target) return null;
const container = buildFilterWidgetContainer({
containerId: 'cw-filter-widget',
radioName: 'cw-filter',
filters: [
{ id: 'all', label: allLabel, checked: true },
{ id: 'linkchange', label: linkChangesLabel, tooltip: linkChangesTooltip },
...buildIndicatorFilters()
],
onFilter: applyChangeListFilter,
initialLabel: loadingLabel
});
target.parentNode.insertBefore(container, target);
updateFilterRadios('cw-filter', () => false); // Disabled until annotation is complete
return container;
}
/**
* Creates and inserts a filter widget for the article surface, placed after
* the section heading. Excludes the "Link Changes" option.
*
* @param {Element} headingContainer - The heading element to insert the widget after.
*/
function createArticleFilterWidget(headingContainer) {
const container = buildFilterWidgetContainer({
containerId: 'cw-article-filter-widget',
radioName: 'cw-article-filter',
filters: [
{ id: 'all', label: allLabel, checked: true },
...buildIndicatorFilters()
],
onFilter: applyArticleFilter
});
headingContainer.after(container);
updateFilterRadios('cw-article-filter', (filterId) =>
!!document.querySelector(`li[id^="cite_note"][data-cw-indicator="${filterId}"]`));
renderLoadWarnings();
}
/**
* Called after each annotation pass on a change list: restores the widget
* label, renders any load warnings, and enables the radios that have matches.
*/
function finishChangeListPass() {
const filterLabel = document.querySelector('#cw-filter-widget .cw-filter-label');
if (filterLabel && filterLabel.firstChild &&
filterLabel.firstChild.nodeType === Node.TEXT_NODE) {
filterLabel.firstChild.textContent = showEditsLabel;
}
renderLoadWarnings();
const rows = document.querySelectorAll('[data-mw-revid]:not(.mw-userlink)');
updateFilterRadios('cw-filter', (filterId) =>
Array.from(rows).some(row => rowMatchesFilter(row, filterId)));
// Re-apply the active filter so newly loaded rows respect it
const active = document.querySelector('input[name="cw-filter"]:checked');
if (active && active.value !== 'all') applyChangeListFilter(active.value);
}
/**
* Whether a change-list row (or any nested revision inside it) matches a filter.
*
* @param {Element} row
* @param {string} filterId
* @returns {boolean}
*/
function rowMatchesFilter(row, filterId) {
if (elementMatchesFilter(row, filterId)) return true;
for (const nested of row.querySelectorAll('li[data-cw-processed="true"]')) {
if (elementMatchesFilter(nested, filterId)) return true;
}
return false;
}
/**
* Applies the selected filter to article section list items.
*
* @param {string} filterId - 'all' or an indicator type.
*/
function applyArticleFilter(filterId) {
document.querySelectorAll('li[id^="cite_note"]').forEach(li => {
li.style.display = (filterId === 'all' || li.dataset.cwIndicator === filterId) ? '' : 'none';
});
}
/**
* Applies the selected filter to the revision list.
*
* @param {string} filterId - 'all', 'linkchange', or an indicator type.
*/
let lastAppliedFilter = 'all';
function applyChangeListFilter(filterId) {
const isAll = filterId === 'all';
const revisions = document.querySelectorAll(
'[data-mw-revid]:not(.mw-userlink), [data-mw-logid]'
);
revisions.forEach(container => {
if (isAll) {
container.style.display = '';
return;
}
// Log entries (data-mw-logid) don't have revision-based attributes to
// match against filters — hide them when any specific filter is active.
const show = container.hasAttribute('data-mw-revid') && rowMatchesFilter(container, filterId);
container.style.display = show ? '' : 'none';
});
// Hide h4 date headers when all their rows are hidden
document.querySelectorAll('div.mw-changeslist h4').forEach(h4 => {
if (isAll) {
h4.style.display = '';
return;
}
const sibling = h4.nextElementSibling;
if (!sibling || sibling.tagName !== 'DIV') return;
const revItems = sibling.querySelectorAll('[data-mw-revid]');
if (revItems.length === 0) return;
h4.style.display = Array.from(revItems).every(el => el.style.display === 'none') ? 'none' : '';
});
const enhanced = document.querySelector('table.mw-enhanced-rc');
if (enhanced) {
// Hide the group header row in each tbody if all other rows are hidden
document.querySelectorAll('table.mw-enhanced-rc tbody').forEach(tbody => {
const rows = tbody.querySelectorAll('tr');
if (rows.length < 2) return;
rows[0].style.display = (!isAll && Array.from(rows).slice(1)
.every(tr => tr.style.display === 'none')) ? 'none' : '';
});
// Expand grouped sections while a specific filter is active, collapse on
// "all". Only on the transition, so switching between two filters leaves
// the expanded state alone.
const wasAll = lastAppliedFilter === 'all';
if (wasAll !== isAll) {
document.querySelectorAll('table.mw-enhanced-rc').forEach(table => {
const checkbox = groupToggleCheckbox(table);
if (checkbox && checkbox.checked === isAll) {
checkbox.checked = !isAll;
checkbox.dispatchEvent(new Event('change'));
}
});
}
}
showNoMatchingEdits(!isAll && Array.from(
document.querySelectorAll('[data-mw-revid]:not(.mw-userlink)')
).every(el => el.style.display === 'none'));
lastAppliedFilter = filterId;
}
/**
* Shows or removes the "No matching edits" message under the filter widget.
*
* @param {boolean} show
*/
function showNoMatchingEdits(show) {
const existing = document.getElementById('cw-no-matching-edits');
if (existing) existing.remove();
if (!show) return;
const msg = makeElement('p', {
text: noMatchingEditsLabel, attrs: { id: 'cw-no-matching-edits' },
style: { margin: '10px 0', fontStyle: 'italic' }
});
const widget = document.getElementById('cw-filter-widget');
if (widget && widget.nextSibling) {
widget.parentNode.insertBefore(msg, widget.nextSibling);
}
}
/**
* Returns the hidden checkbox that expands/collapses an enhanced-RC group.
*
* @param {Element} table - A table.mw-enhanced-rc element.
* @returns {HTMLInputElement|null}
*/
function groupToggleCheckbox(table) {
const arrow = table.querySelector('.mw-enhancedchanges-arrow');
const checkboxId = arrow && arrow.getAttribute('for');
return checkboxId ? document.getElementById(checkboxId) : null;
}
/**
* If any configuration pages failed to load, appends a warning glyph (with
* the details in its tooltip) to every filter-widget label on the page.
* Safe to call more than once.
*/
function renderLoadWarnings() {
const messages = SHOW_LINT_IN_WIDGET ? [...loadWarnings, ...listLint] : loadWarnings;
if (messages.length === 0) return;
document.querySelectorAll('.cw-filter-label').forEach(label => {
if (label.querySelector('.cw-load-warning')) return;
label.appendChild(makeElement('span', {
className: 'cw-load-warning', text: ' ' + loadWarningGlyph,
title: loadWarningPrefix + '\n' + messages.join('\n'), style: { cursor: 'help' }
}));
});
}
/**
* Whether the single document-level outside-click handler has been installed.
* One delegated listener replaces the former one-listener-per-gutter design.
*/
let outsideClickHandlerInstalled = false;
/**
* Installs a single document-level click handler that hides every open popup
* whose owning gutter does not contain the click target.
*/
function installOutsideClickHandler() {
if (outsideClickHandlerInstalled) return;
document.addEventListener('click', (ev) => {
document.querySelectorAll('.cw-popup').forEach(p => {
if (p.style.display === 'none' || p.style.display === '') return;
const owner = p.parentElement;
if (owner && !owner.contains(ev.target)) {
p.style.display = 'none';
}
});
});
outsideClickHandlerInstalled = true;
}
/**
* Installs hover, click, touch, and outside-click handlers on a gutter element
* to show/hide its popup.
*
* @param {HTMLElement} gutter - The gutter element containing a .cw-popup child.
*/
function installPopupHandlers(gutter) {
if (gutter._cwHandlersInstalled) return;
let hideTimer = null;
const positionPopup = () => {
const p = gutter.querySelector('.cw-popup');
if (!gutter._cwEnablePopup || !p) return;
p.style.display = 'block';
p.style.top = '1.4em';
p.style.left = '0px';
const rect = p.getBoundingClientRect();
if (rect.right > window.innerWidth - 10) {
const overflow = rect.right - (window.innerWidth - 10);
p.style.left = Math.max(0, (parseFloat(p.style.left) || 0) - overflow) + 'px';
}
};
const show = () => {
if (!gutter._cwEnablePopup) return;
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
positionPopup();
};
const scheduleHide = () => {
hideTimer = setTimeout(() => {
const p = gutter.querySelector('.cw-popup');
if (p) p.style.display = 'none';
}, 150);
};
gutter.addEventListener('mouseenter', show);
gutter.addEventListener('mouseleave', scheduleHide);
const pInit = gutter.querySelector('.cw-popup');
if (pInit) {
pInit.addEventListener('mouseenter', () => {
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
});
pInit.addEventListener('mouseleave', scheduleHide);
}
const toggle = (e) => {
if (!gutter._cwEnablePopup) return;
e.stopPropagation();
const p = gutter.querySelector('.cw-popup');
if (!p) return;
if (p.style.display === 'none' || p.style.display === '') {
show();
} else {
p.style.display = 'none';
}
};
gutter.addEventListener('click', toggle);
gutter.addEventListener('touchstart', toggle, {
passive: true
});
installOutsideClickHandler();
gutter._cwHandlersInstalled = true;
}
/**
* Watches for the change list being replaced by MediaWiki's live filters
* ("View new changes", filter edits on Special:Watchlist / RecentChanges)
* and annotates the new rows. The existing widget sits outside the replaced
* element and survives; its radios are refreshed after each pass.
*/
function installLiveReloadObserver() {
if (typeof MutationObserver === 'undefined') return;
let timer = null;
let running = false;
let rerun = false;
const run = async () => {
if (running) {
rerun = true;
return;
}
running = true;
try {
await annotateChangeList(document, false);
} catch (e) {
console.warn('Citation Watchlist: live reload pass failed:', e);
} finally {
running = false;
if (rerun) {
rerun = false;
run();
}
}
};
const observer = new MutationObserver(mutations => {
const relevant = mutations.some(m => Array.from(m.addedNodes).some(node =>
node.nodeType === Node.ELEMENT_NODE &&
(node.matches('[data-mw-revid], .mw-changeslist, .mw-changeslist-line') ||
node.querySelector('[data-mw-revid], .mw-changeslist-line'))
));
if (!relevant) return;
clearTimeout(timer);
timer = setTimeout(run, LIVE_RELOAD_DEBOUNCE_MS);
});
observer.observe(document.body, { childList: true, subtree: true });
}
const enabledNsStrings = new Set(enabledNamespaces.filter(n => n >= 0).map(String));
const namespaces = Object.entries(mw.config.get('wgFormattedNamespaces'))
.filter(([num, name]) => num !== '0' && !enabledNsStrings.has(num))
.map(([_, name]) => name.replace(/ /g, '_') + ':');
/**
* Pre-pads an element with empty emoji gutters to prevent layout shift.
*
* @param {Element} element - The DOM element to pre-pad.
*/
function prePadElement(element) {
try {
renderTwoColumnEmojis(element, null, false, []);
} catch (e) {
/* ignore */
}
}
/**
* ==========================================================================
* 3. Builders and Extractors
* ==========================================================================
*/
/**
* Constructs a URL for viewing a diff between two revisions.
*
* @param {string} title - The page title.
* @param {string} fromId - The older revision ID.
* @param {string} toId - The newer revision ID.
* @returns {string} The diff URL, or empty string on error.
*/
function buildDiffUrl(title, fromId, toId) {
try {
const script = mw.config.get('wgScript') || '/w/index.php';
return `${script}` +
`?title=${encodeURIComponent(title)}` +
`&diff=${encodeURIComponent(toId)}` +
`&oldid=${encodeURIComponent(fromId)}`;
} catch (e) {
return '';
}
}
/**
* Constructs a permalink URL for a specific revision.
*
* @param {string} revId - The revision ID.
* @returns {string} The permalink URL, or empty string on error.
*/
function buildPermalinkUrl(revId) {
try {
const articlePath = mw.config.get('wgArticlePath');
if (articlePath && articlePath.includes('$1')) {
return articlePath.replace('$1',
`Special:Permalink/${encodeURIComponent(revId)}`);
}
const script = mw.config.get('wgScript') || '/w/index.php';
return `${script}?oldid=${encodeURIComponent(revId)}`;
} catch (e) {
return '';
}
}
/**
* Strips all HTML comments from a string.
*
* @param {string} text - Input text potentially containing HTML comments.
* @returns {string} Text with all HTML comments removed.
*/
function stripHTMLComments(text) {
if (!text) return text;
return text.replace(/<!--[\s\S]*?-->/g, '');
}
/**
* Extracts all HTTP(S) URLs from a given wikitext string. The scheme is
* matched case-insensitively (MediaWiki links HTTPS:// just as it does
* https://), and protocol-relative links ("[//example.com ...]") are
* normalized to https://.
*
* @param {string} wikitext - Raw wikitext revision content.
* @returns {string[]} List of valid extracted URLs.
*/
function extractURLs(wikitext) {
const urls = [];
if (!wikitext) return urls;
const urlRegex = // eslint-disable-next-line max-len
/(?:https?:\/\/|(?<=^|[\s\[|=<>"'])\/\/)(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)/gi;
let match;
while ((match = urlRegex.exec(wikitext)) !== null) {
const raw = match[0].startsWith('//') ? 'https:' + match[0] : match[0];
try {
const url = new URL(raw);
if (/^https?:$/.test(url.protocol)) urls.push(url.href);
} catch (error) {
console.error(`Invalid URL rejected: ${match[0]}`, error.message);
}
}
return urls;
}
/**
* Web archive services whose URLs embed the original URL. Each entry's
* `extract` returns the embedded URL string or null.
*/
/** Restores "https:/example.com" (slash-collapsed by some archives) to "https://example.com". */
const fixScheme = s => s.replace(/^(https?:)\/+/i, '$1//');
const archiveUnwrappers = [
{
// https://web.archive.org/web/20200101000000/https://example.com/page
// (the embedded scheme is sometimes collapsed to "https:/")
test: h => h === 'web.archive.org' || h === 'wayback.archive.org',
extract: u => {
const m = u.pathname.match(/^\/web\/\d{1,14}[a-z_]*\/(https?:\/{1,2}.+)$/i);
return m ? fixScheme(m[1]) : null;
}
},
{
// https://archive.blah/2020.01.01-000000/https://example.com/page
test: h => /^archive\.(ph|today|is|li|vn|fo|md)$/i.test(h),
extract: u => {
const m = u.pathname.match(/^\/[^/]+\/(https?:\/{1,2}.+)$/i);
return m ? fixScheme(m[1]) : null;
}
},
{
// https://webcache.googleusercontent.com/search?q=cache:https://example.com/page
test: h => h === 'webcache.googleusercontent.com',
extract: u => {
const q = u.searchParams.get('q') || '';
const m = q.match(/^cache:(?:[A-Za-z0-9_-]+:)?(.+)$/);
if (!m) return null;
return /^https?:\/\//i.test(m[1]) ? m[1] : 'https://' + m[1];
}
}
];
/**
* Resolves a URL to the hostname that should be screened. If the URL is a
* web-archive capture of another page, the captured page's host is used.
*
* @param {string} url
* @returns {{hostname: string, target: string, archiveHost: string|null}|null}
* `target` is the unwrapped URL (or the URL itself). Null if unparsable.
*/
function screenedHost(url) {
let current;
try {
current = new URL(url);
} catch (e) {
return null;
}
let archiveHost = null;
for (let depth = 0; depth < 3 && UNWRAP_ARCHIVES; depth++) {
const unwrapper = archiveUnwrappers.find(a => a.test(current.hostname));
if (!unwrapper) break;
const inner = unwrapper.extract(current);
if (!inner) break;
try {
const innerUrl = new URL(inner);
if (!/^https?:$/.test(innerUrl.protocol)) break;
archiveHost = archiveHost || current.hostname;
current = innerUrl;
} catch (e) {
break;
}
}
return { hostname: current.hostname, target: current.href, archiveHost };
}
/**
* Resolves URLs to screened domains and list indicators.
*
* @param {string[]} urls
* @returns {Array<{url: string, target: string, domain: string, indicator: string|null, archiveHost: string|null}>}
*/
function resolveURLs(urls) {
const out = [];
for (const url of urls || []) {
const host = screenedHost(url);
if (!host) continue;
const domain = getRootDomain(host.hostname, publicSuffixSet);
if (!domain) continue;
out.push({
url, target: host.target, domain,
indicator: highestIndicatorFor(domain), archiveHost: host.archiveHost
});
}
return out;
}
/**
* Returns the highest-priority list indicator that contains a domain.
*
* @param {string} domain
* @returns {string|null}
*/
function highestIndicatorFor(domain) {
let chosen = null;
for (const type of listedTypes) {
if (indicators[type].list.has(domain) &&
(!chosen || indicators[type].priority > indicators[chosen].priority)) {
chosen = type;
}
}
return chosen;
}
/**
* Returns the highest-priority type among a list of indicator types.
*
* @param {Array<string|null>} types
* @returns {string|null}
*/
function highestPriority(types) {
let chosen = null;
for (const t of types) {
if (t && (!chosen || indicators[t].priority > indicators[chosen].priority)) chosen = t;
}
return chosen;
}
/**
* Extracts the top-level domain from a full hostname using a public suffix set.
*
* @param {string} hostname - Full hostname (e.g., sub.example.co.uk).
* @param {Set<string>} publicSuffixSet - Set of known public suffixes.
* @returns {string} The top-level domain (e.g., example.co.uk).
*/
function getRootDomain(hostname, publicSuffixSet) {
// Handle empty or invalid hostnames
if (!hostname || typeof hostname !== 'string') {
console.warn('Invalid hostname provided to getRootDomain:', hostname);
return '';
}
// Check if this is an IP address (simple check for IPv4)
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(hostname)) {
return hostname; // Return IP addresses as-is
}
const domainParts = hostname.split('.');
// Handle hostnames that are too short
if (domainParts.length < 2) {
return hostname; // Return as-is if it's a single-part hostname
}
// Try to find a matching public suffix
for (let i = 0; i < domainParts.length; i++) {
const candidate = domainParts.slice(i).join('.');
// Check both normal and exception (prefixed with !) entries
if (publicSuffixSet.has(candidate) || publicSuffixSet.has(`!${candidate}`)) {
// If we found a match, return the domain part plus the public suffix
// But make sure we don't go out of bounds
if (i > 0) {
return domainParts.slice(i - 1).join('.');
} else {
// Edge case: the entire hostname is a public suffix
return hostname;
}
}
}
// If no match in public suffix list, use a simple fallback:
// For hostnames with 2 parts, return the whole thing
// For hostnames with >2 parts, return the last 2 parts
if (domainParts.length === 2) {
return hostname;
} else {
return domainParts.slice(-2).join('.');
}
}
/**
* Extracts the first page object from MediaWiki API query response.
*
* @param {Object} data - MediaWiki API response.
* @returns {Object|null} The first page object or null if unavailable.
*/
function getFirstPage(data) {
if (!data || !data.query || !data.query.pages) return null;
return Object.values(data.query.pages)[0] || null;
}
/**
* Iterates every revision object in an API response, across all pages.
* (A revids= query spanning several articles returns several page objects.)
*
* @param {Object|null} data - MediaWiki API response.
* @returns {Object[]} Revision objects.
*/
function allRevisionsIn(data) {
if (!data || !data.query || !data.query.pages) return [];
const out = [];
for (const page of Object.values(data.query.pages)) {
if (page && Array.isArray(page.revisions)) out.push(...page.revisions);
}
return out;
}
/**
* Returns the main-slot wikitext of a revision object, or null if the
* revision is missing, hidden, or malformed.
*
* @param {Object} rev - Revision object from the API.
* @returns {string|null}
*/
function revisionText(rev) {
if (!rev || rev.texthidden !== undefined) return null;
const slot = rev.slots && rev.slots.main;
if (!slot || typeof slot['*'] !== 'string') return null;
return slot['*'];
}
/**
* Returns true if an API page object represents a page that cannot be
* read (missing, invalid, or without an accessible revision).
*
* @param {Object} page - Page object from the API.
* @returns {boolean}
*/
function isUnreadablePage(page) {
return !page || page.missing !== undefined || page.invalid !== undefined ||
!Array.isArray(page.revisions) || page.revisions.length === 0;
}
/**
* ==========================================================================
* 4. Validation
* ==========================================================================
*/
/**
* Validates that a string is a well-formed domain name.
*
* @param {string} str - The string to validate.
* @returns {boolean} True if the string is a valid domain.
*/
function isValidDomain(str) {
if (!str || str.length === 0 || str.length > 253) return false;
if (!/^[a-zA-Z0-9._-]+$/.test(str)) return false;
if (!str.includes('.')) return false;
if (/\.\./.test(str) || str.startsWith('.') || str.endsWith('.')) return false;
const labels = str.split('.');
if (labels.some(l => l.length === 0 || l.length > 63)) return false;
return true;
}
/**
* Validates that a string is a well-formed public suffix list entry.
* Allows wildcard entries (e.g., *.ck) and exception entries (e.g., !www.ck).
*
* @param {string} str - The string to validate.
* @returns {boolean} True if the string is a valid PSL entry.
*/
function isValidSuffixEntry(str) {
const cleaned = str.replace(/^[!*.]/, '');
return /^[a-zA-Z0-9._-]+$/.test(cleaned) && cleaned.length > 0 && cleaned.length <= 253;
}
/**
* Validates that a value is a string parseable as an absolute http(s) URL.
* Used to re-validate anything read back from localStorage before it is
* assigned to an href.
*
* @param {*} u - The value to validate.
* @returns {boolean} True if the value is an http(s) URL.
*/
function isValidHttpUrl(u) {
if (typeof u !== 'string') return false;
try {
return /^https?:$/i.test(new URL(u).protocol);
} catch (e) {
return false;
}
}
/**
* Validates that a value is a plausible revision ID (a positive integer
* string). Used before an ID is put into an API request or a cache key.
*
* @param {*} id - The value to validate.
* @returns {boolean}
*/
function isRevisionId(id) {
return typeof id === 'string' && /^[1-9][0-9]{0,15}$/.test(id);
}
/**
* Validates that a string is a plausible MediaWiki page title.
* Rejects titles with invalid characters, excessive length, or suspicious patterns.
*
* @param {string} title - The page title to validate.
* @returns {boolean} True if the title looks like a valid page link.
*/
function isValidPageTitle(title) {
if (typeof title !== 'string') return false;
if (title.length === 0 || title.length > 255) return false;
if (/[\[\]{}|#<>\n\r\t]/.test(title)) return false;
if (title.includes('..')) return false;
if (title.startsWith(':') || title.startsWith(' ') || title.endsWith(' ')) return false;
return true;
}
/**
* Determines whether a given page title does *not* belong to the main or draft namespaces.
* A missing title is treated as "not an article" so the row is skipped rather
* than crashing the run.
*
* @param {string} pageTitle - The title of the page.
* @returns {boolean} True if not an article namespace.
*/
function isNotArticle(pageTitle) {
if (typeof pageTitle !== 'string') return true;
return namespaces.some(namespace => pageTitle.startsWith(namespace));
}
/**
* Checks whether an element matches the specified filter.
*
* @param {Element} element - The DOM element to test.
* @param {string} filterId - The active filter ID.
* @returns {boolean} True if the element matches the filter.
*/
function elementMatchesFilter(element, filterId) {
if (filterId === 'linkchange') {
return element.hasAttribute('data-cw-linkchange') ||
listedTypes.some(type => element.hasAttribute(indicators[type].dataAttr));
}
if (indicators[filterId] && indicators[filterId].dataAttr) {
return element.hasAttribute(indicators[filterId].dataAttr);
}
return false;
}
/**
* ==========================================================================
* 5. Fetch
* ==========================================================================
*/
/**
* Fetches revisions by ID and returns them keyed by revision ID (as strings),
* across all pages in the response.
*
* @param {string[]} revIds
* @param {string[]} rvprop - Properties to request (always includes 'ids').
* @returns {Promise<Map<string, Object>|null>} Map of id -> revision object,
* or null if the request failed.
*/
async function fetchRevisionsById(revIds, rvprop) {
const data = await fetchRevisionData({
revids: revIds,
rvprop: unique(['ids', ...rvprop]),
rvslots: ['main']
});
if (!data) return null;
const out = new Map();
for (const rev of allRevisionsIn(data)) {
if (rev && rev.revid !== undefined) out.set(String(rev.revid), rev);
}
return out;
}
/**
* Fetches wikitext for a chunk of revision IDs in one request.
*
* @param {string[]} revIds - Revision IDs (at most CONTENT_CHUNK_SIZE).
* @returns {Promise<Map<string, string|null>|null>} Map of id -> wikitext
* (null for a revision that exists but is unreadable), or null if the
* request itself failed. IDs the API did not return are absent.
*/
async function fetchRevisionTexts(revIds) {
const revs = await fetchRevisionsById(revIds, ['content']);
if (!revs) return null;
return new Map(Array.from(revs, ([id, rev]) => [id, revisionText(rev)]));
}
/**
* Fetches parent IDs and sizes for a list of revision IDs, chunked at the
* API maximum and fetched concurrently.
*
* @param {string[]} revisionIds - Revision IDs.
* @returns {Promise<Object>} Map of id -> {parentid, size}; IDs that could
* not be resolved are absent.
*/
async function fetchRevisionMetadata(revisionIds) {
const result = {};
const chunks = [];
for (let i = 0; i < revisionIds.length; i += METADATA_CHUNK_SIZE) {
chunks.push(revisionIds.slice(i, i + METADATA_CHUNK_SIZE));
}
await runPool(chunks.map(chunk => async () => {
const revs = await fetchRevisionsById(chunk, ['size']);
for (const [id, rev] of revs || []) {
result[id] = {
parentid: rev.parentid || 0,
size: typeof rev.size === 'number' ? rev.size : undefined
};
}
}), CONTENT_CONCURRENCY);
return result;
}
/**
* Fetches a page's current wikitext (main slot) by title.
*
* @param {string} pageTitle
* @returns {Promise<string|null>} Null if the page is missing, unreadable, or the request failed.
*/
async function fetchPageWikitext(pageTitle) {
if (!isValidPageTitle(pageTitle)) return null;
const page = getFirstPage(await fetchRevisionData({
titles: [pageTitle],
rvprop: ['content'],
rvslots: ['main']
}));
return isUnreadablePage(page) ? null : revisionText(page.revisions[0]);
}
/**
* Fetches the ID of the first revision of a page.
*
* @param {string} pageTitle - The page title to look up.
* @returns {number|null} Revision ID or null.
*/
async function fetchFirstRevisionId(pageTitle) {
if (!isValidPageTitle(pageTitle)) return null;
const page = getFirstPage(await fetchRevisionData({
titles: [pageTitle],
rvlimit: 1,
rvdir: 'newer',
rvprop: ['ids'],
}));
if (isUnreadablePage(page)) return null;
return page.revisions[0].revid || null;
}
/**
* Fetches the list of subpages from the list of lists, parses wikilinks, caches
* the result, and returns list of subpage titles.
*
* @param {string} pageName - Title of the list-of-lists page.
* @returns {Promise<string[]>} List of subpage titles (empty on failure).
*/
async function fetchDomainListPages(pageName) {
const cacheKey = `citationWatchlistFetchDomainListPages_${pageName}`;
const cachedContent = readCache(cacheKey);
if (Array.isArray(cachedContent) && cachedContent.length > 0) {
console.log('Loaded list of lists from cache');
return cachedContent.filter(title => isValidPageTitle(title));
}
const content = await fetchPageWikitext(pageName);
if (content === null) {
console.error(`Citation Watchlist: list of lists "${pageName}" could not be loaded`);
return [];
}
const pageTitles = [];
for (const line of content.split('\n')) {
if (!line.startsWith('* [[')) continue;
const match = line.match(/\[\[([^\]]+)\]\]/); // first [[Page Title]]
if (!match) continue;
const title = match[1];
if (isValidPageTitle(title)) {
pageTitles.push(title);
} else {
console.warn(`Rejected invalid page title: "${title}"`);
}
}
if (pageTitles.length > 0) {
writeCache(cacheKey, pageTitles);
console.log('Loaded from API and stored in cache');
}
return pageTitles;
}
/**
* Parses one domain list page into per-indicator domain arrays.
* Entries that are not bare domains are collected as lint. A leading "www."
* is stripped (with a lint note), since root domains never carry it.
*
* @param {string} title - Page title, for lint messages.
* @param {string} content - Page wikitext.
* @returns {Object} {warning: string[], caution: string[], inspect: string[], lint: string[]}
*/
function parseDomainList(title, content) {
const parsed = { lint: [] };
for (const type of listedTypes) parsed[type] = [];
let current = null;
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (/^==/.test(trimmed)) {
// Any heading ends the current section; only recognized ones open a new one.
current = listedTypes.find(t => indicators[t].section === trimmed) || null;
continue;
}
if (!line.startsWith('*') || !current) continue;
let domain = line.substring(1).trim().toLowerCase();
if (!domain) continue;
if (/^www\./.test(domain) && isValidDomain(domain.slice(4))) {
parsed.lint.push(`${title}: "${domain}" has a www. prefix; matched as "${domain.slice(4)}"`);
domain = domain.slice(4);
}
if (isValidDomain(domain)) {
if (parsed[current].length < MAX_DOMAINS_PER_LIST) {
parsed[current].push(domain);
} else {
parsed.lint.push(`${title}: cap of ${MAX_DOMAINS_PER_LIST} reached in ${current}`);
}
} else {
parsed.lint.push(`${title}: "${domain}" is not a bare domain (paths, wildcards and ` +
`bare TLDs are not supported) and was ignored`);
}
}
return parsed;
}
/**
* Loads domain lists from a set of pages (parsed form cached per page) and
* populates the `Set` of each indicator. Pages that are missing or fail to
* load are skipped and recorded in loadWarnings; the remaining lists still load.
*
* @param {string[]} pageNames - List of page titles to fetch.
*/
async function fetchAndOrganizeDomainLists(pageNames) {
const parsedLists = [];
const pagesToFetch = [];
const isParsed = p => p && typeof p === 'object' && listedTypes.every(t => Array.isArray(p[t]));
for (const title of pageNames) {
const cached = readCache(`domainList:${location.hostname}:${title}`);
if (isParsed(cached)) {
parsedLists.push(cached);
} else {
pagesToFetch.push(title);
}
}
if (pagesToFetch.length > 0) {
const apiData = await fetchRevisionData({
titles: pagesToFetch,
rvprop: ['content'],
rvslots: ['main'],
});
const pages = (apiData && apiData.query && apiData.query.pages) || null;
if (!pages) {
loadWarnings.push(`API request for ${pagesToFetch.length} list page(s) failed`);
console.error('Citation Watchlist: domain list fetch failed for', pagesToFetch);
} else {
// The API normalizes titles (underscores -> spaces); cache under the
// requested spelling so the next load hits.
const requestedFor = {};
for (const t of pagesToFetch) requestedFor[t.replace(/_/g, ' ')] = t;
for (const page of Object.values(pages)) {
const title = page.title || '?';
if (isUnreadablePage(page)) {
loadWarnings.push(`List page "${title}" is missing or unreadable`);
console.warn(`Citation Watchlist: skipping list page "${title}"`);
continue;
}
const content = revisionText(page.revisions[0]);
if (content === null) {
loadWarnings.push(`List page "${title}" has no readable content`);
continue;
}
const parsed = parseDomainList(title, content);
parsedLists.push(parsed);
const cacheTitle = requestedFor[title] || title;
writeCache(`domainList:${location.hostname}:${cacheTitle}`, parsed);
}
}
}
for (const parsed of parsedLists) {
for (const type of listedTypes) {
for (const domain of parsed[type]) {
if (isValidDomain(domain)) indicators[type].list.add(domain);
}
}
if (Array.isArray(parsed.lint)) {
listLint.push(...parsed.lint.filter(l => typeof l === 'string'));
}
}
}
/**
* Fetches and caches the public suffix list used to identify top-level domains.
* The cache holds the validated entries, so cached loads skip parsing.
*
* @returns {Promise<Set<string>>} Set of public suffixes.
*/
async function fetchPublicSuffixList() {
const cacheKey = 'publicSuffixListCache';
const cached = readCache(cacheKey);
if (Array.isArray(cached) && cached.length >= MIN_PSL_ENTRIES) {
console.log('Using cached public suffix list');
return new Set(cached.filter(e => typeof e === 'string' && isValidSuffixEntry(e)));
}
const pslUrl = mw.config.get('wgArticlePath').replace('$1', publicSuffixList) +
'?action=raw';
console.log(`Raw page text request: ${pslUrl}`);
const content = await safeFetch(fetch, pslUrl).then(response => response ?
response.text() : null);
if (!content) return new Set();
const entries = [];
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith('//') && isValidSuffixEntry(trimmed)) {
entries.push(trimmed);
}
}
if (entries.length < MIN_PSL_ENTRIES) {
console.error(`Public suffix list suspiciously small (${entries.length} entries), rejecting`);
return new Set();
}
// Only cache a list that passed validation
writeCache(cacheKey, entries);
return new Set(entries);
}
/**
* Makes a MediaWiki API call to fetch revision metadata or content.
*
* @param {Object} data - Options for the API call, such as `revids`, `titles`, `rvprop`, etc.
* @returns {Promise<Object|null>} MediaWiki API query result, or null on failure.
*/
async function fetchRevisionData(data) {
const paramKeys = ['rvprop', 'revids', 'titles', 'rvslots'];
const params = {
action: 'query',
prop: 'revisions',
format: 'json',
rvdir: data.rvdir || 'older',
origin: '*'
};
if (data.rvlimit) {
params.rvlimit = data.rvlimit;
}
paramKeys.forEach(key => {
if (data[key]) {
params[key] = Array.isArray(data[key]) ? data[key].join('|') : data[key];
}
});
const api = new mw.Api();
return await safeFetch(api.get.bind(api), params);
}
/**
* Wraps any asynchronous fetch function with retry logic and error handling.
*
* @param {Function} fn - The function to execute (usually an API call).
* @param {...any} args - Arguments to pass to the fetch function.
* @returns {Promise<any|null>} Result of the fetch or null on failure.
*/
async function safeFetch(fn, ...args) {
const retries = 2;
const retryDelay = 1000;
const maxAttempts = retries + 1;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const result = await fn(...args);
if (result === null || result === undefined) {
throw new Error('Received null or undefined response');
}
if (typeof result.ok === 'boolean' && !result.ok) {
throw new Error(`HTTP error ${result.status}: ${result.statusText || 'Unknown error'}`);
}
return result;
} catch (error) {
if (attempt < maxAttempts) {
console.warn(
`Error during ${fn.name || 'fetch operation'} (attempt ${attempt}/${maxAttempts}):`,
error.message || error);
await new Promise(resolve => setTimeout(resolve, retryDelay));
} else {
console.error(
`All ${maxAttempts} attempts failed for ${fn.name || 'fetch operation'}:`,
error.message || error);
}
}
}
return null;
}
/**
* ==========================================================================
* 6. Cache
* ==========================================================================
*/
/**
* Returns every localStorage key with the given prefix. Snapshotting the keys
* first means callers can remove entries without the index shifting under them.
*
* @param {string} prefix - Key prefix to match.
* @returns {string[]}
*/
function cacheKeysWithPrefix(prefix) {
const keys = [];
try {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith(prefix)) keys.push(key);
}
} catch (e) {
/* storage unavailable */
}
return keys;
}
/**
* Removes every localStorage key with the given prefix.
*
* @param {string} prefix
*/
function removeKeysWithPrefix(prefix) {
for (const key of cacheKeysWithPrefix(prefix)) localStorage.removeItem(key);
}
/**
* Runs all cache maintenance: retired prefixes, version invalidation, and
* (at most once per CACHE_PURGE_INTERVAL) expiry and eviction.
*/
function purgeCaches() {
try {
purgeStaleVersionedCaches();
const lastKey = 'citationWatchlist_lastPurge';
const last = parseInt(localStorage.getItem(lastKey), 10);
if (isNaN(last) || Date.now() - last > CACHE_PURGE_INTERVAL) {
purgeExpiredCache();
localStorage.setItem(lastKey, Date.now().toString());
}
} catch (e) {
console.warn('Citation Watchlist: cache maintenance failed:', e);
}
}
/**
* Removes caches from retired prefixes and purges cached entries whose stored
* version is older than the current version. Each cache type tracks its own
* version independently via a localStorage key.
*/
function purgeStaleVersionedCaches() {
for (const prefix of retiredCachePrefixes) {
removeKeysWithPrefix(prefix);
localStorage.removeItem(`citationWatchlist_cacheVersion:${prefix}`);
}
for (const cache of cacheConfig) {
const versionKey = `citationWatchlist_cacheVersion:${cache.prefix}`;
const storedVersion = parseInt(localStorage.getItem(versionKey), 10);
const effectiveVersion = isNaN(storedVersion) ? 0 : storedVersion;
if (effectiveVersion < cache.version) {
removeKeysWithPrefix(cache.prefix);
try {
localStorage.setItem(versionKey, cache.version.toString());
} catch (e) {
console.warn('Cache version write error:', e);
}
console.log(`Cache invalidated for "${cache.prefix}": version ${effectiveVersion} → ${cache.version}`);
}
}
}
/**
* Removes expired localStorage cache entries and enforces per-cache entry
* caps by evicting the oldest entries.
*/
function purgeExpiredCache() {
const now = Date.now();
const stats = { checked: 0, expired: 0, evicted: 0, errors: 0 };
for (const cache of cacheConfig) {
// Snapshot keys first so removals do not shift the iteration index
const survivors = []; // { key, timestamp }
for (const key of cacheKeysWithPrefix(cache.prefix)) {
stats.checked++;
try {
const value = localStorage.getItem(key);
let parsed = null;
try {
parsed = value ? JSON.parse(value) : null;
} catch (jsonError) {
stats.errors++;
}
if (!parsed || typeof parsed.timestamp !== 'number' || now - parsed.timestamp > cache.ttl) {
localStorage.removeItem(key);
stats.expired++;
continue;
}
survivors.push({ key, timestamp: parsed.timestamp });
} catch (itemError) {
stats.errors++;
try {
localStorage.removeItem(key);
} catch (e) { /* ignore */ }
}
}
if (cache.maxEntries && survivors.length > cache.maxEntries) {
survivors.sort((a, b) => a.timestamp - b.timestamp);
const excess = survivors.length - cache.maxEntries;
for (let i = 0; i < excess; i++) {
localStorage.removeItem(survivors[i].key);
stats.evicted++;
}
}
}
if (stats.checked > 0) {
console.log(`Cache cleanup: checked ${stats.checked}, expired ${stats.expired}, ` +
`evicted ${stats.evicted}, errors ${stats.errors}`);
}
}
/**
* Looks up the cache configuration whose prefix matches a key.
*
* @param {string} key - A cache key or prefix.
* @returns {Object|undefined}
*/
function cacheConfigFor(key) {
return cacheConfig.find(c => key.startsWith(c.prefix));
}
/**
* Reads a JSON cache entry from localStorage, returning the stored content
* if it exists and has not expired (TTL taken from the key's cacheConfig
* entry). Callers must validate the shape of the returned content.
*
* @param {string} key - The localStorage key.
* @returns {*|null} The cached content, or null if missing/expired/invalid.
*/
function readCache(key) {
const config = cacheConfigFor(key);
let raw;
try {
raw = config ? localStorage.getItem(key) : null;
} catch (e) {
return null;
}
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed.timestamp === 'number' &&
(Date.now() - parsed.timestamp < config.ttl) && parsed.content !== undefined) {
return parsed.content;
}
} catch (e) {
console.warn('Cache read error:', e);
}
return null;
}
/**
* Writes a JSON cache entry to localStorage with an embedded timestamp.
* Failures (e.g. quota exceeded) are logged and otherwise ignored; the
* cache is an optimization, never a dependency.
*
* @param {string} key - The localStorage key.
* @param {*} content - The data to cache.
*/
function writeCache(key, content) {
try {
localStorage.setItem(key, JSON.stringify({ timestamp: Date.now(), content }));
} catch (e) {
console.warn('Cache write error:', e);
}
}
/**
* ==========================================================================
* 7. Entry Point
* ==========================================================================
*/
analyzeView().catch(e => {
console.error('Citation Watchlist: unrecoverable error:', e);
loadWarnings.push('Citation Watchlist stopped early due to an error (see console)');
finishChangeListPass();
}).then(() => {
console.log('Citation Watchlist script finished executing');
// On enhanced RC pages, hide the summary gutter on a group's header row
// while the group is expanded (each nested row then shows its own).
document.querySelectorAll('table.mw-enhanced-rc').forEach(table => {
const checkbox = groupToggleCheckbox(table);
if (!checkbox) return;
checkbox.addEventListener('change', () => {
const topRow = table.querySelector('tr');
const gutter = topRow && topRow.querySelector('.cw-gutter');
if (!gutter) return;
const host = gutter.parentElement;
gutter.style.display = checkbox.checked ? 'none' : 'inline-flex';
if (host) host.style.paddingLeft = checkbox.checked ? '' : 'calc(2em + 0.5em)';
});
});
});
})();
l6umo95ahffjyehvopg1l7u034hsp37
781273
781272
2026-10-28T00:02:09Z
Harej
7994
Fix overflow bug
781273
javascript
text/javascript
/**
*
* Citation Watchlist
* https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist
*
*/
(function() {
'use strict';
/**
* ==========================================================================
* LOCALIZATION
* ==========================================================================
*/
/* Labeling tool in the user interface */
const toolName = 'Citation Watchlist';
const aboutLink = 'https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist';
/* Revision inspection popup */
const addedLabel = 'Added';
const removedLabel = 'Removed';
const viewDiffLabel = 'View Diff';
const viewRevisionLabel = 'View Revision';
const aboutLabel = 'About Citation Watchlist';
const archivedViaLabel = 'Archived copy hosted at';
const archiveOnlyLabel = 'Added (archive only)';
const archivedLabel = 'Archived';
const swappedLabel = 'Swapped to archive';
const showAllLinksLabel = 'Show all $1 links';
/* Filter widget */
const showEditsLabel = 'Show edits:';
const loadingLabel = 'Loading...';
const allLabel = 'All';
const linkChangesLabel = 'Link Changes';
const linkChangesTooltip = 'Edits that add or remove links in articles';
const noMatchingEditsLabel = 'No matching edits';
/* Load-failure indicator */
const loadWarningGlyph = '⚠';
const loadWarningPrefix = 'Some Citation Watchlist lists could not be loaded:';
/**
* ==========================================================================
* DOMAIN LIST CONFIGURATION
* ==========================================================================
*
* Citation Watchlist requires the following wiki pages to function:
*
* 1. Public Suffix List
* - A local copy of the public suffix list, used for domain parsing.
* - Copy the contents of:
* https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist/Public_Suffix_List
* to a page on your own wiki.
* - Update the `publicSuffixList` variable below to reflect your page title.
*
* 2. List of Lists
* - A page linking to one or more domain list pages.
* - Format as a bullet list: "* [[Page Title]]" (space after asterisk).
* - Reference formatting example:
* https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist/Lists
* - Update the `listOfLists` variable below accordingly.
*
* 3+. Domain List Pages
* - One or more pages listing suspicious or noteworthy domains.
* - Each page must contain section headers that match the `indicators` config
* below (e.g., "==Warn==", "==Caution==").
* - Under each section, list domains in the format: "* example.com"
* - Do not use link formatting—just plain text.
* - Entries with paths ("example.com/opinion"), no dot ("gov"), or a
* leading "www." are not supported and are reported in the console.
*/
const publicSuffixList = 'Wikipedia:Citation_Watchlist/Public_Suffix_List';
const listOfLists = 'Wikipedia:Citation_Watchlist/Lists';
const enabledNamespaces = [-1, 0, 118];
const enabledSectionHeaders = ['References'];
/**
* Detection options.
* - UNWRAP_ARCHIVES: screen the original URL embedded in a web archive link
* (web.archive.org, archive.today, Google cache) rather than the archive
* host. The popup still links to the cited archive URL.
* - SHOW_LINT_IN_WIDGET: also show domain-list lint (ignored entries) in the
* filter widget's warning tooltip. Lint always goes to the console.
*/
const UNWRAP_ARCHIVES = true;
const SHOW_LINT_IN_WIDGET = false;
/**
* ==========================================================================
* INDICATOR CONFIGURATION
* ==========================================================================
*
* Defines metadata for domain indicators used in the watchlist UI.
* Each indicator is associated with a level of urgency and a unique symbol.
*
* Fields:
* - msg: Display label for the level (e.g., "Warning", "Caution").
* - emoji: Unicode character for the visual indicator (escaped as `\uXXXX`).
* - section: Must exactly match the section headers in the domain list pages.
* - priority: Higher values override lower ones for conflicting domain matches.
* Priority scale: 1 (lowest) to N (highest).
* - list: Defined as "new Set()" for all indicator types.
*
* If a domain appears in multiple lists, the one with the highest priority
* takes precedence.
*/
const indicators = {
warning: {
msg: 'Warning',
emoji: '❗',
section: '==Warn==',
explanation: 'This domain is classified as an unreliable source',
priority: 3,
dataAttr: 'data-cw-linkwarn',
list: new Set()
},
caution: {
msg: 'Caution',
emoji: '✋',
section: '==Caution==',
explanation: 'This source may be unreliable depending on context',
priority: 2,
dataAttr: 'data-cw-linkcaution',
list: new Set()
},
inspect: {
msg: 'Inspect',
emoji: '🔎',
section: '==Inspect==',
explanation: 'Take a closer look at this particular source',
priority: 1,
dataAttr: 'data-cw-linkinspect',
list: new Set()
},
added: {
msg: addedLabel,
emoji: '\u{1F517}',
section: null,
explanation: 'All added links',
priority: 0,
list: new Set()
},
removed: {
msg: removedLabel,
emoji: '\u{1F5D1}',
section: null,
explanation: 'All removed links',
priority: -1,
list: new Set()
}
};
/** Indicator types that come from the domain lists (priority > 0). */
const listedTypes = Object.keys(indicators).filter(t => indicators[t].priority > 0);
/**
* Kinds of link change between two revisions, in popup display order.
* `counts` says whether the kind counts as a source being added or removed
* (and so drives the gutter icon and list indicators); archive supplements
* and swaps are shown but not counted, since the source was already cited.
*/
const changeKinds = {
added: {
msg: addedLabel, emoji: indicators.added.emoji,
explanation: 'Links to sources not previously cited', counts: 'added'
},
archiveOnly: {
msg: archiveOnlyLabel, emoji: '\u{1F4E6}',
explanation: 'An archived copy was cited without the original link', counts: 'added'
},
archived: {
msg: archivedLabel, emoji: '\u{1F4E6}',
explanation: 'An archive link was added alongside an existing citation', counts: null
},
swapped: {
msg: swappedLabel, emoji: '\u{1F501}',
explanation: 'An existing link was replaced by its archived copy', counts: null
},
removed: {
msg: removedLabel, emoji: indicators.removed.emoji,
explanation: indicators.removed.explanation, counts: 'removed'
}
};
/** Popups with more links than this start collapsed. */
const POPUP_COLLAPSE_AFTER = 8;
/**
* Citation Watchlist
*
* Highlights potentially questionable citations added in Wikipedia revisions
* based on community-built lists of domain names
*
*
* Documentation: https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist
*
* Author: James Hare
* License: GNU General Public License v3.0 (GPL-3.0)
*
* @version 2.5
* @since 2026-10-28
*/
/**
* IF YOU WANT TO MODIFY any part of the script below this point, please submit
* your edits to https://test.wikipedia.org/wiki/User:Harej/citation-watchlist.js
* so that your modifications can be tested.
*/
let publicSuffixSet = new Set();
const MAX_DOMAINS_PER_LIST = 50000;
const MIN_PSL_ENTRIES = 1000;
/**
* Revisions larger than this are not scanned. MediaWiki's own page-size
* limit is 2 MiB, so this only trips on pathological cases; it bounds the
* download and regex work done per row.
*/
const MAX_REVISION_CHARS = 2 * 1024 * 1024;
/**
* Revision fetching strategy. Rows are never fetched one at a time: revision
* IDs are grouped into small chunks (so the first chunk returns quickly and
* its rows render while the rest are still in flight), several chunks are
* kept in flight at once, and rows are annotated the moment their chunk
* arrives. Concurrency is capped because origin=* requests are anonymous and
* therefore per-IP rate limited (see release notes for 1.5/1.6).
*/
const CONTENT_CHUNK_SIZE = 8; // max revisions per content request
const CONTENT_CHUNK_BYTES = 400 * 1024; // soft byte budget per chunk when sizes are known
const CONTENT_CONCURRENCY = 4; // simultaneous content requests
const METADATA_CHUNK_SIZE = 50; // revids per ids|size request (API max for users)
const ASSUMED_REVISION_BYTES = 40 * 1024; // chunking estimate when size is unknown
const LIVE_RELOAD_DEBOUNCE_MS = 250; // settle time after the change list is replaced
/**
* Non-fatal problems encountered while loading configuration (missing list
* pages, API failures). Surfaced in the filter widget so list maintainers
* notice quickly, instead of the tool silently running with partial data.
*/
const loadWarnings = [];
/**
* Domain-list lint: entries that were ignored because they are not bare
* domains. Always logged; shown in the widget only if SHOW_LINT_IN_WIDGET.
*/
const listLint = [];
/**
* Unified cache configuration. Each entry defines a cache key prefix, its TTL,
* version number, an optional cap on the number of entries kept, and a
* human-readable description.
*
* - revURLs: per-revision list of extracted URLs. Revisions are immutable,
* so the TTL and cap exist only to bound storage.
* - sectionURLs: per-revision, per-section URL map from rendered article HTML.
* - domainList: parsed domain lists ({warning: [...], caution: [...], ...}).
* - publicSuffixListCache: validated PSL entries as an array.
* - citationWatchlistFetchDomainListPages_: list-of-lists page titles.
*/
const cacheConfig = [
{
prefix: 'revURLs:',
ttl: 30 * 24 * 60 * 60 * 1000,
version: 1,
maxEntries: 4000,
description: 'Revision URL cache'
},
{
prefix: 'sectionURLs:',
ttl: 30 * 24 * 60 * 60 * 1000,
version: 1,
maxEntries: 500,
description: 'Article section URL cache'
},
{
prefix: 'domainList:',
ttl: 6 * 60 * 60 * 1000,
version: 1,
description: 'Domain list cache'
},
{
prefix: 'publicSuffixListCache',
ttl: 24 * 60 * 60 * 1000,
version: 1,
description: 'Public suffix list cache'
},
{
prefix: 'citationWatchlistFetchDomainListPages_',
ttl: 4 * 60 * 60 * 1000,
version: 0,
description: 'Domain list pages cache'
}
];
/** Cache prefixes from earlier versions, removed on startup. */
const retiredCachePrefixes = ['revisionDiff:'];
/** How often (ms) the full localStorage expiry/eviction pass runs. */
const CACHE_PURGE_INTERVAL = 60 * 60 * 1000;
/**
* ==========================================================================
* 1. Primary Workflow
* ==========================================================================
*/
/** Resolves once the PSL and domain lists are loaded (true) or failed (false). */
let configReady = null;
/** Rows already handed to processRevisions(), so live reloads only pick up new ones. */
const seenRows = new WeakSet();
/**
* Loads the public suffix list and the domain lists, in parallel. Never
* throws; failures are recorded in loadWarnings.
*
* @returns {Promise<boolean>} True if the tool can screen domains.
*/
async function loadConfiguration() {
try {
const [psl, listPages] = await Promise.all([
fetchPublicSuffixList(),
fetchDomainListPages(listOfLists)
]);
publicSuffixSet = psl;
if (publicSuffixSet.size === 0) {
console.error('Public Suffix List loading failed');
loadWarnings.push('Public Suffix List could not be loaded');
return false;
}
console.log('Welcome to Citation Watchlist');
if (listPages && listPages.length > 0) {
await fetchAndOrganizeDomainLists(listPages);
} else {
loadWarnings.push(`Could not load list of lists (${listOfLists})`);
}
if (listLint.length > 0) {
console.warn(`Citation Watchlist: ${listLint.length} domain-list entries were ignored:\n` +
listLint.join('\n'));
}
return true;
} catch (e) {
console.error('Citation Watchlist: configuration load failed:', e);
loadWarnings.push('Configuration could not be loaded (see console)');
return false;
}
}
/**
* Function called by the entry point for Citation Watchlist.
* Determines if the current page should be analyzed, starts loading the
* configuration, collects the revisions behind each change-list row, and
* fetches and annotates them while the configuration loads.
*/
async function analyzeView() {
purgeCaches();
const ns = mw.config.get('wgNamespaceNumber');
const inspectEnabled = enabledNamespaces.includes(ns);
// Start the configuration load immediately; nothing below waits on it
// until the first row is ready to render.
if (inspectEnabled) {
configReady = loadConfiguration();
}
// Article surface: scan enabled sections for watchlisted domains
const action = mw.config.get('wgAction');
if (action === 'view' && ns >= 0 && inspectEnabled) {
if (await configReady) {
await analyzeArticleSections();
}
return;
}
// Pre-pad change log lines immediately on Special:RecentChanges for visual stability
if (ns === -1) {
document.querySelectorAll('li.mw-changeslist-line, .mw-changeslist-line')
.forEach(line => prePadElement(line));
flushGutterPadding();
}
if (!inspectEnabled) {
return;
}
createChangeListFilterWidget();
await annotateChangeList(document, true);
installLiveReloadObserver();
}
/**
* Collects and annotates every unseen change-list row under `root`.
*
* @param {Document|Element} root - Where to look for rows.
* @param {boolean} allowFirstRevisionFallback - On a page history with no
* diff links (single-revision page), analyze the page's first revision.
*/
async function annotateChangeList(root, allowFirstRevisionFallback) {
const revisions = [];
let noLinks = true;
// Prefer iterating per-change rows when available (JS-enhanced watchlist/RC);
// fall back to link containers on non-enhanced layouts.
const unseen = selector => Array.from(root.querySelectorAll(selector)).filter(el => !seenRows.has(el));
let entries = unseen('[data-mw-revid]:not(.mw-userlink), .mw-changeslist-line');
let prePadCallback = null;
if (entries.length > 0) {
entries.forEach(line => prePadElement(line));
flushGutterPadding();
} else {
entries = unseen('.mw-changeslist-links');
prePadCallback = (_el, link) => {
const target = link.parentNode.parentNode;
prePadElement(target);
flushGutterPadding();
return target;
};
}
for (const entry of entries) {
seenRows.add(entry);
const result = await extractEntry(entry, entry, prePadCallback);
if (result.hasLink) noLinks = false;
if (!result.skip && result.revision) revisions.push(result.revision);
}
// If no links were found, extract the first revision ID
if (allowFirstRevisionFallback && noLinks && entries.length > 0) {
try {
const firstID = await fetchFirstRevisionId(mw.config.get('wgTitle'));
if (firstID) {
revisions.push({
oldrevision: String(firstID),
element: entries[0],
viewHref: buildPermalinkUrl(firstID),
viewLabel: viewRevisionLabel
});
}
} catch (e) {
console.warn('Citation Watchlist: single-revision fallback failed:', e);
}
}
await processRevisions(revisions);
finishChangeListPass();
}
/**
* Extracts the revision behind one change-list entry, isolating failures so
* that a single malformed row does not abort the remaining rows.
*
* @param {Element} container - The DOM element to query for revision links.
* @param {Element} element - The DOM element to attach to the revision object.
* @param {Function|null} prePadCallback - See extractRevisionFromLinks.
* @returns {Promise<{revision: Object|null, hasLink: boolean, skip: boolean}>}
*/
async function extractEntry(container, element, prePadCallback) {
try {
return await extractRevisionFromLinks(container, element, prePadCallback);
} catch (e) {
console.warn('Citation Watchlist: skipping entry due to error:', e);
// Report hasLink=true so a row-level failure never triggers the
// single-revision fallback for the whole page.
return { revision: null, hasLink: true, skip: true };
}
}
/**
* Annotates a set of revisions with as little latency as possible:
*
* 1. Parent IDs (for diff=prev and history rows) and sizes are resolved in
* one metadata request, only when any row needs them.
* 2. Per-revision URL lists already in the cache are used directly.
* 3. Remaining revision IDs are deduplicated (history rows share revisions),
* chunked in DOM order, fetched with bounded concurrency, and each row is
* rendered as soon as every revision it depends on is available. Fetching
* overlaps the configuration load; only rendering waits for it.
*
* @param {Object[]} revisions - Revision objects from extractRevisionFromLinks.
*/
async function processRevisions(revisions) {
if (revisions.length === 0) return;
// --- 1. Resolve parents / sizes ---------------------------------------
const parentLookups = revisions.filter(r => r.needsParentOf).map(r => r.needsParentOf);
let metadata = {};
if (parentLookups.length > 0) {
metadata = await fetchRevisionMetadata(unique(parentLookups));
for (const revision of revisions) {
if (!revision.needsParentOf) continue;
const currentId = revision.needsParentOf;
const meta = metadata[currentId];
delete revision.needsParentOf;
if (meta && meta.parentid) {
revision.oldrevision = String(meta.parentid);
revision.newrevision = currentId;
if (revision.pageTitle && !revision.viewHref) {
revision.viewHref = buildDiffUrl(revision.pageTitle,
revision.oldrevision, revision.newrevision);
}
} else if (meta) {
// First revision of the page: everything in it was "added"
revision.oldrevision = currentId;
revision.newrevision = null;
revision.viewHref = buildPermalinkUrl(currentId);
revision.viewLabel = viewRevisionLabel;
} else {
markSkipped(revision, 'fetch-failed');
revision.oldrevision = null;
}
}
}
// --- 2. Per-revision URL lists from cache; queue the rest --------------
const urlLists = new Map(); // id -> string[] | null (null = unreadable)
const pending = []; // { revision, needs: string[] }
for (const revision of revisions) {
if (revision.skipped) continue;
if (!isRevisionId(revision.oldrevision) ||
(revision.newrevision && !isRevisionId(revision.newrevision))) {
markSkipped(revision, 'invalid');
continue;
}
const needs = [revision.oldrevision];
if (revision.newrevision) needs.push(revision.newrevision);
for (const id of needs) {
if (urlLists.has(id)) continue;
const cached = readCache(`revURLs:${location.hostname}:${id}`);
if (Array.isArray(cached)) urlLists.set(id, cached.filter(isValidHttpUrl));
}
pending.push({ revision, needs });
}
if (pending.length === 0) return;
// --- 3. Fetch missing content in chunks; render as data arrives --------
const neededIds = unique(pending.flatMap(p => p.needs)).filter(id => !urlLists.has(id));
const oversized = new Set();
for (const id of neededIds) {
const meta = metadata[id];
if (meta && typeof meta.size === 'number' && meta.size > MAX_REVISION_CHARS) {
oversized.add(id);
urlLists.set(id, null);
}
}
const chunks = chunkRevisionIds(neededIds.filter(id => !oversized.has(id)), metadata);
let canRender = false;
const settle = () => {
if (!canRender) return;
for (let i = pending.length - 1; i >= 0; i--) {
const item = pending[i];
if (!item.needs.every(id => urlLists.has(id))) continue;
pending.splice(i, 1);
if (item.needs.some(id => oversized.has(id))) {
markSkipped(item.revision, 'oversized');
continue;
}
const oldList = urlLists.get(item.revision.oldrevision);
const newList = item.revision.newrevision ? urlLists.get(item.revision.newrevision) : null;
if (oldList === null || (item.revision.newrevision && newList === null)) {
markSkipped(item.revision, 'fetch-failed');
continue;
}
renderRevision(item.revision,
classifyLinkChanges(oldList, newList, !!item.revision.newrevision));
}
flushGutterPadding();
};
const onText = (id, text) => {
if (text === null) {
urlLists.set(id, null);
return;
}
if (text.length > MAX_REVISION_CHARS) {
oversized.add(id);
urlLists.set(id, null);
return;
}
const urls = extractURLs(stripHTMLComments(text));
urlLists.set(id, urls);
writeCache(`revURLs:${location.hostname}:${id}`, urls);
};
// Dispatch content fetches now; they run while the lists are still loading.
const fetching = runPool(chunks.map(chunk => async () => {
await fetchChunkIsolating(chunk, onText);
settle();
}), CONTENT_CONCURRENCY);
if (!(await configReady)) {
await fetching;
return; // no lists: nothing to screen against
}
canRender = true;
settle(); // cache hits and anything that landed while the lists loaded
await fetching;
// Anything still pending had an id the API never returned
for (const item of pending) {
markSkipped(item.revision, 'fetch-failed');
}
}
/**
* Fetches a chunk of revisions; if the request fails outright, splits the
* chunk in half and retries each half, so one problematic revision only
* costs itself rather than its neighbours. Each fetch already carries
* safeFetch's retries, so this only runs in genuine failure cases.
*
* @param {string[]} chunk - Revision IDs.
* @param {Function} onText - Called with (id, text|null) for every id.
*/
async function fetchChunkIsolating(chunk, onText) {
const result = await fetchRevisionTexts(chunk);
if (result) {
for (const id of chunk) {
onText(id, result.has(id) ? result.get(id) : null);
}
return;
}
if (chunk.length === 1) {
onText(chunk[0], null);
return;
}
const mid = Math.ceil(chunk.length / 2);
await fetchChunkIsolating(chunk.slice(0, mid), onText);
await fetchChunkIsolating(chunk.slice(mid), onText);
}
/**
* Groups revision IDs into fetch chunks in order, bounded by count and, when
* sizes are known, by an approximate byte budget so one huge article does not
* delay its neighbours.
*
* @param {string[]} ids - Revision IDs in the order they should be fetched.
* @param {Object} metadata - Map of id -> {parentid, size} (may be sparse).
* @returns {string[][]}
*/
function chunkRevisionIds(ids, metadata) {
const chunks = [];
let current = [];
let currentBytes = 0;
for (const id of ids) {
const meta = metadata[id];
const bytes = (meta && typeof meta.size === 'number') ? meta.size : ASSUMED_REVISION_BYTES;
if (current.length > 0 &&
(current.length >= CONTENT_CHUNK_SIZE || currentBytes + bytes > CONTENT_CHUNK_BYTES)) {
chunks.push(current);
current = [];
currentBytes = 0;
}
current.push(id);
currentBytes += bytes;
}
if (current.length > 0) chunks.push(current);
return chunks;
}
/**
* Runs async task factories with at most `concurrency` in flight, in order.
*
* @param {Array<() => Promise<*>>} tasks
* @param {number} concurrency
* @returns {Promise<void>}
*/
async function runPool(tasks, concurrency) {
let next = 0;
const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, async () => {
while (next < tasks.length) {
const task = tasks[next++];
try {
await task();
} catch (e) {
console.warn('Citation Watchlist: chunk failed:', e);
}
}
});
await Promise.all(workers);
}
/**
* Returns the elements of an array with duplicates removed, first occurrence wins.
*
* @param {Array} arr
* @returns {Array}
*/
function unique(arr) {
return Array.from(new Set(arr));
}
/**
* Marks a revision's row as skipped with a reason, without annotating it.
*
* @param {Object} revision
* @param {string} reason - 'fetch-failed', 'oversized' or 'invalid'.
*/
function markSkipped(revision, reason) {
revision.skipped = reason;
try {
revisionTargetElement(revision).setAttribute('data-cw-skipped', reason);
} catch (e) {
/* ignore */
}
console.warn(`Citation Watchlist: skipped ${revision.oldrevision || '?'}` +
`${revision.newrevision ? ' -> ' + revision.newrevision : ''} (${reason})`);
}
/**
* Finds the row-level element that carries data attributes for a revision.
*
* @param {Object} revision
* @returns {Element}
*/
function revisionTargetElement(revision) {
const el = revision.element;
return el.closest('[data-mw-revid]:not(.mw-userlink)') ||
el.closest('li') || el.closest('.mw-changeslist-line, tr') || el;
}
/**
* Scans the article page for sections whose H2 headers match enabledSectionHeaders,
* then analyzes each matching section for watchlisted domains.
*/
async function analyzeArticleSections() {
const headings = document.querySelectorAll('.mw-heading2 h2, h2');
for (const h2 of headings) {
const headerText = h2.textContent.trim();
if (!enabledSectionHeaders.includes(headerText)) continue;
// Collect all content between this heading and the next heading
const headingContainer = h2.closest('.mw-heading') || h2;
let sibling = headingContainer.nextElementSibling;
const sectionElements = [];
while (sibling && !sibling.classList.contains('mw-heading')) {
sectionElements.push(sibling);
sibling = sibling.nextElementSibling;
}
try {
analyzeSection(headerText, sectionElements);
createArticleFilterWidget(headingContainer);
} catch (e) {
console.warn(`Citation Watchlist: failed to analyze section "${headerText}":`, e);
}
}
}
/**
* Analyzes a single article section: extracts URLs from each <li>, screens them
* against domain lists, and prepends indicator icons to matching list items.
*
* @param {string} sectionName - The section header text (e.g. "References").
* @param {Element[]} sectionElements - DOM elements between this heading and the next.
*/
function analyzeSection(sectionName, sectionElements) {
const revisionId = mw.config.get('wgRevisionId');
const cacheKey = `sectionURLs:${location.hostname}:${revisionId}:${sectionName}`;
// Map: array of { liId, urls }. Anything read back from localStorage is
// re-validated before it can reach an href.
let liURLMap = null;
const cachedMap = readCache(cacheKey);
if (Array.isArray(cachedMap)) {
liURLMap = cachedMap
.filter(entry => entry && typeof entry.liId === 'string' && Array.isArray(entry.urls))
.map(entry => ({ liId: entry.liId, urls: entry.urls.filter(isValidHttpUrl) }));
}
const citeNotes = sectionElements.flatMap(el => Array.from(el.querySelectorAll('li[id^="cite_note"]')));
// Build from DOM if not cached
if (!liURLMap) {
liURLMap = citeNotes.map(li => ({
liId: li.id,
urls: Array.from(li.querySelectorAll('a.external[href]'))
.map(a => a.getAttribute('href'))
.filter(href => /^https?:\/\//i.test(href))
}));
writeCache(cacheKey, liURLMap);
}
// Add padding to all list items so citation text is flush with icons
for (const li of citeNotes) li.style.paddingLeft = '1.5em';
// Screen each li's URLs against domain lists and prepend icons with popups
for (const entry of liURLMap) {
const li = document.getElementById(entry.liId);
if (!li) continue;
const matched = resolveURLs(entry.urls).filter(r => r.indicator);
if (matched.length === 0) continue;
const highest = highestPriority(matched.map(m => m.indicator));
const wrapper = makeElement('span', {
style: { position: 'relative', cursor: 'pointer', marginLeft: '-1.5em',
display: 'inline-block', width: '1.5em' }
});
wrapper.appendChild(makeElement('span', { text: indicators[highest].emoji }));
attachPopup(wrapper, matched.map(m => popupLine(indicators[m.indicator], [m], false)), {});
li.prepend(wrapper);
li.dataset.cwIndicator = highest;
}
}
/**
* Extracts revision information from diff/hist/prev/cur links within a container.
* Parent IDs are not fetched here; rows that need one set `needsParentOf` and
* processRevisions() resolves them in a single batched request.
*
* @param {Element} container - The DOM element to query for revision links.
* @param {Element} element - The DOM element to attach to the revision object.
* @param {Function|null} prePadCallback - Optional callback invoked with (element, link) before
* processing each branch. In fallback mode this derives the target element and pre-pads it.
* @returns {Promise<{revision: Object|null, hasLink: boolean, skip: boolean}>}
* - revision: the extracted revision object, or null if no usable link was found.
* - hasLink: true if any recognized link was present (even if skipped).
* - skip: true if the entry should be skipped (non-article after pre-padding).
*/
async function extractRevisionFromLinks(container, element, prePadCallback) {
const kinds = [
{ selector: 'a.mw-changeslist-diff', kind: 'diff' },
{ selector: 'a.mw-changeslist-history', kind: 'hist' },
{ selector: 'a.mw-history-histlinks-previous', kind: 'prev' },
{ selector: 'a.mw-history-histlinks-current', kind: 'cur' }
];
let link = null;
let kind = null;
for (const k of kinds) {
link = container.querySelector(k.selector);
if (link) {
kind = k.kind;
break;
}
}
if (!link) {
return { revision: null, hasLink: false, skip: false };
}
const params = new URL(link.href).searchParams;
const pageTitle = params.get('title');
const target = prePadCallback ? prePadCallback(element, link) : element;
// The diff and hist links appear on watchlist/RC/contribs rows that can be
// for any namespace; history prev/cur links are always for the page itself.
if ((kind === 'diff' || kind === 'hist') && isNotArticle(pageTitle)) {
return { revision: null, hasLink: true, skip: true };
}
const revision = {
oldrevision: null,
newrevision: null,
element: target,
viewHref: '',
viewLabel: viewDiffLabel,
pageTitle: pageTitle || undefined
};
switch (kind) {
case 'diff': {
// MediaWiki convention: diff = newer, oldid = older
const diffParam = params.get('diff');
const oldidParam = params.get('oldid');
revision.viewHref = link.href;
if (diffParam === 'prev' && oldidParam) {
// Contributions: oldid is the current revision; parent resolved later
revision.needsParentOf = oldidParam;
} else {
revision.oldrevision = oldidParam;
revision.newrevision = diffParam;
}
break;
}
case 'hist': {
// New page: analyze its first revision
const firstID = await fetchFirstRevisionId(pageTitle);
if (!firstID) {
return { revision: null, hasLink: true, skip: false };
}
revision.oldrevision = String(firstID);
revision.viewHref = buildPermalinkUrl(firstID);
revision.viewLabel = viewRevisionLabel;
break;
}
case 'prev': {
// viewHref is filled in once the parent is known
revision.needsParentOf = params.get('oldid') || null;
break;
}
case 'cur': {
revision.oldrevision = params.get('oldid');
revision.viewHref = buildPermalinkUrl(revision.oldrevision);
revision.viewLabel = viewRevisionLabel;
break;
}
}
return { revision, hasLink: true, skip: false };
}
/**
* Classifies the link changes between two revisions. Links are grouped by
* the page they ultimately point at (archive URLs are unwrapped), so an
* archive link added next to an existing citation is reported as
* "archived" rather than as a new source. Multiset semantics: a URL that
* appears twice in the new revision and once in the old counts as one
* addition.
*
* @param {string[]} fromList - URLs in the older revision.
* @param {string[]|null} toList - URLs in the newer revision (ignored if !hasNew).
* @param {boolean} hasNew - False for a page's first revision, in which case
* every URL in fromList is considered added.
* @returns {Object[]} Resolved links, each with a `kind` from changeKinds.
*/
function classifyLinkChanges(fromList, toList, hasNew) {
const tally = (urls) => {
const byTarget = new Map(); // target -> { direct: [], archive: [] }
for (const r of resolveURLs(urls)) {
if (!byTarget.has(r.target)) byTarget.set(r.target, { direct: [], archive: [] });
byTarget.get(r.target)[r.archiveHost ? 'archive' : 'direct'].push(r);
}
return byTarget;
};
const oldMap = hasNew ? tally(fromList) : new Map();
const newMap = tally(hasNew ? (toList || []) : fromList);
const changes = [];
const push = (kind, entries, n) => {
for (let i = 0; i < n && i < entries.length; i++) changes.push({ ...entries[i], kind });
};
for (const target of new Set([...oldMap.keys(), ...newMap.keys()])) {
const o = oldMap.get(target) || { direct: [], archive: [] };
const n = newMap.get(target) || { direct: [], archive: [] };
const oD = o.direct.length, oA = o.archive.length;
const nD = n.direct.length, nA = n.archive.length;
if (nD > oD) push('added', n.direct, nD - oD);
if (nA > oA) {
let kind = 'archiveOnly'; // archive cited, original never present
if (nD > 0) kind = 'archived'; // original still cited: supplement
else if (oD > 0) kind = 'swapped'; // original gone, archive took its place
push(kind, n.archive, nA - oA);
}
const swapped = oD > 0 && nD === 0 && nA > oA;
if (oD > nD && !swapped) push('removed', o.direct, oD - nD);
// An archive-only citation that disappeared entirely is a removal;
// dropping a supplementary archive while the original stays is not.
if (oA > nA && oD === 0 && nD === 0 && nA === 0) push('removed', o.archive, oA - nA);
}
return changes;
}
/**
* Screens a revision's link changes against the domain lists and annotates
* its row (gutter icons, popup, filter data attributes).
* Synchronous: all network work happens in processRevisions().
*
* @param {Object} revision - Object containing oldrevision, optional
* newrevision, and DOM element.
* @param {Object[]} changes - From classifyLinkChanges().
*/
function renderRevision(revision, changes) {
const targetElement = revisionTargetElement(revision);
targetElement.setAttribute('data-cw-processed', 'true');
const counted = changes.filter(c => changeKinds[c.kind].counts);
const added = counted.filter(c => changeKinds[c.kind].counts === 'added');
const removed = counted.filter(c => changeKinds[c.kind].counts === 'removed');
// Highest-priority indicator among added links (lists only), for the gutter
const addType = highestPriority(added.map(a => a.indicator)) ||
(added.length > 0 ? 'added' : null);
try {
if (revision.viewHref) revision.element.dataset.cwViewHref = revision.viewHref;
if (revision.viewLabel) revision.element.dataset.cwViewLabel = revision.viewLabel;
} catch (e) {
/* ignore */
}
// Render two-column emojis: left = addition (highest-priority), right = removal (bin)
renderTwoColumnEmojis(revision.element, addType, removed.length > 0, changes);
// Data attributes drive the filter widget. Every kind is a link change;
// only counted kinds carry list indicators.
const changedDomains = unique(changes.map(r => r.domain)).sort();
if (changedDomains.length > 0) {
targetElement.dataset.cwLinkchange = changedDomains.join('|');
}
for (const type of listedTypes) {
const domains = unique(counted.filter(r => r.indicator === type).map(r => r.domain)).sort();
if (domains.length > 0) {
targetElement.setAttribute(indicators[type].dataAttr, domains.join('|'));
}
}
}
/**
* ==========================================================================
* 2. User Interface
* ==========================================================================
*/
/**
* Pending hosts that need gutter padding applied. Collected during
* renderTwoColumnEmojis and flushed in a single batch via flushGutterPadding()
* so that all rows shift at the same time instead of one at a time.
*/
const pendingGutterPadding = [];
/**
* Applies the gutter padding to a single host element, reserving horizontal
* space for the two-column emoji gutter.
*
* @param {HTMLElement} host - The element to pad.
*/
function applyGutterPadding(host) {
const pad = 'calc(2em + 0.5em)';
try {
const cs = window.getComputedStyle(host);
const existing = parseFloat(cs.paddingLeft || '0');
const fontSizePx = parseFloat(cs.fontSize || '16');
const desiredPx = 2.5 * fontSizePx; // 2em + 0.5em
if (isNaN(existing) || existing < desiredPx - 0.5) {
host.style.paddingLeft = pad;
}
} catch (e) {
host.style.paddingLeft = pad;
}
}
/**
* Applies gutter padding to all collected hosts at once, then clears the
* queue. Call this after a batch of renderTwoColumnEmojis calls to avoid
* one-at-a-time layout shifts.
*/
function flushGutterPadding() {
for (const host of pendingGutterPadding) {
applyGutterPadding(host);
}
pendingGutterPadding.length = 0;
}
/**
* Finds the element that hosts the gutter for a change-list element:
* the list container or, for table rows, the content cell.
*
* @param {HTMLElement} element
* @returns {HTMLElement|null}
*/
function gutterHostFor(element) {
const container =
element.closest('[data-mw-revid]:not(.mw-userlink)') ||
element.closest('li') ||
element.closest('.mw-changeslist-line, tr') || element.parentNode || element;
if (!container) return null;
let host = container;
if (host.tagName && host.tagName.toUpperCase() === 'TR') {
// For nested enhanced RC rows, place the gutter in the content cell so
// icons align with singleton rows that use .mw-changeslist-line-inner.
const nestedContent = host.querySelector('td.mw-enhanced-rc-nested[data-target-page]');
host = nestedContent || host.querySelector('td, th') || host;
}
// Prefer inner change-line wrappers on Watchlist/RC if present
const innerHost = host.querySelector('.mw-changeslist-line-inner, .mw-changeslist-line-main');
return innerHost || host;
}
/**
* Renders exactly two fixed-width emoji columns before a revision entry:
* - Left column: highest-priority addition indicator
* (warning/caution/inspect/added), or spacer if none.
* - Right column: removal indicator if removals exist, otherwise spacer.
*
* This preserves consistent alignment even when no links are added/removed.
*
* @param {HTMLElement} element - The revision list DOM element.
* @param {string|null} addType - Indicator type for the left column, or null.
* @param {boolean} hasRemovals - Whether to show the removal icon.
* @param {Object[]} changes - Resolved link changes from classifyLinkChanges().
*/
function renderTwoColumnEmojis(element, addType, hasRemovals, changes = []) {
if (!element) return;
const host = gutterHostFor(element);
if (!host) return;
const cell = (char, className) => makeElement('span', {
className, text: char,
style: { display: 'inline-block', width: '1em', textAlign: 'center', userSelect: 'none' }
});
const addChar = addType ? indicators[addType].emoji : ' ';
const remChar = hasRemovals ? indicators.removed.emoji : ' ';
// Propagate dataset for view link from source element to host
try {
if (element.dataset.cwViewHref) host.dataset.cwViewHref = element.dataset.cwViewHref;
if (element.dataset.cwViewLabel) host.dataset.cwViewLabel = element.dataset.cwViewLabel;
} catch (e) {
/* ignore */
}
// Ensure a single gutter exists
let gutter = host.querySelector('.cw-gutter');
if (gutter) {
gutter.querySelector('.cw-add').textContent = addChar;
gutter.querySelector('.cw-rem').textContent = remChar;
} else {
// Ensure the host can anchor absolutely positioned children
if (!host.style.position) host.style.position = 'relative';
// Create a left gutter that does not participate in text wrapping
gutter = makeElement('span', {
className: 'cw-gutter',
style: {
position: 'absolute', left: '0', top: '0', display: 'inline-flex', gap: '0.25em',
width: 'calc(2em + 0.25em)', alignItems: 'flex-start', userSelect: 'none'
}
});
gutter.appendChild(cell(addChar, 'cw-add'));
gutter.appendChild(cell(remChar, 'cw-rem'));
// Insert the gutter into the host; padding is deferred to flushGutterPadding()
host.insertBefore(gutter, host.firstChild);
pendingGutterPadding.push(host);
}
const lines = Object.keys(changeKinds).map(kind =>
popupLine(changeKinds[kind], changes.filter(c => c.kind === kind), true));
attachPopup(gutter, lines, {
viewHref: host.dataset.cwViewHref || '',
viewLabel: host.dataset.cwViewLabel || ''
});
host.setAttribute('data-processed-twocol', 'true');
}
/**
* Fills a popup with a close button, a two-column grid of labelled link
* lines, and a footer. Within a line, links are grouped by domain:
* "example.com" for a single link, "example.com (1, 2, 3)" for several.
* Popups with more than POPUP_COLLAPSE_AFTER links start collapsed with a
* "Show all" toggle. Idempotent: safe to call again to refresh content.
*
* @param {HTMLElement} popupEl - The popup element.
* @param {Array<{labelText: string, labelTitle: string, items: Object[]}>} lines
* Each item is a resolved link ({url, domain, indicator, archiveHost}).
* @param {Object} footerOptions - See createPopupFooter.
*/
function buildPopupContent(popupEl, lines, footerOptions) {
if (!popupEl.querySelector('.cw-close')) {
popupEl.appendChild(createCloseButton(popupEl));
}
for (const stale of popupEl.querySelectorAll('.cw-popup-content, .cw-popup-more, .cw-popup-footer')) {
stale.remove();
}
const contentWrap = makeElement('div', {
className: 'cw-popup-content',
style: { display: 'grid', gridTemplateColumns: 'max-content 1fr', columnGap: '0.5em', rowGap: '2px' }
});
let totalLinks = 0;
for (const line of lines) {
if (!line.items || line.items.length === 0) continue;
totalLinks += line.items.length;
contentWrap.appendChild(makeElement('strong', {
text: `${line.labelText}:`, title: line.labelTitle,
ariaLabel: `${line.labelText}: ${line.labelTitle}`,
style: { alignSelf: 'start', justifySelf: 'end', textAlign: 'right', whiteSpace: 'nowrap' }
}));
const content = makeElement('div', { style: { display: 'inline' } });
const groups = groupByDomain(line.items);
groups.forEach((group, idx) => {
content.appendChild(makeDomainGroup(group));
if (idx < groups.length - 1) content.appendChild(makeElement('span', { text: ' • ' }));
});
contentWrap.appendChild(content);
}
popupEl.appendChild(contentWrap);
if (totalLinks > POPUP_COLLAPSE_AFTER) {
Object.assign(contentWrap.style, { maxHeight: '7.5em', overflow: 'hidden' });
const more = makeElement('a', {
className: 'cw-popup-more',
text: showAllLinksLabel.replace('$1', String(totalLinks)) + ' ▾',
attrs: { href: '#', role: 'button' },
style: { display: 'block', marginTop: '4px' }
});
more.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
Object.assign(contentWrap.style, { maxHeight: '', overflow: '' });
more.remove();
});
popupEl.appendChild(more);
}
popupEl.appendChild(createPopupFooter(footerOptions));
}
/**
* Groups resolved links by domain, preserving first-seen order.
*
* @param {Object[]} items
* @returns {Array<{domain: string, indicator: string|null, items: Object[]}>}
*/
function groupByDomain(items) {
const groups = new Map();
for (const item of items) {
if (!groups.has(item.domain)) {
groups.set(item.domain, { domain: item.domain, indicator: item.indicator, items: [] });
}
groups.get(item.domain).items.push(item);
}
return Array.from(groups.values());
}
/**
* Renders one domain group: an optional indicator emoji, then either a
* single link labelled with the domain or the domain followed by numbered
* links to each URL.
*
* @param {{domain: string, indicator: string|null, items: Object[]}} group
* @returns {HTMLSpanElement}
*/
function makeDomainGroup(group) {
// Inline (not inline-block) so a long numbered list can wrap inside the
// popup; only the emoji+domain label is kept unbreakable.
const span = makeElement('span', { style: { display: 'inline' } });
if (group.indicator) {
const ind = indicators[group.indicator];
span.appendChild(makeElement('span', {
className: 'cw-emoji', text: ind.emoji + ' ',
title: ind.explanation, ariaLabel: `${ind.msg}: ${ind.explanation}`
}));
}
if (group.items.length === 1) {
span.appendChild(makeItemLink(group.items[0], group.domain));
return span;
}
span.appendChild(makeElement('span', { text: group.domain + ' (', style: { whiteSpace: 'nowrap' } }));
group.items.forEach((item, idx) => {
span.appendChild(makeItemLink(item, String(idx + 1)));
if (idx < group.items.length - 1) span.appendChild(makeElement('span', { text: ', ' }));
});
span.appendChild(makeElement('span', { text: ')' }));
return span;
}
/**
* Link to one resolved item, with an archive note in the tooltip if the
* cited URL is an archived copy.
*
* @param {Object} item
* @param {string} text
* @returns {HTMLAnchorElement}
*/
function makeItemLink(item, text) {
const a = makeExternalLink(item.url, text);
if (item.archiveHost) a.title = `${archivedViaLabel} ${item.archiveHost}`;
return a;
}
/**
* Creates (or refreshes) the popup under a host element and wires its
* hover/click handlers. With no lines, any existing popup is hidden.
*
* @param {HTMLElement} host - The gutter or icon wrapper that owns the popup.
* @param {Array} lines - See buildPopupContent.
* @param {Object} footerOptions - See createPopupFooter.
*/
function attachPopup(host, lines, footerOptions) {
let popup = host.querySelector('.cw-popup');
const hasContent = lines.some(l => l.items && l.items.length > 0);
if (!hasContent) {
if (popup) popup.style.display = 'none';
host._cwEnablePopup = false;
return;
}
if (!popup) {
popup = createPopupElement();
host.appendChild(popup);
}
buildPopupContent(popup, lines, footerOptions);
host._cwEnablePopup = true;
installPopupHandlers(host);
}
/**
* Builds a popup line from a kind/indicator descriptor over a set of links.
*
* @param {{msg: string, emoji: string, explanation: string}} desc
* @param {Object[]} items - Resolved links.
* @param {boolean} withEmoji - Prefix the label with the descriptor's emoji.
* @returns {{labelText: string, labelTitle: string, items: Object[]}}
*/
function popupLine(desc, items, withEmoji) {
return {
labelText: withEmoji ? `${desc.emoji} ${desc.msg}` : desc.msg,
labelTitle: desc.explanation,
items
};
}
/**
* Creates a link with the attributes every outbound link in this script
* uses. The single place an attacker-influenced URL becomes an href, so the
* URL check lives here: absolute http(s) only, plus same-origin paths when
* `allowPath` is set (used for diff/permalink footers).
*
* @param {string} url
* @param {string} text
* @param {boolean} [allowPath=false]
* @returns {HTMLAnchorElement}
*/
function makeExternalLink(url, text, allowPath = false) {
const a = makeElement('a', {
text, attrs: { rel: 'nofollow noopener noreferrer', target: '_blank' },
style: { whiteSpace: 'nowrap' }
});
if (isValidHttpUrl(url) || (allowPath && /^\/(?!\/)/.test(url))) a.href = url;
return a;
}
/**
* Creates a DOM element with class, text, title, inline styles and
* attributes in one call. Text is always set through textContent.
*
* @param {string} tag
* @param {Object} [opts] - {className, text, title, ariaLabel, style, attrs}
* @returns {HTMLElement}
*/
function makeElement(tag, opts = {}) {
const el = document.createElement(tag);
if (opts.className) el.className = opts.className;
if (opts.text !== undefined) el.textContent = opts.text;
if (opts.title) {
el.title = opts.title;
el.setAttribute('aria-label', opts.ariaLabel || opts.title);
}
if (opts.style) Object.assign(el.style, opts.style);
for (const [k, v] of Object.entries(opts.attrs || {})) el.setAttribute(k, v);
return el;
}
/**
* Creates a styled popup element for displaying domain information.
*
* @returns {HTMLDivElement} The configured popup element.
*/
function createPopupElement() {
return makeElement('div', {
className: 'mwe-popups cw-popup',
style: {
position: 'absolute', minWidth: '260px', maxWidth: '420px', zIndex: '1000',
display: 'none', background: 'var(--background-color, #fff)',
border: '1px solid rgba(0,0,0,.15)', boxShadow: '0 2px 8px rgba(0,0,0,.2)',
padding: '8px 26px 8px 10px', borderRadius: '4px', fontSize: '0.92em',
overflowWrap: 'anywhere', boxSizing: 'border-box'
}
});
}
/**
* Creates a styled close button for popups.
*
* @param {HTMLElement} popupEl - The popup element to hide when the button is clicked.
* @returns {HTMLButtonElement} The configured close button.
*/
function createCloseButton(popupEl) {
const closeBtn = makeElement('button', {
className: 'cw-close', text: '×', title: 'Close', attrs: { type: 'button' },
style: {
position: 'absolute', top: '4px', right: '6px', border: 'none',
background: 'transparent', cursor: 'pointer', fontSize: '14px',
lineHeight: '1', padding: '0', margin: '0'
}
});
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
popupEl.style.display = 'none';
});
return closeBtn;
}
/**
* Creates a popup footer element with an optional primary link and the
* "About Citation Watchlist" link.
*
* @param {Object} [options] - Optional configuration.
* @param {string} [options.viewHref] - URL for the primary link (e.g., diff or revision).
* @param {string} [options.viewLabel] - Label text for the primary link.
* @returns {HTMLDivElement} The configured footer element.
*/
function createPopupFooter(options) {
const footer = makeElement('div', {
className: 'cw-popup-footer', style: { marginTop: '10px', color: 'inherit' }
});
if (options && options.viewHref && options.viewLabel) {
const viewA = makeExternalLink(options.viewHref, options.viewLabel, true);
if (viewA.href) {
const strong = makeElement('strong');
strong.appendChild(viewA);
footer.appendChild(strong);
footer.appendChild(makeElement('span', { text: ' • ' }));
}
}
footer.appendChild(makeExternalLink(aboutLink, aboutLabel));
return footer;
}
/**
* Builds the shared filter widget container with radio buttons and an about link.
*
* @param {Object} config - Widget configuration.
* @param {string} config.containerId - The ID for the container element.
* @param {string} config.radioName - The name attribute for the radio button group.
* @param {Array<Object>} config.filters - Array of filter option objects
* ({id, label, checked?, tooltip?}).
* @param {Function} config.onFilter - Callback invoked with the selected filter ID.
* @param {string} [config.initialLabel] - Label text; defaults to showEditsLabel.
* @returns {HTMLDivElement} The configured filter widget container.
*/
function buildFilterWidgetContainer(config) {
const container = makeElement('div', {
attrs: { id: config.containerId },
style: {
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
border: '1px solid black', borderRadius: '25px', padding: '10px 20px',
margin: '10px 0', width: 'fit-content'
}
});
const leftSection = makeElement('div', {
style: { display: 'flex', alignItems: 'center', gap: '15px' }
});
leftSection.appendChild(makeElement('span', {
className: 'cw-filter-label', text: config.initialLabel || showEditsLabel,
style: { fontWeight: '500' }
}));
for (const filter of config.filters) {
const wrapper = makeElement('label', {
style: { display: 'flex', alignItems: 'center', gap: '5px', cursor: 'pointer' }
});
const radio = makeElement('input', {
attrs: { type: 'radio', name: config.radioName, value: filter.id }, style: { margin: '0' }
});
radio.checked = !!filter.checked;
radio.addEventListener('change', () => {
if (radio.checked) config.onFilter(filter.id);
});
const text = makeElement('span', { text: filter.label });
if (filter.tooltip) {
text.title = filter.tooltip;
Object.assign(text.style, { borderBottom: '1px dotted', cursor: 'help' });
}
wrapper.appendChild(radio);
wrapper.appendChild(text);
leftSection.appendChild(wrapper);
}
container.appendChild(leftSection);
const rightSection = makeElement('div', { style: { marginLeft: '4em' } });
const link = makeElement('a', {
text: toolName, attrs: { href: aboutLink },
style: { textDecoration: 'underline', color: '#0645ad' }
});
rightSection.appendChild(link);
container.appendChild(rightSection);
return container;
}
/**
* Builds the indicator filter list from the indicators config, sorted by
* descending priority and excluding non-positive priorities.
*
* @returns {Array<{id: string, label: string, tooltip: string}>}
*/
function buildIndicatorFilters() {
return listedTypes
.slice()
.sort((a, b) => indicators[b].priority - indicators[a].priority)
.map(id => ({
id,
label: `${indicators[id].emoji} ${indicators[id].msg}`,
tooltip: indicators[id].explanation
}));
}
/**
* Enables or greys out each radio in a filter widget according to whether
* any row currently matches it.
*
* @param {string} radioName - The radio group name.
* @param {Function} hasResults - (filterId) => boolean.
*/
function updateFilterRadios(radioName, hasResults) {
document.querySelectorAll(`input[name="${radioName}"]`).forEach(radio => {
const enabled = radio.value === 'all' || hasResults(radio.value);
const label = radio.closest('label');
radio.disabled = !enabled;
if (label) {
label.style.cursor = enabled ? 'pointer' : 'default';
label.style.color = enabled ? '' : 'gray';
}
});
}
/**
* Creates and inserts the change-list filter widget (watchlist, recent
* changes, history, contributions). Radios start disabled and are enabled by
* finishChangeListPass() once rows are annotated.
*
* @returns {HTMLElement|null} The created filter container or null if not created.
*/
function createChangeListFilterWidget() {
const ns = mw.config.get('wgNamespaceNumber');
const pageName = mw.config.get('wgPageName');
let target = document.querySelector('.mw-changeslist') ||
document.getElementById('mw-history-compare');
if (!target && ns === -1 && pageName.startsWith('Special:Contributions')) {
target = document.querySelector('.mw-pager-body');
}
if (!target) return null;
const container = buildFilterWidgetContainer({
containerId: 'cw-filter-widget',
radioName: 'cw-filter',
filters: [
{ id: 'all', label: allLabel, checked: true },
{ id: 'linkchange', label: linkChangesLabel, tooltip: linkChangesTooltip },
...buildIndicatorFilters()
],
onFilter: applyChangeListFilter,
initialLabel: loadingLabel
});
target.parentNode.insertBefore(container, target);
updateFilterRadios('cw-filter', () => false); // Disabled until annotation is complete
return container;
}
/**
* Creates and inserts a filter widget for the article surface, placed after
* the section heading. Excludes the "Link Changes" option.
*
* @param {Element} headingContainer - The heading element to insert the widget after.
*/
function createArticleFilterWidget(headingContainer) {
const container = buildFilterWidgetContainer({
containerId: 'cw-article-filter-widget',
radioName: 'cw-article-filter',
filters: [
{ id: 'all', label: allLabel, checked: true },
...buildIndicatorFilters()
],
onFilter: applyArticleFilter
});
headingContainer.after(container);
updateFilterRadios('cw-article-filter', (filterId) =>
!!document.querySelector(`li[id^="cite_note"][data-cw-indicator="${filterId}"]`));
renderLoadWarnings();
}
/**
* Called after each annotation pass on a change list: restores the widget
* label, renders any load warnings, and enables the radios that have matches.
*/
function finishChangeListPass() {
const filterLabel = document.querySelector('#cw-filter-widget .cw-filter-label');
if (filterLabel && filterLabel.firstChild &&
filterLabel.firstChild.nodeType === Node.TEXT_NODE) {
filterLabel.firstChild.textContent = showEditsLabel;
}
renderLoadWarnings();
const rows = document.querySelectorAll('[data-mw-revid]:not(.mw-userlink)');
updateFilterRadios('cw-filter', (filterId) =>
Array.from(rows).some(row => rowMatchesFilter(row, filterId)));
// Re-apply the active filter so newly loaded rows respect it
const active = document.querySelector('input[name="cw-filter"]:checked');
if (active && active.value !== 'all') applyChangeListFilter(active.value);
}
/**
* Whether a change-list row (or any nested revision inside it) matches a filter.
*
* @param {Element} row
* @param {string} filterId
* @returns {boolean}
*/
function rowMatchesFilter(row, filterId) {
if (elementMatchesFilter(row, filterId)) return true;
for (const nested of row.querySelectorAll('li[data-cw-processed="true"]')) {
if (elementMatchesFilter(nested, filterId)) return true;
}
return false;
}
/**
* Applies the selected filter to article section list items.
*
* @param {string} filterId - 'all' or an indicator type.
*/
function applyArticleFilter(filterId) {
document.querySelectorAll('li[id^="cite_note"]').forEach(li => {
li.style.display = (filterId === 'all' || li.dataset.cwIndicator === filterId) ? '' : 'none';
});
}
/**
* Applies the selected filter to the revision list.
*
* @param {string} filterId - 'all', 'linkchange', or an indicator type.
*/
let lastAppliedFilter = 'all';
function applyChangeListFilter(filterId) {
const isAll = filterId === 'all';
const revisions = document.querySelectorAll(
'[data-mw-revid]:not(.mw-userlink), [data-mw-logid]'
);
revisions.forEach(container => {
if (isAll) {
container.style.display = '';
return;
}
// Log entries (data-mw-logid) don't have revision-based attributes to
// match against filters — hide them when any specific filter is active.
const show = container.hasAttribute('data-mw-revid') && rowMatchesFilter(container, filterId);
container.style.display = show ? '' : 'none';
});
// Hide h4 date headers when all their rows are hidden
document.querySelectorAll('div.mw-changeslist h4').forEach(h4 => {
if (isAll) {
h4.style.display = '';
return;
}
const sibling = h4.nextElementSibling;
if (!sibling || sibling.tagName !== 'DIV') return;
const revItems = sibling.querySelectorAll('[data-mw-revid]');
if (revItems.length === 0) return;
h4.style.display = Array.from(revItems).every(el => el.style.display === 'none') ? 'none' : '';
});
const enhanced = document.querySelector('table.mw-enhanced-rc');
if (enhanced) {
// Hide the group header row in each tbody if all other rows are hidden
document.querySelectorAll('table.mw-enhanced-rc tbody').forEach(tbody => {
const rows = tbody.querySelectorAll('tr');
if (rows.length < 2) return;
rows[0].style.display = (!isAll && Array.from(rows).slice(1)
.every(tr => tr.style.display === 'none')) ? 'none' : '';
});
// Expand grouped sections while a specific filter is active, collapse on
// "all". Only on the transition, so switching between two filters leaves
// the expanded state alone.
const wasAll = lastAppliedFilter === 'all';
if (wasAll !== isAll) {
document.querySelectorAll('table.mw-enhanced-rc').forEach(table => {
const checkbox = groupToggleCheckbox(table);
if (checkbox && checkbox.checked === isAll) {
checkbox.checked = !isAll;
checkbox.dispatchEvent(new Event('change'));
}
});
}
}
showNoMatchingEdits(!isAll && Array.from(
document.querySelectorAll('[data-mw-revid]:not(.mw-userlink)')
).every(el => el.style.display === 'none'));
lastAppliedFilter = filterId;
}
/**
* Shows or removes the "No matching edits" message under the filter widget.
*
* @param {boolean} show
*/
function showNoMatchingEdits(show) {
const existing = document.getElementById('cw-no-matching-edits');
if (existing) existing.remove();
if (!show) return;
const msg = makeElement('p', {
text: noMatchingEditsLabel, attrs: { id: 'cw-no-matching-edits' },
style: { margin: '10px 0', fontStyle: 'italic' }
});
const widget = document.getElementById('cw-filter-widget');
if (widget && widget.nextSibling) {
widget.parentNode.insertBefore(msg, widget.nextSibling);
}
}
/**
* Returns the hidden checkbox that expands/collapses an enhanced-RC group.
*
* @param {Element} table - A table.mw-enhanced-rc element.
* @returns {HTMLInputElement|null}
*/
function groupToggleCheckbox(table) {
const arrow = table.querySelector('.mw-enhancedchanges-arrow');
const checkboxId = arrow && arrow.getAttribute('for');
return checkboxId ? document.getElementById(checkboxId) : null;
}
/**
* If any configuration pages failed to load, appends a warning glyph (with
* the details in its tooltip) to every filter-widget label on the page.
* Safe to call more than once.
*/
function renderLoadWarnings() {
const messages = SHOW_LINT_IN_WIDGET ? [...loadWarnings, ...listLint] : loadWarnings;
if (messages.length === 0) return;
document.querySelectorAll('.cw-filter-label').forEach(label => {
if (label.querySelector('.cw-load-warning')) return;
label.appendChild(makeElement('span', {
className: 'cw-load-warning', text: ' ' + loadWarningGlyph,
title: loadWarningPrefix + '\n' + messages.join('\n'), style: { cursor: 'help' }
}));
});
}
/**
* Whether the single document-level outside-click handler has been installed.
* One delegated listener replaces the former one-listener-per-gutter design.
*/
let outsideClickHandlerInstalled = false;
/**
* Installs a single document-level click handler that hides every open popup
* whose owning gutter does not contain the click target.
*/
function installOutsideClickHandler() {
if (outsideClickHandlerInstalled) return;
document.addEventListener('click', (ev) => {
document.querySelectorAll('.cw-popup').forEach(p => {
if (p.style.display === 'none' || p.style.display === '') return;
const owner = p.parentElement;
if (owner && !owner.contains(ev.target)) {
p.style.display = 'none';
}
});
});
outsideClickHandlerInstalled = true;
}
/**
* Installs hover, click, touch, and outside-click handlers on a gutter element
* to show/hide its popup.
*
* @param {HTMLElement} gutter - The gutter element containing a .cw-popup child.
*/
function installPopupHandlers(gutter) {
if (gutter._cwHandlersInstalled) return;
let hideTimer = null;
const positionPopup = () => {
const p = gutter.querySelector('.cw-popup');
if (!gutter._cwEnablePopup || !p) return;
p.style.display = 'block';
p.style.top = '1.4em';
p.style.left = '0px';
const rect = p.getBoundingClientRect();
if (rect.right > window.innerWidth - 10) {
const overflow = rect.right - (window.innerWidth - 10);
p.style.left = Math.max(0, (parseFloat(p.style.left) || 0) - overflow) + 'px';
}
};
const show = () => {
if (!gutter._cwEnablePopup) return;
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
positionPopup();
};
const scheduleHide = () => {
hideTimer = setTimeout(() => {
const p = gutter.querySelector('.cw-popup');
if (p) p.style.display = 'none';
}, 150);
};
gutter.addEventListener('mouseenter', show);
gutter.addEventListener('mouseleave', scheduleHide);
const pInit = gutter.querySelector('.cw-popup');
if (pInit) {
pInit.addEventListener('mouseenter', () => {
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
});
pInit.addEventListener('mouseleave', scheduleHide);
}
const toggle = (e) => {
if (!gutter._cwEnablePopup) return;
e.stopPropagation();
const p = gutter.querySelector('.cw-popup');
if (!p) return;
if (p.style.display === 'none' || p.style.display === '') {
show();
} else {
p.style.display = 'none';
}
};
gutter.addEventListener('click', toggle);
gutter.addEventListener('touchstart', toggle, {
passive: true
});
installOutsideClickHandler();
gutter._cwHandlersInstalled = true;
}
/**
* Watches for the change list being replaced by MediaWiki's live filters
* ("View new changes", filter edits on Special:Watchlist / RecentChanges)
* and annotates the new rows. The existing widget sits outside the replaced
* element and survives; its radios are refreshed after each pass.
*/
function installLiveReloadObserver() {
if (typeof MutationObserver === 'undefined') return;
let timer = null;
let running = false;
let rerun = false;
const run = async () => {
if (running) {
rerun = true;
return;
}
running = true;
try {
await annotateChangeList(document, false);
} catch (e) {
console.warn('Citation Watchlist: live reload pass failed:', e);
} finally {
running = false;
if (rerun) {
rerun = false;
run();
}
}
};
const observer = new MutationObserver(mutations => {
const relevant = mutations.some(m => Array.from(m.addedNodes).some(node =>
node.nodeType === Node.ELEMENT_NODE &&
(node.matches('[data-mw-revid], .mw-changeslist, .mw-changeslist-line') ||
node.querySelector('[data-mw-revid], .mw-changeslist-line'))
));
if (!relevant) return;
clearTimeout(timer);
timer = setTimeout(run, LIVE_RELOAD_DEBOUNCE_MS);
});
observer.observe(document.body, { childList: true, subtree: true });
}
const enabledNsStrings = new Set(enabledNamespaces.filter(n => n >= 0).map(String));
const namespaces = Object.entries(mw.config.get('wgFormattedNamespaces'))
.filter(([num, name]) => num !== '0' && !enabledNsStrings.has(num))
.map(([_, name]) => name.replace(/ /g, '_') + ':');
/**
* Pre-pads an element with empty emoji gutters to prevent layout shift.
*
* @param {Element} element - The DOM element to pre-pad.
*/
function prePadElement(element) {
try {
renderTwoColumnEmojis(element, null, false, []);
} catch (e) {
/* ignore */
}
}
/**
* ==========================================================================
* 3. Builders and Extractors
* ==========================================================================
*/
/**
* Constructs a URL for viewing a diff between two revisions.
*
* @param {string} title - The page title.
* @param {string} fromId - The older revision ID.
* @param {string} toId - The newer revision ID.
* @returns {string} The diff URL, or empty string on error.
*/
function buildDiffUrl(title, fromId, toId) {
try {
const script = mw.config.get('wgScript') || '/w/index.php';
return `${script}` +
`?title=${encodeURIComponent(title)}` +
`&diff=${encodeURIComponent(toId)}` +
`&oldid=${encodeURIComponent(fromId)}`;
} catch (e) {
return '';
}
}
/**
* Constructs a permalink URL for a specific revision.
*
* @param {string} revId - The revision ID.
* @returns {string} The permalink URL, or empty string on error.
*/
function buildPermalinkUrl(revId) {
try {
const articlePath = mw.config.get('wgArticlePath');
if (articlePath && articlePath.includes('$1')) {
return articlePath.replace('$1',
`Special:Permalink/${encodeURIComponent(revId)}`);
}
const script = mw.config.get('wgScript') || '/w/index.php';
return `${script}?oldid=${encodeURIComponent(revId)}`;
} catch (e) {
return '';
}
}
/**
* Strips all HTML comments from a string.
*
* @param {string} text - Input text potentially containing HTML comments.
* @returns {string} Text with all HTML comments removed.
*/
function stripHTMLComments(text) {
if (!text) return text;
return text.replace(/<!--[\s\S]*?-->/g, '');
}
/**
* Extracts all HTTP(S) URLs from a given wikitext string. The scheme is
* matched case-insensitively (MediaWiki links HTTPS:// just as it does
* https://), and protocol-relative links ("[//example.com ...]") are
* normalized to https://.
*
* @param {string} wikitext - Raw wikitext revision content.
* @returns {string[]} List of valid extracted URLs.
*/
function extractURLs(wikitext) {
const urls = [];
if (!wikitext) return urls;
const urlRegex = // eslint-disable-next-line max-len
/(?:https?:\/\/|(?<=^|[\s\[|=<>"'])\/\/)(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)/gi;
let match;
while ((match = urlRegex.exec(wikitext)) !== null) {
const raw = match[0].startsWith('//') ? 'https:' + match[0] : match[0];
try {
const url = new URL(raw);
if (/^https?:$/.test(url.protocol)) urls.push(url.href);
} catch (error) {
console.error(`Invalid URL rejected: ${match[0]}`, error.message);
}
}
return urls;
}
/**
* Web archive services whose URLs embed the original URL. Each entry's
* `extract` returns the embedded URL string or null.
*/
/** Restores "https:/example.com" (slash-collapsed by some archives) to "https://example.com". */
const fixScheme = s => s.replace(/^(https?:)\/+/i, '$1//');
const archiveUnwrappers = [
{
// https://web.archive.org/web/20200101000000/https://example.com/page
// (the embedded scheme is sometimes collapsed to "https:/")
test: h => h === 'web.archive.org' || h === 'wayback.archive.org',
extract: u => {
const m = u.pathname.match(/^\/web\/\d{1,14}[a-z_]*\/(https?:\/{1,2}.+)$/i);
return m ? fixScheme(m[1]) : null;
}
},
{
// https://archive.blah/2020.01.01-000000/https://example.com/page
test: h => /^archive\.(ph|today|is|li|vn|fo|md)$/i.test(h),
extract: u => {
const m = u.pathname.match(/^\/[^/]+\/(https?:\/{1,2}.+)$/i);
return m ? fixScheme(m[1]) : null;
}
},
{
// https://webcache.googleusercontent.com/search?q=cache:https://example.com/page
test: h => h === 'webcache.googleusercontent.com',
extract: u => {
const q = u.searchParams.get('q') || '';
const m = q.match(/^cache:(?:[A-Za-z0-9_-]+:)?(.+)$/);
if (!m) return null;
return /^https?:\/\//i.test(m[1]) ? m[1] : 'https://' + m[1];
}
}
];
/**
* Resolves a URL to the hostname that should be screened. If the URL is a
* web-archive capture of another page, the captured page's host is used.
*
* @param {string} url
* @returns {{hostname: string, target: string, archiveHost: string|null}|null}
* `target` is the unwrapped URL (or the URL itself). Null if unparsable.
*/
function screenedHost(url) {
let current;
try {
current = new URL(url);
} catch (e) {
return null;
}
let archiveHost = null;
for (let depth = 0; depth < 3 && UNWRAP_ARCHIVES; depth++) {
const unwrapper = archiveUnwrappers.find(a => a.test(current.hostname));
if (!unwrapper) break;
const inner = unwrapper.extract(current);
if (!inner) break;
try {
const innerUrl = new URL(inner);
if (!/^https?:$/.test(innerUrl.protocol)) break;
archiveHost = archiveHost || current.hostname;
current = innerUrl;
} catch (e) {
break;
}
}
return { hostname: current.hostname, target: current.href, archiveHost };
}
/**
* Resolves URLs to screened domains and list indicators.
*
* @param {string[]} urls
* @returns {Array<{url: string, target: string, domain: string, indicator: string|null, archiveHost: string|null}>}
*/
function resolveURLs(urls) {
const out = [];
for (const url of urls || []) {
const host = screenedHost(url);
if (!host) continue;
const domain = getRootDomain(host.hostname, publicSuffixSet);
if (!domain) continue;
out.push({
url, target: host.target, domain,
indicator: highestIndicatorFor(domain), archiveHost: host.archiveHost
});
}
return out;
}
/**
* Returns the highest-priority list indicator that contains a domain.
*
* @param {string} domain
* @returns {string|null}
*/
function highestIndicatorFor(domain) {
let chosen = null;
for (const type of listedTypes) {
if (indicators[type].list.has(domain) &&
(!chosen || indicators[type].priority > indicators[chosen].priority)) {
chosen = type;
}
}
return chosen;
}
/**
* Returns the highest-priority type among a list of indicator types.
*
* @param {Array<string|null>} types
* @returns {string|null}
*/
function highestPriority(types) {
let chosen = null;
for (const t of types) {
if (t && (!chosen || indicators[t].priority > indicators[chosen].priority)) chosen = t;
}
return chosen;
}
/**
* Extracts the top-level domain from a full hostname using a public suffix set.
*
* @param {string} hostname - Full hostname (e.g., sub.example.co.uk).
* @param {Set<string>} publicSuffixSet - Set of known public suffixes.
* @returns {string} The top-level domain (e.g., example.co.uk).
*/
function getRootDomain(hostname, publicSuffixSet) {
// Handle empty or invalid hostnames
if (!hostname || typeof hostname !== 'string') {
console.warn('Invalid hostname provided to getRootDomain:', hostname);
return '';
}
// Check if this is an IP address (simple check for IPv4)
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(hostname)) {
return hostname; // Return IP addresses as-is
}
const domainParts = hostname.split('.');
// Handle hostnames that are too short
if (domainParts.length < 2) {
return hostname; // Return as-is if it's a single-part hostname
}
// Try to find a matching public suffix
for (let i = 0; i < domainParts.length; i++) {
const candidate = domainParts.slice(i).join('.');
// Check both normal and exception (prefixed with !) entries
if (publicSuffixSet.has(candidate) || publicSuffixSet.has(`!${candidate}`)) {
// If we found a match, return the domain part plus the public suffix
// But make sure we don't go out of bounds
if (i > 0) {
return domainParts.slice(i - 1).join('.');
} else {
// Edge case: the entire hostname is a public suffix
return hostname;
}
}
}
// If no match in public suffix list, use a simple fallback:
// For hostnames with 2 parts, return the whole thing
// For hostnames with >2 parts, return the last 2 parts
if (domainParts.length === 2) {
return hostname;
} else {
return domainParts.slice(-2).join('.');
}
}
/**
* Extracts the first page object from MediaWiki API query response.
*
* @param {Object} data - MediaWiki API response.
* @returns {Object|null} The first page object or null if unavailable.
*/
function getFirstPage(data) {
if (!data || !data.query || !data.query.pages) return null;
return Object.values(data.query.pages)[0] || null;
}
/**
* Iterates every revision object in an API response, across all pages.
* (A revids= query spanning several articles returns several page objects.)
*
* @param {Object|null} data - MediaWiki API response.
* @returns {Object[]} Revision objects.
*/
function allRevisionsIn(data) {
if (!data || !data.query || !data.query.pages) return [];
const out = [];
for (const page of Object.values(data.query.pages)) {
if (page && Array.isArray(page.revisions)) out.push(...page.revisions);
}
return out;
}
/**
* Returns the main-slot wikitext of a revision object, or null if the
* revision is missing, hidden, or malformed.
*
* @param {Object} rev - Revision object from the API.
* @returns {string|null}
*/
function revisionText(rev) {
if (!rev || rev.texthidden !== undefined) return null;
const slot = rev.slots && rev.slots.main;
if (!slot || typeof slot['*'] !== 'string') return null;
return slot['*'];
}
/**
* Returns true if an API page object represents a page that cannot be
* read (missing, invalid, or without an accessible revision).
*
* @param {Object} page - Page object from the API.
* @returns {boolean}
*/
function isUnreadablePage(page) {
return !page || page.missing !== undefined || page.invalid !== undefined ||
!Array.isArray(page.revisions) || page.revisions.length === 0;
}
/**
* ==========================================================================
* 4. Validation
* ==========================================================================
*/
/**
* Validates that a string is a well-formed domain name.
*
* @param {string} str - The string to validate.
* @returns {boolean} True if the string is a valid domain.
*/
function isValidDomain(str) {
if (!str || str.length === 0 || str.length > 253) return false;
if (!/^[a-zA-Z0-9._-]+$/.test(str)) return false;
if (!str.includes('.')) return false;
if (/\.\./.test(str) || str.startsWith('.') || str.endsWith('.')) return false;
const labels = str.split('.');
if (labels.some(l => l.length === 0 || l.length > 63)) return false;
return true;
}
/**
* Validates that a string is a well-formed public suffix list entry.
* Allows wildcard entries (e.g., *.ck) and exception entries (e.g., !www.ck).
*
* @param {string} str - The string to validate.
* @returns {boolean} True if the string is a valid PSL entry.
*/
function isValidSuffixEntry(str) {
const cleaned = str.replace(/^[!*.]/, '');
return /^[a-zA-Z0-9._-]+$/.test(cleaned) && cleaned.length > 0 && cleaned.length <= 253;
}
/**
* Validates that a value is a string parseable as an absolute http(s) URL.
* Used to re-validate anything read back from localStorage before it is
* assigned to an href.
*
* @param {*} u - The value to validate.
* @returns {boolean} True if the value is an http(s) URL.
*/
function isValidHttpUrl(u) {
if (typeof u !== 'string') return false;
try {
return /^https?:$/i.test(new URL(u).protocol);
} catch (e) {
return false;
}
}
/**
* Validates that a value is a plausible revision ID (a positive integer
* string). Used before an ID is put into an API request or a cache key.
*
* @param {*} id - The value to validate.
* @returns {boolean}
*/
function isRevisionId(id) {
return typeof id === 'string' && /^[1-9][0-9]{0,15}$/.test(id);
}
/**
* Validates that a string is a plausible MediaWiki page title.
* Rejects titles with invalid characters, excessive length, or suspicious patterns.
*
* @param {string} title - The page title to validate.
* @returns {boolean} True if the title looks like a valid page link.
*/
function isValidPageTitle(title) {
if (typeof title !== 'string') return false;
if (title.length === 0 || title.length > 255) return false;
if (/[\[\]{}|#<>\n\r\t]/.test(title)) return false;
if (title.includes('..')) return false;
if (title.startsWith(':') || title.startsWith(' ') || title.endsWith(' ')) return false;
return true;
}
/**
* Determines whether a given page title does *not* belong to the main or draft namespaces.
* A missing title is treated as "not an article" so the row is skipped rather
* than crashing the run.
*
* @param {string} pageTitle - The title of the page.
* @returns {boolean} True if not an article namespace.
*/
function isNotArticle(pageTitle) {
if (typeof pageTitle !== 'string') return true;
return namespaces.some(namespace => pageTitle.startsWith(namespace));
}
/**
* Checks whether an element matches the specified filter.
*
* @param {Element} element - The DOM element to test.
* @param {string} filterId - The active filter ID.
* @returns {boolean} True if the element matches the filter.
*/
function elementMatchesFilter(element, filterId) {
if (filterId === 'linkchange') {
return element.hasAttribute('data-cw-linkchange') ||
listedTypes.some(type => element.hasAttribute(indicators[type].dataAttr));
}
if (indicators[filterId] && indicators[filterId].dataAttr) {
return element.hasAttribute(indicators[filterId].dataAttr);
}
return false;
}
/**
* ==========================================================================
* 5. Fetch
* ==========================================================================
*/
/**
* Fetches revisions by ID and returns them keyed by revision ID (as strings),
* across all pages in the response.
*
* @param {string[]} revIds
* @param {string[]} rvprop - Properties to request (always includes 'ids').
* @returns {Promise<Map<string, Object>|null>} Map of id -> revision object,
* or null if the request failed.
*/
async function fetchRevisionsById(revIds, rvprop) {
const data = await fetchRevisionData({
revids: revIds,
rvprop: unique(['ids', ...rvprop]),
rvslots: ['main']
});
if (!data) return null;
const out = new Map();
for (const rev of allRevisionsIn(data)) {
if (rev && rev.revid !== undefined) out.set(String(rev.revid), rev);
}
return out;
}
/**
* Fetches wikitext for a chunk of revision IDs in one request.
*
* @param {string[]} revIds - Revision IDs (at most CONTENT_CHUNK_SIZE).
* @returns {Promise<Map<string, string|null>|null>} Map of id -> wikitext
* (null for a revision that exists but is unreadable), or null if the
* request itself failed. IDs the API did not return are absent.
*/
async function fetchRevisionTexts(revIds) {
const revs = await fetchRevisionsById(revIds, ['content']);
if (!revs) return null;
return new Map(Array.from(revs, ([id, rev]) => [id, revisionText(rev)]));
}
/**
* Fetches parent IDs and sizes for a list of revision IDs, chunked at the
* API maximum and fetched concurrently.
*
* @param {string[]} revisionIds - Revision IDs.
* @returns {Promise<Object>} Map of id -> {parentid, size}; IDs that could
* not be resolved are absent.
*/
async function fetchRevisionMetadata(revisionIds) {
const result = {};
const chunks = [];
for (let i = 0; i < revisionIds.length; i += METADATA_CHUNK_SIZE) {
chunks.push(revisionIds.slice(i, i + METADATA_CHUNK_SIZE));
}
await runPool(chunks.map(chunk => async () => {
const revs = await fetchRevisionsById(chunk, ['size']);
for (const [id, rev] of revs || []) {
result[id] = {
parentid: rev.parentid || 0,
size: typeof rev.size === 'number' ? rev.size : undefined
};
}
}), CONTENT_CONCURRENCY);
return result;
}
/**
* Fetches a page's current wikitext (main slot) by title.
*
* @param {string} pageTitle
* @returns {Promise<string|null>} Null if the page is missing, unreadable, or the request failed.
*/
async function fetchPageWikitext(pageTitle) {
if (!isValidPageTitle(pageTitle)) return null;
const page = getFirstPage(await fetchRevisionData({
titles: [pageTitle],
rvprop: ['content'],
rvslots: ['main']
}));
return isUnreadablePage(page) ? null : revisionText(page.revisions[0]);
}
/**
* Fetches the ID of the first revision of a page.
*
* @param {string} pageTitle - The page title to look up.
* @returns {number|null} Revision ID or null.
*/
async function fetchFirstRevisionId(pageTitle) {
if (!isValidPageTitle(pageTitle)) return null;
const page = getFirstPage(await fetchRevisionData({
titles: [pageTitle],
rvlimit: 1,
rvdir: 'newer',
rvprop: ['ids'],
}));
if (isUnreadablePage(page)) return null;
return page.revisions[0].revid || null;
}
/**
* Fetches the list of subpages from the list of lists, parses wikilinks, caches
* the result, and returns list of subpage titles.
*
* @param {string} pageName - Title of the list-of-lists page.
* @returns {Promise<string[]>} List of subpage titles (empty on failure).
*/
async function fetchDomainListPages(pageName) {
const cacheKey = `citationWatchlistFetchDomainListPages_${pageName}`;
const cachedContent = readCache(cacheKey);
if (Array.isArray(cachedContent) && cachedContent.length > 0) {
console.log('Loaded list of lists from cache');
return cachedContent.filter(title => isValidPageTitle(title));
}
const content = await fetchPageWikitext(pageName);
if (content === null) {
console.error(`Citation Watchlist: list of lists "${pageName}" could not be loaded`);
return [];
}
const pageTitles = [];
for (const line of content.split('\n')) {
if (!line.startsWith('* [[')) continue;
const match = line.match(/\[\[([^\]]+)\]\]/); // first [[Page Title]]
if (!match) continue;
const title = match[1];
if (isValidPageTitle(title)) {
pageTitles.push(title);
} else {
console.warn(`Rejected invalid page title: "${title}"`);
}
}
if (pageTitles.length > 0) {
writeCache(cacheKey, pageTitles);
console.log('Loaded from API and stored in cache');
}
return pageTitles;
}
/**
* Parses one domain list page into per-indicator domain arrays.
* Entries that are not bare domains are collected as lint. A leading "www."
* is stripped (with a lint note), since root domains never carry it.
*
* @param {string} title - Page title, for lint messages.
* @param {string} content - Page wikitext.
* @returns {Object} {warning: string[], caution: string[], inspect: string[], lint: string[]}
*/
function parseDomainList(title, content) {
const parsed = { lint: [] };
for (const type of listedTypes) parsed[type] = [];
let current = null;
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (/^==/.test(trimmed)) {
// Any heading ends the current section; only recognized ones open a new one.
current = listedTypes.find(t => indicators[t].section === trimmed) || null;
continue;
}
if (!line.startsWith('*') || !current) continue;
let domain = line.substring(1).trim().toLowerCase();
if (!domain) continue;
if (/^www\./.test(domain) && isValidDomain(domain.slice(4))) {
parsed.lint.push(`${title}: "${domain}" has a www. prefix; matched as "${domain.slice(4)}"`);
domain = domain.slice(4);
}
if (isValidDomain(domain)) {
if (parsed[current].length < MAX_DOMAINS_PER_LIST) {
parsed[current].push(domain);
} else {
parsed.lint.push(`${title}: cap of ${MAX_DOMAINS_PER_LIST} reached in ${current}`);
}
} else {
parsed.lint.push(`${title}: "${domain}" is not a bare domain (paths, wildcards and ` +
`bare TLDs are not supported) and was ignored`);
}
}
return parsed;
}
/**
* Loads domain lists from a set of pages (parsed form cached per page) and
* populates the `Set` of each indicator. Pages that are missing or fail to
* load are skipped and recorded in loadWarnings; the remaining lists still load.
*
* @param {string[]} pageNames - List of page titles to fetch.
*/
async function fetchAndOrganizeDomainLists(pageNames) {
const parsedLists = [];
const pagesToFetch = [];
const isParsed = p => p && typeof p === 'object' && listedTypes.every(t => Array.isArray(p[t]));
for (const title of pageNames) {
const cached = readCache(`domainList:${location.hostname}:${title}`);
if (isParsed(cached)) {
parsedLists.push(cached);
} else {
pagesToFetch.push(title);
}
}
if (pagesToFetch.length > 0) {
const apiData = await fetchRevisionData({
titles: pagesToFetch,
rvprop: ['content'],
rvslots: ['main'],
});
const pages = (apiData && apiData.query && apiData.query.pages) || null;
if (!pages) {
loadWarnings.push(`API request for ${pagesToFetch.length} list page(s) failed`);
console.error('Citation Watchlist: domain list fetch failed for', pagesToFetch);
} else {
// The API normalizes titles (underscores -> spaces); cache under the
// requested spelling so the next load hits.
const requestedFor = {};
for (const t of pagesToFetch) requestedFor[t.replace(/_/g, ' ')] = t;
for (const page of Object.values(pages)) {
const title = page.title || '?';
if (isUnreadablePage(page)) {
loadWarnings.push(`List page "${title}" is missing or unreadable`);
console.warn(`Citation Watchlist: skipping list page "${title}"`);
continue;
}
const content = revisionText(page.revisions[0]);
if (content === null) {
loadWarnings.push(`List page "${title}" has no readable content`);
continue;
}
const parsed = parseDomainList(title, content);
parsedLists.push(parsed);
const cacheTitle = requestedFor[title] || title;
writeCache(`domainList:${location.hostname}:${cacheTitle}`, parsed);
}
}
}
for (const parsed of parsedLists) {
for (const type of listedTypes) {
for (const domain of parsed[type]) {
if (isValidDomain(domain)) indicators[type].list.add(domain);
}
}
if (Array.isArray(parsed.lint)) {
listLint.push(...parsed.lint.filter(l => typeof l === 'string'));
}
}
}
/**
* Fetches and caches the public suffix list used to identify top-level domains.
* The cache holds the validated entries, so cached loads skip parsing.
*
* @returns {Promise<Set<string>>} Set of public suffixes.
*/
async function fetchPublicSuffixList() {
const cacheKey = 'publicSuffixListCache';
const cached = readCache(cacheKey);
if (Array.isArray(cached) && cached.length >= MIN_PSL_ENTRIES) {
console.log('Using cached public suffix list');
return new Set(cached.filter(e => typeof e === 'string' && isValidSuffixEntry(e)));
}
const pslUrl = mw.config.get('wgArticlePath').replace('$1', publicSuffixList) +
'?action=raw';
console.log(`Raw page text request: ${pslUrl}`);
const content = await safeFetch(fetch, pslUrl).then(response => response ?
response.text() : null);
if (!content) return new Set();
const entries = [];
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith('//') && isValidSuffixEntry(trimmed)) {
entries.push(trimmed);
}
}
if (entries.length < MIN_PSL_ENTRIES) {
console.error(`Public suffix list suspiciously small (${entries.length} entries), rejecting`);
return new Set();
}
// Only cache a list that passed validation
writeCache(cacheKey, entries);
return new Set(entries);
}
/**
* Makes a MediaWiki API call to fetch revision metadata or content.
*
* @param {Object} data - Options for the API call, such as `revids`, `titles`, `rvprop`, etc.
* @returns {Promise<Object|null>} MediaWiki API query result, or null on failure.
*/
async function fetchRevisionData(data) {
const paramKeys = ['rvprop', 'revids', 'titles', 'rvslots'];
const params = {
action: 'query',
prop: 'revisions',
format: 'json',
rvdir: data.rvdir || 'older',
origin: '*'
};
if (data.rvlimit) {
params.rvlimit = data.rvlimit;
}
paramKeys.forEach(key => {
if (data[key]) {
params[key] = Array.isArray(data[key]) ? data[key].join('|') : data[key];
}
});
const api = new mw.Api();
return await safeFetch(api.get.bind(api), params);
}
/**
* Wraps any asynchronous fetch function with retry logic and error handling.
*
* @param {Function} fn - The function to execute (usually an API call).
* @param {...any} args - Arguments to pass to the fetch function.
* @returns {Promise<any|null>} Result of the fetch or null on failure.
*/
async function safeFetch(fn, ...args) {
const retries = 2;
const retryDelay = 1000;
const maxAttempts = retries + 1;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const result = await fn(...args);
if (result === null || result === undefined) {
throw new Error('Received null or undefined response');
}
if (typeof result.ok === 'boolean' && !result.ok) {
throw new Error(`HTTP error ${result.status}: ${result.statusText || 'Unknown error'}`);
}
return result;
} catch (error) {
if (attempt < maxAttempts) {
console.warn(
`Error during ${fn.name || 'fetch operation'} (attempt ${attempt}/${maxAttempts}):`,
error.message || error);
await new Promise(resolve => setTimeout(resolve, retryDelay));
} else {
console.error(
`All ${maxAttempts} attempts failed for ${fn.name || 'fetch operation'}:`,
error.message || error);
}
}
}
return null;
}
/**
* ==========================================================================
* 6. Cache
* ==========================================================================
*/
/**
* Returns every localStorage key with the given prefix. Snapshotting the keys
* first means callers can remove entries without the index shifting under them.
*
* @param {string} prefix - Key prefix to match.
* @returns {string[]}
*/
function cacheKeysWithPrefix(prefix) {
const keys = [];
try {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith(prefix)) keys.push(key);
}
} catch (e) {
/* storage unavailable */
}
return keys;
}
/**
* Removes every localStorage key with the given prefix.
*
* @param {string} prefix
*/
function removeKeysWithPrefix(prefix) {
for (const key of cacheKeysWithPrefix(prefix)) localStorage.removeItem(key);
}
/**
* Runs all cache maintenance: retired prefixes, version invalidation, and
* (at most once per CACHE_PURGE_INTERVAL) expiry and eviction.
*/
function purgeCaches() {
try {
purgeStaleVersionedCaches();
const lastKey = 'citationWatchlist_lastPurge';
const last = parseInt(localStorage.getItem(lastKey), 10);
if (isNaN(last) || Date.now() - last > CACHE_PURGE_INTERVAL) {
purgeExpiredCache();
localStorage.setItem(lastKey, Date.now().toString());
}
} catch (e) {
console.warn('Citation Watchlist: cache maintenance failed:', e);
}
}
/**
* Removes caches from retired prefixes and purges cached entries whose stored
* version is older than the current version. Each cache type tracks its own
* version independently via a localStorage key.
*/
function purgeStaleVersionedCaches() {
for (const prefix of retiredCachePrefixes) {
removeKeysWithPrefix(prefix);
localStorage.removeItem(`citationWatchlist_cacheVersion:${prefix}`);
}
for (const cache of cacheConfig) {
const versionKey = `citationWatchlist_cacheVersion:${cache.prefix}`;
const storedVersion = parseInt(localStorage.getItem(versionKey), 10);
const effectiveVersion = isNaN(storedVersion) ? 0 : storedVersion;
if (effectiveVersion < cache.version) {
removeKeysWithPrefix(cache.prefix);
try {
localStorage.setItem(versionKey, cache.version.toString());
} catch (e) {
console.warn('Cache version write error:', e);
}
console.log(`Cache invalidated for "${cache.prefix}": version ${effectiveVersion} → ${cache.version}`);
}
}
}
/**
* Removes expired localStorage cache entries and enforces per-cache entry
* caps by evicting the oldest entries.
*/
function purgeExpiredCache() {
const now = Date.now();
const stats = { checked: 0, expired: 0, evicted: 0, errors: 0 };
for (const cache of cacheConfig) {
// Snapshot keys first so removals do not shift the iteration index
const survivors = []; // { key, timestamp }
for (const key of cacheKeysWithPrefix(cache.prefix)) {
stats.checked++;
try {
const value = localStorage.getItem(key);
let parsed = null;
try {
parsed = value ? JSON.parse(value) : null;
} catch (jsonError) {
stats.errors++;
}
if (!parsed || typeof parsed.timestamp !== 'number' || now - parsed.timestamp > cache.ttl) {
localStorage.removeItem(key);
stats.expired++;
continue;
}
survivors.push({ key, timestamp: parsed.timestamp });
} catch (itemError) {
stats.errors++;
try {
localStorage.removeItem(key);
} catch (e) { /* ignore */ }
}
}
if (cache.maxEntries && survivors.length > cache.maxEntries) {
survivors.sort((a, b) => a.timestamp - b.timestamp);
const excess = survivors.length - cache.maxEntries;
for (let i = 0; i < excess; i++) {
localStorage.removeItem(survivors[i].key);
stats.evicted++;
}
}
}
if (stats.checked > 0) {
console.log(`Cache cleanup: checked ${stats.checked}, expired ${stats.expired}, ` +
`evicted ${stats.evicted}, errors ${stats.errors}`);
}
}
/**
* Looks up the cache configuration whose prefix matches a key.
*
* @param {string} key - A cache key or prefix.
* @returns {Object|undefined}
*/
function cacheConfigFor(key) {
return cacheConfig.find(c => key.startsWith(c.prefix));
}
/**
* Reads a JSON cache entry from localStorage, returning the stored content
* if it exists and has not expired (TTL taken from the key's cacheConfig
* entry). Callers must validate the shape of the returned content.
*
* @param {string} key - The localStorage key.
* @returns {*|null} The cached content, or null if missing/expired/invalid.
*/
function readCache(key) {
const config = cacheConfigFor(key);
let raw;
try {
raw = config ? localStorage.getItem(key) : null;
} catch (e) {
return null;
}
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed.timestamp === 'number' &&
(Date.now() - parsed.timestamp < config.ttl) && parsed.content !== undefined) {
return parsed.content;
}
} catch (e) {
console.warn('Cache read error:', e);
}
return null;
}
/**
* Writes a JSON cache entry to localStorage with an embedded timestamp.
* Failures (e.g. quota exceeded) are logged and otherwise ignored; the
* cache is an optimization, never a dependency.
*
* @param {string} key - The localStorage key.
* @param {*} content - The data to cache.
*/
function writeCache(key, content) {
try {
localStorage.setItem(key, JSON.stringify({ timestamp: Date.now(), content }));
} catch (e) {
console.warn('Cache write error:', e);
}
}
/**
* ==========================================================================
* 7. Entry Point
* ==========================================================================
*/
analyzeView().catch(e => {
console.error('Citation Watchlist: unrecoverable error:', e);
loadWarnings.push('Citation Watchlist stopped early due to an error (see console)');
finishChangeListPass();
}).then(() => {
console.log('Citation Watchlist script finished executing');
// On enhanced RC pages, hide the summary gutter on a group's header row
// while the group is expanded (each nested row then shows its own).
document.querySelectorAll('table.mw-enhanced-rc').forEach(table => {
const checkbox = groupToggleCheckbox(table);
if (!checkbox) return;
checkbox.addEventListener('change', () => {
const topRow = table.querySelector('tr');
const gutter = topRow && topRow.querySelector('.cw-gutter');
if (!gutter) return;
const host = gutter.parentElement;
gutter.style.display = checkbox.checked ? 'none' : 'inline-flex';
if (host) host.style.paddingLeft = checkbox.checked ? '' : 'calc(2em + 0.5em)';
});
});
});
})();
8d7xizu9hjjbghhw2z9w4a24p6lgnzn
781274
781273
2026-10-28T00:19:02Z
Harej
7994
Added/removed/swapped with archive indicator badge
781274
javascript
text/javascript
/**
*
* Citation Watchlist
* https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist
*
*/
(function() {
'use strict';
/**
* ==========================================================================
* LOCALIZATION
* ==========================================================================
*/
/* Labeling tool in the user interface */
const toolName = 'Citation Watchlist';
const aboutLink = 'https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist';
/* Revision inspection popup */
const addedLabel = 'Added';
const removedLabel = 'Removed';
const viewDiffLabel = 'View Diff';
const viewRevisionLabel = 'View Revision';
const aboutLabel = 'About Citation Watchlist';
const archivedViaLabel = 'Archived copy hosted at';
const changedLabel = 'Changed';
const showAllLabel = 'Show all';
const archiveBadgeTitle = 'Archived copy';
const supplementTitle = 'Archive link added to a citation that was already present';
const changedArrow = ' \u2192 ';
/* Filter widget */
const showEditsLabel = 'Show edits:';
const loadingLabel = 'Loading...';
const allLabel = 'All';
const linkChangesLabel = 'Link Changes';
const linkChangesTooltip = 'Edits that add or remove links in articles';
const noMatchingEditsLabel = 'No matching edits';
/* Load-failure indicator */
const loadWarningGlyph = '⚠';
const loadWarningPrefix = 'Some Citation Watchlist lists could not be loaded:';
/**
* ==========================================================================
* DOMAIN LIST CONFIGURATION
* ==========================================================================
*
* Citation Watchlist requires the following wiki pages to function:
*
* 1. Public Suffix List
* - A local copy of the public suffix list, used for domain parsing.
* - Copy the contents of:
* https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist/Public_Suffix_List
* to a page on your own wiki.
* - Update the `publicSuffixList` variable below to reflect your page title.
*
* 2. List of Lists
* - A page linking to one or more domain list pages.
* - Format as a bullet list: "* [[Page Title]]" (space after asterisk).
* - Reference formatting example:
* https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist/Lists
* - Update the `listOfLists` variable below accordingly.
*
* 3+. Domain List Pages
* - One or more pages listing suspicious or noteworthy domains.
* - Each page must contain section headers that match the `indicators` config
* below (e.g., "==Warn==", "==Caution==").
* - Under each section, list domains in the format: "* example.com"
* - Do not use link formatting—just plain text.
* - Entries with paths ("example.com/opinion"), no dot ("gov"), or a
* leading "www." are not supported and are reported in the console.
*/
const publicSuffixList = 'Wikipedia:Citation_Watchlist/Public_Suffix_List';
const listOfLists = 'Wikipedia:Citation_Watchlist/Lists';
const enabledNamespaces = [-1, 0, 118];
const enabledSectionHeaders = ['References'];
/**
* Detection options.
* - UNWRAP_ARCHIVES: screen the original URL embedded in a web archive link
* (web.archive.org, archive.today, Google cache) rather than the archive
* host. The popup still links to the cited archive URL.
* - SHOW_LINT_IN_WIDGET: also show domain-list lint (ignored entries) in the
* filter widget's warning tooltip. Lint always goes to the console.
*/
const UNWRAP_ARCHIVES = true;
const SHOW_LINT_IN_WIDGET = false;
/**
* ==========================================================================
* INDICATOR CONFIGURATION
* ==========================================================================
*
* Defines metadata for domain indicators used in the watchlist UI.
* Each indicator is associated with a level of urgency and a unique symbol.
*
* Fields:
* - msg: Display label for the level (e.g., "Warning", "Caution").
* - emoji: Unicode character for the visual indicator (escaped as `\uXXXX`).
* - section: Must exactly match the section headers in the domain list pages.
* - priority: Higher values override lower ones for conflicting domain matches.
* Priority scale: 1 (lowest) to N (highest).
* - list: Defined as "new Set()" for all indicator types.
*
* If a domain appears in multiple lists, the one with the highest priority
* takes precedence.
*/
const indicators = {
warning: {
msg: 'Warning',
emoji: '❗',
section: '==Warn==',
explanation: 'This domain is classified as an unreliable source',
priority: 3,
dataAttr: 'data-cw-linkwarn',
list: new Set()
},
caution: {
msg: 'Caution',
emoji: '✋',
section: '==Caution==',
explanation: 'This source may be unreliable depending on context',
priority: 2,
dataAttr: 'data-cw-linkcaution',
list: new Set()
},
inspect: {
msg: 'Inspect',
emoji: '🔎',
section: '==Inspect==',
explanation: 'Take a closer look at this particular source',
priority: 1,
dataAttr: 'data-cw-linkinspect',
list: new Set()
},
added: {
msg: addedLabel,
emoji: '\u{1F517}',
section: null,
explanation: 'All added links',
priority: 0,
list: new Set()
},
removed: {
msg: removedLabel,
emoji: '\u{1F5D1}',
section: null,
explanation: 'All removed links',
priority: -1,
list: new Set()
}
};
/** Indicator types that come from the domain lists (priority > 0). */
const listedTypes = Object.keys(indicators).filter(t => indicators[t].priority > 0);
/**
* Kinds of link change between two revisions, in popup display order.
* Whether an entry counts toward the gutter icon and the list indicators is
* decided per entry (`counted`), not per kind: an archive link added to an
* already-cited source is shown under Added but not counted.
*/
const changeKinds = {
added: {
msg: addedLabel, emoji: indicators.added.emoji,
explanation: 'Links added to the article'
},
changed: {
msg: changedLabel, emoji: '\u{1F500}',
explanation: 'Links whose URL changed, including swaps to or from an archived copy'
},
removed: {
msg: removedLabel, emoji: indicators.removed.emoji,
explanation: indicators.removed.explanation
}
};
/** Gutter glyphs for edits with no newly cited source. */
const archiveGlyph = '\u{1F4E6}'; // only archive links were added
const changedGlyph = changeKinds.changed.emoji; // only URL changes
/** Popups with more domain lines than this start collapsed. */
const POPUP_COLLAPSE_AFTER = 8;
/**
* Citation Watchlist
*
* Highlights potentially questionable citations added in Wikipedia revisions
* based on community-built lists of domain names
*
*
* Documentation: https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist
*
* Author: James Hare
* License: GNU General Public License v3.0 (GPL-3.0)
*
* @version 2.5
* @since 2026-10-28
*/
/**
* IF YOU WANT TO MODIFY any part of the script below this point, please submit
* your edits to https://test.wikipedia.org/wiki/User:Harej/citation-watchlist.js
* so that your modifications can be tested.
*/
let publicSuffixSet = new Set();
const MAX_DOMAINS_PER_LIST = 50000;
const MIN_PSL_ENTRIES = 1000;
/**
* Revisions larger than this are not scanned. MediaWiki's own page-size
* limit is 2 MiB, so this only trips on pathological cases; it bounds the
* download and regex work done per row.
*/
const MAX_REVISION_CHARS = 2 * 1024 * 1024;
/**
* Revision fetching strategy. Rows are never fetched one at a time: revision
* IDs are grouped into small chunks (so the first chunk returns quickly and
* its rows render while the rest are still in flight), several chunks are
* kept in flight at once, and rows are annotated the moment their chunk
* arrives. Concurrency is capped because origin=* requests are anonymous and
* therefore per-IP rate limited (see release notes for 1.5/1.6).
*/
const CONTENT_CHUNK_SIZE = 8; // max revisions per content request
const CONTENT_CHUNK_BYTES = 400 * 1024; // soft byte budget per chunk when sizes are known
const CONTENT_CONCURRENCY = 4; // simultaneous content requests
const METADATA_CHUNK_SIZE = 50; // revids per ids|size request (API max for users)
const ASSUMED_REVISION_BYTES = 40 * 1024; // chunking estimate when size is unknown
const LIVE_RELOAD_DEBOUNCE_MS = 250; // settle time after the change list is replaced
/**
* Non-fatal problems encountered while loading configuration (missing list
* pages, API failures). Surfaced in the filter widget so list maintainers
* notice quickly, instead of the tool silently running with partial data.
*/
const loadWarnings = [];
/**
* Domain-list lint: entries that were ignored because they are not bare
* domains. Always logged; shown in the widget only if SHOW_LINT_IN_WIDGET.
*/
const listLint = [];
/**
* Unified cache configuration. Each entry defines a cache key prefix, its TTL,
* version number, an optional cap on the number of entries kept, and a
* human-readable description.
*
* - revURLs: per-revision list of extracted URLs. Revisions are immutable,
* so the TTL and cap exist only to bound storage.
* - sectionURLs: per-revision, per-section URL map from rendered article HTML.
* - domainList: parsed domain lists ({warning: [...], caution: [...], ...}).
* - publicSuffixListCache: validated PSL entries as an array.
* - citationWatchlistFetchDomainListPages_: list-of-lists page titles.
*/
const cacheConfig = [
{
prefix: 'revURLs:',
ttl: 30 * 24 * 60 * 60 * 1000,
version: 1,
maxEntries: 4000,
description: 'Revision URL cache'
},
{
prefix: 'sectionURLs:',
ttl: 30 * 24 * 60 * 60 * 1000,
version: 1,
maxEntries: 500,
description: 'Article section URL cache'
},
{
prefix: 'domainList:',
ttl: 6 * 60 * 60 * 1000,
version: 1,
description: 'Domain list cache'
},
{
prefix: 'publicSuffixListCache',
ttl: 24 * 60 * 60 * 1000,
version: 1,
description: 'Public suffix list cache'
},
{
prefix: 'citationWatchlistFetchDomainListPages_',
ttl: 4 * 60 * 60 * 1000,
version: 0,
description: 'Domain list pages cache'
}
];
/** Cache prefixes from earlier versions, removed on startup. */
const retiredCachePrefixes = ['revisionDiff:'];
/** How often (ms) the full localStorage expiry/eviction pass runs. */
const CACHE_PURGE_INTERVAL = 60 * 60 * 1000;
/**
* ==========================================================================
* 1. Primary Workflow
* ==========================================================================
*/
/** Resolves once the PSL and domain lists are loaded (true) or failed (false). */
let configReady = null;
/** Rows already handed to processRevisions(), so live reloads only pick up new ones. */
const seenRows = new WeakSet();
/**
* Loads the public suffix list and the domain lists, in parallel. Never
* throws; failures are recorded in loadWarnings.
*
* @returns {Promise<boolean>} True if the tool can screen domains.
*/
async function loadConfiguration() {
try {
const [psl, listPages] = await Promise.all([
fetchPublicSuffixList(),
fetchDomainListPages(listOfLists)
]);
publicSuffixSet = psl;
if (publicSuffixSet.size === 0) {
console.error('Public Suffix List loading failed');
loadWarnings.push('Public Suffix List could not be loaded');
return false;
}
console.log('Welcome to Citation Watchlist');
if (listPages && listPages.length > 0) {
await fetchAndOrganizeDomainLists(listPages);
} else {
loadWarnings.push(`Could not load list of lists (${listOfLists})`);
}
if (listLint.length > 0) {
console.warn(`Citation Watchlist: ${listLint.length} domain-list entries were ignored:\n` +
listLint.join('\n'));
}
return true;
} catch (e) {
console.error('Citation Watchlist: configuration load failed:', e);
loadWarnings.push('Configuration could not be loaded (see console)');
return false;
}
}
/**
* Function called by the entry point for Citation Watchlist.
* Determines if the current page should be analyzed, starts loading the
* configuration, collects the revisions behind each change-list row, and
* fetches and annotates them while the configuration loads.
*/
async function analyzeView() {
purgeCaches();
const ns = mw.config.get('wgNamespaceNumber');
const inspectEnabled = enabledNamespaces.includes(ns);
// Start the configuration load immediately; nothing below waits on it
// until the first row is ready to render.
if (inspectEnabled) {
configReady = loadConfiguration();
}
// Article surface: scan enabled sections for watchlisted domains
const action = mw.config.get('wgAction');
if (action === 'view' && ns >= 0 && inspectEnabled) {
if (await configReady) {
await analyzeArticleSections();
}
return;
}
// Pre-pad change log lines immediately on Special:RecentChanges for visual stability
if (ns === -1) {
document.querySelectorAll('li.mw-changeslist-line, .mw-changeslist-line')
.forEach(line => prePadElement(line));
flushGutterPadding();
}
if (!inspectEnabled) {
return;
}
createChangeListFilterWidget();
await annotateChangeList(document, true);
installLiveReloadObserver();
}
/**
* Collects and annotates every unseen change-list row under `root`.
*
* @param {Document|Element} root - Where to look for rows.
* @param {boolean} allowFirstRevisionFallback - On a page history with no
* diff links (single-revision page), analyze the page's first revision.
*/
async function annotateChangeList(root, allowFirstRevisionFallback) {
const revisions = [];
let noLinks = true;
// Prefer iterating per-change rows when available (JS-enhanced watchlist/RC);
// fall back to link containers on non-enhanced layouts.
const unseen = selector => Array.from(root.querySelectorAll(selector)).filter(el => !seenRows.has(el));
let entries = unseen('[data-mw-revid]:not(.mw-userlink), .mw-changeslist-line');
let prePadCallback = null;
if (entries.length > 0) {
entries.forEach(line => prePadElement(line));
flushGutterPadding();
} else {
entries = unseen('.mw-changeslist-links');
prePadCallback = (_el, link) => {
const target = link.parentNode.parentNode;
prePadElement(target);
flushGutterPadding();
return target;
};
}
for (const entry of entries) {
seenRows.add(entry);
const result = await extractEntry(entry, entry, prePadCallback);
if (result.hasLink) noLinks = false;
if (!result.skip && result.revision) revisions.push(result.revision);
}
// If no links were found, extract the first revision ID
if (allowFirstRevisionFallback && noLinks && entries.length > 0) {
try {
const firstID = await fetchFirstRevisionId(mw.config.get('wgTitle'));
if (firstID) {
revisions.push({
oldrevision: String(firstID),
element: entries[0],
viewHref: buildPermalinkUrl(firstID),
viewLabel: viewRevisionLabel
});
}
} catch (e) {
console.warn('Citation Watchlist: single-revision fallback failed:', e);
}
}
await processRevisions(revisions);
finishChangeListPass();
}
/**
* Extracts the revision behind one change-list entry, isolating failures so
* that a single malformed row does not abort the remaining rows.
*
* @param {Element} container - The DOM element to query for revision links.
* @param {Element} element - The DOM element to attach to the revision object.
* @param {Function|null} prePadCallback - See extractRevisionFromLinks.
* @returns {Promise<{revision: Object|null, hasLink: boolean, skip: boolean}>}
*/
async function extractEntry(container, element, prePadCallback) {
try {
return await extractRevisionFromLinks(container, element, prePadCallback);
} catch (e) {
console.warn('Citation Watchlist: skipping entry due to error:', e);
// Report hasLink=true so a row-level failure never triggers the
// single-revision fallback for the whole page.
return { revision: null, hasLink: true, skip: true };
}
}
/**
* Annotates a set of revisions with as little latency as possible:
*
* 1. Parent IDs (for diff=prev and history rows) and sizes are resolved in
* one metadata request, only when any row needs them.
* 2. Per-revision URL lists already in the cache are used directly.
* 3. Remaining revision IDs are deduplicated (history rows share revisions),
* chunked in DOM order, fetched with bounded concurrency, and each row is
* rendered as soon as every revision it depends on is available. Fetching
* overlaps the configuration load; only rendering waits for it.
*
* @param {Object[]} revisions - Revision objects from extractRevisionFromLinks.
*/
async function processRevisions(revisions) {
if (revisions.length === 0) return;
// --- 1. Resolve parents / sizes ---------------------------------------
const parentLookups = revisions.filter(r => r.needsParentOf).map(r => r.needsParentOf);
let metadata = {};
if (parentLookups.length > 0) {
metadata = await fetchRevisionMetadata(unique(parentLookups));
for (const revision of revisions) {
if (!revision.needsParentOf) continue;
const currentId = revision.needsParentOf;
const meta = metadata[currentId];
delete revision.needsParentOf;
if (meta && meta.parentid) {
revision.oldrevision = String(meta.parentid);
revision.newrevision = currentId;
if (revision.pageTitle && !revision.viewHref) {
revision.viewHref = buildDiffUrl(revision.pageTitle,
revision.oldrevision, revision.newrevision);
}
} else if (meta) {
// First revision of the page: everything in it was "added"
revision.oldrevision = currentId;
revision.newrevision = null;
revision.viewHref = buildPermalinkUrl(currentId);
revision.viewLabel = viewRevisionLabel;
} else {
markSkipped(revision, 'fetch-failed');
revision.oldrevision = null;
}
}
}
// --- 2. Per-revision URL lists from cache; queue the rest --------------
const urlLists = new Map(); // id -> string[] | null (null = unreadable)
const pending = []; // { revision, needs: string[] }
for (const revision of revisions) {
if (revision.skipped) continue;
if (!isRevisionId(revision.oldrevision) ||
(revision.newrevision && !isRevisionId(revision.newrevision))) {
markSkipped(revision, 'invalid');
continue;
}
const needs = [revision.oldrevision];
if (revision.newrevision) needs.push(revision.newrevision);
for (const id of needs) {
if (urlLists.has(id)) continue;
const cached = readCache(`revURLs:${location.hostname}:${id}`);
if (Array.isArray(cached)) urlLists.set(id, cached.filter(isValidHttpUrl));
}
pending.push({ revision, needs });
}
if (pending.length === 0) return;
// --- 3. Fetch missing content in chunks; render as data arrives --------
const neededIds = unique(pending.flatMap(p => p.needs)).filter(id => !urlLists.has(id));
const oversized = new Set();
for (const id of neededIds) {
const meta = metadata[id];
if (meta && typeof meta.size === 'number' && meta.size > MAX_REVISION_CHARS) {
oversized.add(id);
urlLists.set(id, null);
}
}
const chunks = chunkRevisionIds(neededIds.filter(id => !oversized.has(id)), metadata);
let canRender = false;
const settle = () => {
if (!canRender) return;
for (let i = pending.length - 1; i >= 0; i--) {
const item = pending[i];
if (!item.needs.every(id => urlLists.has(id))) continue;
pending.splice(i, 1);
if (item.needs.some(id => oversized.has(id))) {
markSkipped(item.revision, 'oversized');
continue;
}
const oldList = urlLists.get(item.revision.oldrevision);
const newList = item.revision.newrevision ? urlLists.get(item.revision.newrevision) : null;
if (oldList === null || (item.revision.newrevision && newList === null)) {
markSkipped(item.revision, 'fetch-failed');
continue;
}
renderRevision(item.revision,
classifyLinkChanges(oldList, newList, !!item.revision.newrevision));
}
flushGutterPadding();
};
const onText = (id, text) => {
if (text === null) {
urlLists.set(id, null);
return;
}
if (text.length > MAX_REVISION_CHARS) {
oversized.add(id);
urlLists.set(id, null);
return;
}
const urls = extractURLs(stripHTMLComments(text));
urlLists.set(id, urls);
writeCache(`revURLs:${location.hostname}:${id}`, urls);
};
// Dispatch content fetches now; they run while the lists are still loading.
const fetching = runPool(chunks.map(chunk => async () => {
await fetchChunkIsolating(chunk, onText);
settle();
}), CONTENT_CONCURRENCY);
if (!(await configReady)) {
await fetching;
return; // no lists: nothing to screen against
}
canRender = true;
settle(); // cache hits and anything that landed while the lists loaded
await fetching;
// Anything still pending had an id the API never returned
for (const item of pending) {
markSkipped(item.revision, 'fetch-failed');
}
}
/**
* Fetches a chunk of revisions; if the request fails outright, splits the
* chunk in half and retries each half, so one problematic revision only
* costs itself rather than its neighbours. Each fetch already carries
* safeFetch's retries, so this only runs in genuine failure cases.
*
* @param {string[]} chunk - Revision IDs.
* @param {Function} onText - Called with (id, text|null) for every id.
*/
async function fetchChunkIsolating(chunk, onText) {
const result = await fetchRevisionTexts(chunk);
if (result) {
for (const id of chunk) {
onText(id, result.has(id) ? result.get(id) : null);
}
return;
}
if (chunk.length === 1) {
onText(chunk[0], null);
return;
}
const mid = Math.ceil(chunk.length / 2);
await fetchChunkIsolating(chunk.slice(0, mid), onText);
await fetchChunkIsolating(chunk.slice(mid), onText);
}
/**
* Groups revision IDs into fetch chunks in order, bounded by count and, when
* sizes are known, by an approximate byte budget so one huge article does not
* delay its neighbours.
*
* @param {string[]} ids - Revision IDs in the order they should be fetched.
* @param {Object} metadata - Map of id -> {parentid, size} (may be sparse).
* @returns {string[][]}
*/
function chunkRevisionIds(ids, metadata) {
const chunks = [];
let current = [];
let currentBytes = 0;
for (const id of ids) {
const meta = metadata[id];
const bytes = (meta && typeof meta.size === 'number') ? meta.size : ASSUMED_REVISION_BYTES;
if (current.length > 0 &&
(current.length >= CONTENT_CHUNK_SIZE || currentBytes + bytes > CONTENT_CHUNK_BYTES)) {
chunks.push(current);
current = [];
currentBytes = 0;
}
current.push(id);
currentBytes += bytes;
}
if (current.length > 0) chunks.push(current);
return chunks;
}
/**
* Runs async task factories with at most `concurrency` in flight, in order.
*
* @param {Array<() => Promise<*>>} tasks
* @param {number} concurrency
* @returns {Promise<void>}
*/
async function runPool(tasks, concurrency) {
let next = 0;
const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, async () => {
while (next < tasks.length) {
const task = tasks[next++];
try {
await task();
} catch (e) {
console.warn('Citation Watchlist: chunk failed:', e);
}
}
});
await Promise.all(workers);
}
/**
* Returns the elements of an array with duplicates removed, first occurrence wins.
*
* @param {Array} arr
* @returns {Array}
*/
function unique(arr) {
return Array.from(new Set(arr));
}
/**
* Marks a revision's row as skipped with a reason, without annotating it.
*
* @param {Object} revision
* @param {string} reason - 'fetch-failed', 'oversized' or 'invalid'.
*/
function markSkipped(revision, reason) {
revision.skipped = reason;
try {
revisionTargetElement(revision).setAttribute('data-cw-skipped', reason);
} catch (e) {
/* ignore */
}
console.warn(`Citation Watchlist: skipped ${revision.oldrevision || '?'}` +
`${revision.newrevision ? ' -> ' + revision.newrevision : ''} (${reason})`);
}
/**
* Finds the row-level element that carries data attributes for a revision.
*
* @param {Object} revision
* @returns {Element}
*/
function revisionTargetElement(revision) {
const el = revision.element;
return el.closest('[data-mw-revid]:not(.mw-userlink)') ||
el.closest('li') || el.closest('.mw-changeslist-line, tr') || el;
}
/**
* Scans the article page for sections whose H2 headers match enabledSectionHeaders,
* then analyzes each matching section for watchlisted domains.
*/
async function analyzeArticleSections() {
const headings = document.querySelectorAll('.mw-heading2 h2, h2');
for (const h2 of headings) {
const headerText = h2.textContent.trim();
if (!enabledSectionHeaders.includes(headerText)) continue;
// Collect all content between this heading and the next heading
const headingContainer = h2.closest('.mw-heading') || h2;
let sibling = headingContainer.nextElementSibling;
const sectionElements = [];
while (sibling && !sibling.classList.contains('mw-heading')) {
sectionElements.push(sibling);
sibling = sibling.nextElementSibling;
}
try {
analyzeSection(headerText, sectionElements);
createArticleFilterWidget(headingContainer);
} catch (e) {
console.warn(`Citation Watchlist: failed to analyze section "${headerText}":`, e);
}
}
}
/**
* Analyzes a single article section: extracts URLs from each <li>, screens them
* against domain lists, and prepends indicator icons to matching list items.
*
* @param {string} sectionName - The section header text (e.g. "References").
* @param {Element[]} sectionElements - DOM elements between this heading and the next.
*/
function analyzeSection(sectionName, sectionElements) {
const revisionId = mw.config.get('wgRevisionId');
const cacheKey = `sectionURLs:${location.hostname}:${revisionId}:${sectionName}`;
// Map: array of { liId, urls }. Anything read back from localStorage is
// re-validated before it can reach an href.
let liURLMap = null;
const cachedMap = readCache(cacheKey);
if (Array.isArray(cachedMap)) {
liURLMap = cachedMap
.filter(entry => entry && typeof entry.liId === 'string' && Array.isArray(entry.urls))
.map(entry => ({ liId: entry.liId, urls: entry.urls.filter(isValidHttpUrl) }));
}
const citeNotes = sectionElements.flatMap(el => Array.from(el.querySelectorAll('li[id^="cite_note"]')));
// Build from DOM if not cached
if (!liURLMap) {
liURLMap = citeNotes.map(li => ({
liId: li.id,
urls: Array.from(li.querySelectorAll('a.external[href]'))
.map(a => a.getAttribute('href'))
.filter(href => /^https?:\/\//i.test(href))
}));
writeCache(cacheKey, liURLMap);
}
// Add padding to all list items so citation text is flush with icons
for (const li of citeNotes) li.style.paddingLeft = '1.5em';
// Screen each li's URLs against domain lists and prepend icons with popups
for (const entry of liURLMap) {
const li = document.getElementById(entry.liId);
if (!li) continue;
const matched = resolveURLs(entry.urls).filter(r => r.indicator);
if (matched.length === 0) continue;
const highest = highestPriority(matched.map(m => m.indicator));
const wrapper = makeElement('span', {
style: { position: 'relative', cursor: 'pointer', marginLeft: '-1.5em',
display: 'inline-block', width: '1.5em' }
});
wrapper.appendChild(makeElement('span', { text: indicators[highest].emoji }));
attachPopup(wrapper, listedTypes
.slice().sort((a, b) => indicators[b].priority - indicators[a].priority)
.map(type => ({ desc: indicators[type], items: matched.filter(m => m.indicator === type) })), {});
li.prepend(wrapper);
li.dataset.cwIndicator = highest;
}
}
/**
* Extracts revision information from diff/hist/prev/cur links within a container.
* Parent IDs are not fetched here; rows that need one set `needsParentOf` and
* processRevisions() resolves them in a single batched request.
*
* @param {Element} container - The DOM element to query for revision links.
* @param {Element} element - The DOM element to attach to the revision object.
* @param {Function|null} prePadCallback - Optional callback invoked with (element, link) before
* processing each branch. In fallback mode this derives the target element and pre-pads it.
* @returns {Promise<{revision: Object|null, hasLink: boolean, skip: boolean}>}
* - revision: the extracted revision object, or null if no usable link was found.
* - hasLink: true if any recognized link was present (even if skipped).
* - skip: true if the entry should be skipped (non-article after pre-padding).
*/
async function extractRevisionFromLinks(container, element, prePadCallback) {
const kinds = [
{ selector: 'a.mw-changeslist-diff', kind: 'diff' },
{ selector: 'a.mw-changeslist-history', kind: 'hist' },
{ selector: 'a.mw-history-histlinks-previous', kind: 'prev' },
{ selector: 'a.mw-history-histlinks-current', kind: 'cur' }
];
let link = null;
let kind = null;
for (const k of kinds) {
link = container.querySelector(k.selector);
if (link) {
kind = k.kind;
break;
}
}
if (!link) {
return { revision: null, hasLink: false, skip: false };
}
const params = new URL(link.href).searchParams;
const pageTitle = params.get('title');
const target = prePadCallback ? prePadCallback(element, link) : element;
// The diff and hist links appear on watchlist/RC/contribs rows that can be
// for any namespace; history prev/cur links are always for the page itself.
if ((kind === 'diff' || kind === 'hist') && isNotArticle(pageTitle)) {
return { revision: null, hasLink: true, skip: true };
}
const revision = {
oldrevision: null,
newrevision: null,
element: target,
viewHref: '',
viewLabel: viewDiffLabel,
pageTitle: pageTitle || undefined
};
switch (kind) {
case 'diff': {
// MediaWiki convention: diff = newer, oldid = older
const diffParam = params.get('diff');
const oldidParam = params.get('oldid');
revision.viewHref = link.href;
if (diffParam === 'prev' && oldidParam) {
// Contributions: oldid is the current revision; parent resolved later
revision.needsParentOf = oldidParam;
} else {
revision.oldrevision = oldidParam;
revision.newrevision = diffParam;
}
break;
}
case 'hist': {
// New page: analyze its first revision
const firstID = await fetchFirstRevisionId(pageTitle);
if (!firstID) {
return { revision: null, hasLink: true, skip: false };
}
revision.oldrevision = String(firstID);
revision.viewHref = buildPermalinkUrl(firstID);
revision.viewLabel = viewRevisionLabel;
break;
}
case 'prev': {
// viewHref is filled in once the parent is known
revision.needsParentOf = params.get('oldid') || null;
break;
}
case 'cur': {
revision.oldrevision = params.get('oldid');
revision.viewHref = buildPermalinkUrl(revision.oldrevision);
revision.viewLabel = viewRevisionLabel;
break;
}
}
return { revision, hasLink: true, skip: false };
}
/**
* Classifies the link changes between two revisions. Links are grouped by
* the page they ultimately point at (archive URLs are unwrapped). Each
* entry has a `kind` (added / changed / removed), `archive` (the cited URL
* is an archived copy), `withArchive` (an archive copy was added with it),
* `supplement` (archive link added to an already-cited source) and
* `counted` (affects the gutter icon and list indicators). A removed URL and
* an added URL on the same domain are paired as a `changed` entry.
* Multiset semantics: a URL that appears twice in the new revision and once
* in the old counts as one addition.
*
* @param {string[]} fromList - URLs in the older revision.
* @param {string[]|null} toList - URLs in the newer revision (ignored if !hasNew).
* @param {boolean} hasNew - False for a page's first revision, in which case
* every URL in fromList is considered added.
* @returns {Object[]} Change entries.
*/
function classifyLinkChanges(fromList, toList, hasNew) {
const tally = (urls) => {
const byTarget = new Map(); // target -> { direct: [], archive: [] }
for (const r of resolveURLs(urls)) {
if (!byTarget.has(r.target)) byTarget.set(r.target, { direct: [], archive: [] });
byTarget.get(r.target)[r.archiveHost ? 'archive' : 'direct'].push(r);
}
return byTarget;
};
const oldMap = hasNew ? tally(fromList) : new Map();
const newMap = tally(hasNew ? (toList || []) : fromList);
const added = []; // counted, non-supplement additions (candidates for pairing)
const removed = []; // counted removals (candidates for pairing)
const other = []; // everything else, already final
const entry = (link, kind, props) => ({
url: link.url, target: link.target, domain: link.domain, indicator: link.indicator,
archive: !!link.archiveHost, archiveHost: link.archiveHost,
withArchive: false, supplement: false, counted: true, kind, ...props
});
for (const target of new Set([...oldMap.keys(), ...newMap.keys()])) {
const o = oldMap.get(target) || { direct: [], archive: [] };
const n = newMap.get(target) || { direct: [], archive: [] };
let dAdded = Math.max(0, n.direct.length - o.direct.length);
let dRemoved = Math.max(0, o.direct.length - n.direct.length);
let aAdded = Math.max(0, n.archive.length - o.archive.length);
let aRemoved = Math.max(0, o.archive.length - n.archive.length);
// New direct citations; an archive added with one is folded into it
for (let i = 0; i < dAdded; i++) {
const withArchive = aAdded > 0;
if (withArchive) aAdded--;
added.push(entry(n.direct[i], 'added', { withArchive }));
}
// Archive links not accounted for above
if (aAdded > 0) {
if (n.direct.length > 0) {
// Source still cited directly: these are supplements, not new sources
for (let i = 0; i < aAdded; i++) {
other.push(entry(n.archive[i], 'added', { supplement: true, counted: false }));
}
} else if (dRemoved > 0) {
// Original gone, archive took its place: a swap
const swaps = Math.min(dRemoved, aAdded);
for (let i = 0; i < swaps; i++) {
other.push(entry(n.archive[i], 'changed', {
counted: false, oldUrl: o.direct[i].url, oldArchive: false
}));
}
dRemoved -= swaps;
for (let i = swaps; i < aAdded; i++) {
other.push(entry(n.archive[i], 'added', { supplement: true, counted: false }));
}
} else {
// Archive-only citation of a source not otherwise cited
for (let i = 0; i < aAdded; i++) added.push(entry(n.archive[i], 'added'));
}
}
// Direct links removed
if (dRemoved > 0) {
if (n.direct.length === 0 && n.archive.length > 0) {
// Original dropped but an already-present archive remains: a swap
for (let i = 0; i < dRemoved; i++) {
other.push(entry(n.archive[0], 'changed', {
counted: false, oldUrl: o.direct[i].url, oldArchive: false
}));
}
} else {
for (let i = 0; i < dRemoved; i++) removed.push(entry(o.direct[i], 'removed'));
}
}
// Archive links removed: only matters when nothing of the source remains
if (aRemoved > 0 && n.direct.length === 0 && n.archive.length === 0) {
if (dAdded > 0) {
// archive-only -> direct: un-archived
for (let i = 0; i < aRemoved && i < dAdded; i++) {
const a = added.pop(); // the direct entry pushed above
other.push(entry(n.direct[i], 'changed', {
counted: false, oldUrl: o.archive[i].url, oldArchive: true
}));
if (!a) break;
}
} else if (o.direct.length === 0) {
for (let i = 0; i < aRemoved; i++) removed.push(entry(o.archive[i], 'removed'));
}
}
}
// Pair same-domain removals with additions as URL changes
for (const r of removed) {
const idx = added.findIndex(a => a.domain === r.domain && !a.archive && !r.archive);
if (idx === -1) {
other.push(r);
continue;
}
const a = added.splice(idx, 1)[0];
other.push({ ...a, kind: 'changed', counted: false, oldUrl: r.url, oldArchive: false });
}
return [...added, ...other];
}
/**
* Screens a revision's link changes against the domain lists and annotates
* its row (gutter icons, popup, filter data attributes).
* Synchronous: all network work happens in processRevisions().
*
* @param {Object} revision - Object containing oldrevision, optional
* newrevision, and DOM element.
* @param {Object[]} changes - From classifyLinkChanges().
*/
function renderRevision(revision, changes) {
const targetElement = revisionTargetElement(revision);
targetElement.setAttribute('data-cw-processed', 'true');
const counted = changes.filter(c => c.counted);
const addedSources = counted.filter(c => c.kind === 'added');
const removedSources = counted.filter(c => c.kind === 'removed');
// Left gutter glyph: list indicator > new source > archiving only > URL changes only
let addChar = null;
const topType = highestPriority(addedSources.map(a => a.indicator));
if (topType) addChar = indicators[topType].emoji;
else if (addedSources.length > 0) addChar = indicators.added.emoji;
else if (changes.some(c => c.kind === 'added')) addChar = archiveGlyph;
else if (changes.some(c => c.kind === 'changed')) addChar = changedGlyph;
try {
if (revision.viewHref) revision.element.dataset.cwViewHref = revision.viewHref;
if (revision.viewLabel) revision.element.dataset.cwViewLabel = revision.viewLabel;
} catch (e) {
/* ignore */
}
renderTwoColumnEmojis(revision.element, addChar, removedSources.length > 0, changes);
// Data attributes drive the filter widget. Every entry is a link change;
// only counted entries carry list indicators.
const changedDomains = unique(changes.map(r => r.domain)).sort();
if (changedDomains.length > 0) {
targetElement.dataset.cwLinkchange = changedDomains.join('|');
}
for (const type of listedTypes) {
const domains = unique(counted.filter(r => r.indicator === type).map(r => r.domain)).sort();
if (domains.length > 0) {
targetElement.setAttribute(indicators[type].dataAttr, domains.join('|'));
}
}
}
/**
* ==========================================================================
* 2. User Interface
* ==========================================================================
*/
/**
* Pending hosts that need gutter padding applied. Collected during
* renderTwoColumnEmojis and flushed in a single batch via flushGutterPadding()
* so that all rows shift at the same time instead of one at a time.
*/
const pendingGutterPadding = [];
/**
* Applies the gutter padding to a single host element, reserving horizontal
* space for the two-column emoji gutter.
*
* @param {HTMLElement} host - The element to pad.
*/
function applyGutterPadding(host) {
const pad = 'calc(2em + 0.5em)';
try {
const cs = window.getComputedStyle(host);
const existing = parseFloat(cs.paddingLeft || '0');
const fontSizePx = parseFloat(cs.fontSize || '16');
const desiredPx = 2.5 * fontSizePx; // 2em + 0.5em
if (isNaN(existing) || existing < desiredPx - 0.5) {
host.style.paddingLeft = pad;
}
} catch (e) {
host.style.paddingLeft = pad;
}
}
/**
* Applies gutter padding to all collected hosts at once, then clears the
* queue. Call this after a batch of renderTwoColumnEmojis calls to avoid
* one-at-a-time layout shifts.
*/
function flushGutterPadding() {
for (const host of pendingGutterPadding) {
applyGutterPadding(host);
}
pendingGutterPadding.length = 0;
}
/**
* Finds the element that hosts the gutter for a change-list element:
* the list container or, for table rows, the content cell.
*
* @param {HTMLElement} element
* @returns {HTMLElement|null}
*/
function gutterHostFor(element) {
const container =
element.closest('[data-mw-revid]:not(.mw-userlink)') ||
element.closest('li') ||
element.closest('.mw-changeslist-line, tr') || element.parentNode || element;
if (!container) return null;
let host = container;
if (host.tagName && host.tagName.toUpperCase() === 'TR') {
// For nested enhanced RC rows, place the gutter in the content cell so
// icons align with singleton rows that use .mw-changeslist-line-inner.
const nestedContent = host.querySelector('td.mw-enhanced-rc-nested[data-target-page]');
host = nestedContent || host.querySelector('td, th') || host;
}
// Prefer inner change-line wrappers on Watchlist/RC if present
const innerHost = host.querySelector('.mw-changeslist-line-inner, .mw-changeslist-line-main');
return innerHost || host;
}
/**
* Renders exactly two fixed-width emoji columns before a revision entry:
* - Left column: highest-priority addition indicator
* (warning/caution/inspect/added), or spacer if none.
* - Right column: removal indicator if removals exist, otherwise spacer.
*
* This preserves consistent alignment even when no links are added/removed.
*
* @param {HTMLElement} element - The revision list DOM element.
* @param {string|null} addChar - Glyph for the left column, or null for a spacer.
* @param {boolean} hasRemovals - Whether to show the removal icon.
* @param {Object[]} changes - Resolved link changes from classifyLinkChanges().
*/
function renderTwoColumnEmojis(element, addChar, hasRemovals, changes = []) {
if (!element) return;
const host = gutterHostFor(element);
if (!host) return;
const cell = (char, className) => makeElement('span', {
className, text: char,
style: { display: 'inline-block', width: '1em', textAlign: 'center', userSelect: 'none' }
});
addChar = addChar || '\u00A0';
const remChar = hasRemovals ? indicators.removed.emoji : ' ';
// Propagate dataset for view link from source element to host
try {
if (element.dataset.cwViewHref) host.dataset.cwViewHref = element.dataset.cwViewHref;
if (element.dataset.cwViewLabel) host.dataset.cwViewLabel = element.dataset.cwViewLabel;
} catch (e) {
/* ignore */
}
// Ensure a single gutter exists
let gutter = host.querySelector('.cw-gutter');
if (gutter) {
gutter.querySelector('.cw-add').textContent = addChar;
gutter.querySelector('.cw-rem').textContent = remChar;
} else {
// Ensure the host can anchor absolutely positioned children
if (!host.style.position) host.style.position = 'relative';
// Create a left gutter that does not participate in text wrapping
gutter = makeElement('span', {
className: 'cw-gutter',
style: {
position: 'absolute', left: '0', top: '0', display: 'inline-flex', gap: '0.25em',
width: 'calc(2em + 0.25em)', alignItems: 'flex-start', userSelect: 'none'
}
});
gutter.appendChild(cell(addChar, 'cw-add'));
gutter.appendChild(cell(remChar, 'cw-rem'));
// Insert the gutter into the host; padding is deferred to flushGutterPadding()
host.insertBefore(gutter, host.firstChild);
pendingGutterPadding.push(host);
}
const sections = Object.keys(changeKinds).map(kind =>
({ desc: changeKinds[kind], items: changes.filter(c => c.kind === kind) }));
attachPopup(gutter, sections, {
viewHref: host.dataset.cwViewHref || '',
viewLabel: host.dataset.cwViewLabel || ''
});
host.setAttribute('data-processed-twocol', 'true');
}
/**
* Fills a popup with a close button, stacked sections (a header line with
* emoji, label and count, then one line per domain), and a footer. Popups
* with more than POPUP_COLLAPSE_AFTER domain lines start collapsed with a
* "Show all" toggle. Idempotent: safe to call again to refresh content.
*
* @param {HTMLElement} popupEl - The popup element.
* @param {Array<{desc: Object, items: Object[]}>} sections - `desc` has
* msg, emoji and explanation; `items` are change entries or resolved links.
* @param {Object} footerOptions - See createPopupFooter.
*/
function buildPopupContent(popupEl, sections, footerOptions) {
if (!popupEl.querySelector('.cw-close')) {
popupEl.appendChild(createCloseButton(popupEl));
}
for (const stale of popupEl.querySelectorAll('.cw-popup-content, .cw-popup-more, .cw-popup-footer')) {
stale.remove();
}
const contentWrap = makeElement('div', { className: 'cw-popup-content' });
let lineCount = 0;
for (const section of sections) {
if (!section.items || section.items.length === 0) continue;
const header = makeElement('div', {
className: 'cw-popup-header',
title: section.desc.explanation,
ariaLabel: `${section.desc.msg}: ${section.desc.explanation}`,
style: { fontWeight: 'bold', whiteSpace: 'nowrap', marginTop: lineCount ? '6px' : '0' }
});
header.appendChild(makeElement('span', { text: `${section.desc.emoji} ${section.desc.msg} ` }));
header.appendChild(makeElement('span', {
text: `(${section.items.length})`, style: { fontWeight: 'normal', opacity: '0.7' }
}));
contentWrap.appendChild(header);
for (const group of groupByDomain(section.items)) {
contentWrap.appendChild(makeDomainLine(group));
lineCount++;
}
}
popupEl.appendChild(contentWrap);
if (lineCount > POPUP_COLLAPSE_AFTER) {
Object.assign(contentWrap.style, { maxHeight: '11em', overflow: 'hidden' });
const more = makeElement('a', {
className: 'cw-popup-more', text: `${showAllLabel} ▾`,
attrs: { href: '#', role: 'button' }, style: { display: 'block', marginTop: '4px' }
});
more.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
Object.assign(contentWrap.style, { maxHeight: '', overflow: '' });
more.remove();
});
popupEl.appendChild(more);
}
popupEl.appendChild(createPopupFooter(footerOptions));
}
/**
* Groups resolved links by domain, preserving first-seen order.
*
* @param {Object[]} items
* @returns {Array<{domain: string, indicator: string|null, items: Object[]}>}
*/
function groupByDomain(items) {
const groups = new Map();
for (const item of items) {
if (!groups.has(item.domain)) {
groups.set(item.domain, { domain: item.domain, indicator: item.indicator, items: [] });
}
groups.get(item.domain).items.push(item);
}
return Array.from(groups.values());
}
/**
* Renders one domain line: an optional list-indicator emoji, the domain,
* and the links. A single link is the domain itself; several become
* numbered links in parentheses. Archived copies carry a badge, URL changes
* render as "old → new", and archive supplements are greyed.
*
* @param {{domain: string, indicator: string|null, items: Object[]}} group
* @returns {HTMLDivElement}
*/
function makeDomainLine(group) {
const line = makeElement('div', { style: { paddingLeft: '1.2em' } });
if (group.indicator) {
const ind = indicators[group.indicator];
line.appendChild(makeElement('span', {
className: 'cw-emoji', text: ind.emoji + ' ',
title: ind.explanation, ariaLabel: `${ind.msg}: ${ind.explanation}`
}));
}
const allArchive = group.items.every(i => i.archive || i.withArchive);
const allSupplement = group.items.every(i => i.supplement);
if (allSupplement) {
line.style.opacity = '0.65';
line.title = supplementTitle;
}
const isChanged = group.items[0].kind === 'changed';
if (group.items.length === 1 && !isChanged) {
line.appendChild(makeItemLink(group.items[0], group.domain));
if (allArchive) line.appendChild(archiveBadge());
return line;
}
// "domain (1, 2, 3)" or, for URL changes, "domain (old → new)" /
// "domain (1: old → new, 2: old → new)"
line.appendChild(makeElement('span', { text: group.domain, style: { whiteSpace: 'nowrap' } }));
if (allArchive && !isChanged) line.appendChild(archiveBadge());
line.appendChild(makeElement('span', { text: ' (' }));
group.items.forEach((item, idx) => {
const label = group.items.length > 1 ? String(idx + 1) : '';
line.appendChild(makeItemLink(item, label));
if (!isChanged && !allArchive && (item.archive || item.withArchive)) line.appendChild(archiveBadge());
if (idx < group.items.length - 1) line.appendChild(makeElement('span', { text: ', ' }));
});
line.appendChild(makeElement('span', { text: ')' }));
return line;
}
/**
* The small "archived copy" badge shown after a link or domain.
*
* @returns {HTMLSpanElement}
*/
function archiveBadge() {
return makeElement('span', {
className: 'cw-archive-badge', text: ' ' + archiveGlyph,
title: archiveBadgeTitle, style: { fontSize: '0.85em' }
});
}
/**
* Link(s) for one entry. A changed entry renders as "old → new" with both
* halves linked; otherwise a single link. Archived copies note their host
* in the tooltip.
*
* @param {Object} item - Change entry or resolved link.
* @param {string} text - Link text (the domain, or an index number).
* @returns {HTMLElement}
*/
function makeItemLink(item, text) {
const link = (url, label, isArchive, host) => {
const a = makeExternalLink(url, label);
if (isArchive) a.title = host ? `${archivedViaLabel} ${host}` : archiveBadgeTitle;
return a;
};
if (item.kind !== 'changed') {
return link(item.url, text, item.archive, item.archiveHost);
}
const span = makeElement('span', { style: { whiteSpace: 'nowrap' } });
if (text) span.appendChild(makeElement('span', { text: `${text}: ` }));
span.appendChild(link(item.oldUrl, 'old', item.oldArchive, null));
if (item.oldArchive) span.appendChild(archiveBadge());
span.appendChild(makeElement('span', { text: changedArrow }));
span.appendChild(link(item.url, 'new', item.archive, item.archiveHost));
if (item.archive) span.appendChild(archiveBadge());
return span;
}
/**
* Creates (or refreshes) the popup under a host element and wires its
* hover/click handlers. With no lines, any existing popup is hidden.
*
* @param {HTMLElement} host - The gutter or icon wrapper that owns the popup.
* @param {Array} sections - See buildPopupContent.
* @param {Object} footerOptions - See createPopupFooter.
*/
function attachPopup(host, sections, footerOptions) {
let popup = host.querySelector('.cw-popup');
const hasContent = sections.some(s => s.items && s.items.length > 0);
if (!hasContent) {
if (popup) popup.style.display = 'none';
host._cwEnablePopup = false;
return;
}
if (!popup) {
popup = createPopupElement();
host.appendChild(popup);
}
buildPopupContent(popup, sections, footerOptions);
host._cwEnablePopup = true;
installPopupHandlers(host);
}
/**
* Creates a link with the attributes every outbound link in this script
* uses. The single place an attacker-influenced URL becomes an href, so the
* URL check lives here: absolute http(s) only, plus same-origin paths when
* `allowPath` is set (used for diff/permalink footers).
*
* @param {string} url
* @param {string} text
* @param {boolean} [allowPath=false]
* @returns {HTMLAnchorElement}
*/
function makeExternalLink(url, text, allowPath = false) {
const a = makeElement('a', {
text, attrs: { rel: 'nofollow noopener noreferrer', target: '_blank' },
style: { whiteSpace: 'nowrap' }
});
if (isValidHttpUrl(url) || (allowPath && /^\/(?!\/)/.test(url))) a.href = url;
return a;
}
/**
* Creates a DOM element with class, text, title, inline styles and
* attributes in one call. Text is always set through textContent.
*
* @param {string} tag
* @param {Object} [opts] - {className, text, title, ariaLabel, style, attrs}
* @returns {HTMLElement}
*/
function makeElement(tag, opts = {}) {
const el = document.createElement(tag);
if (opts.className) el.className = opts.className;
if (opts.text !== undefined) el.textContent = opts.text;
if (opts.title) {
el.title = opts.title;
el.setAttribute('aria-label', opts.ariaLabel || opts.title);
}
if (opts.style) Object.assign(el.style, opts.style);
for (const [k, v] of Object.entries(opts.attrs || {})) el.setAttribute(k, v);
return el;
}
/**
* Creates a styled popup element for displaying domain information.
*
* @returns {HTMLDivElement} The configured popup element.
*/
function createPopupElement() {
return makeElement('div', {
className: 'mwe-popups cw-popup',
style: {
position: 'absolute', minWidth: '260px', maxWidth: '420px', zIndex: '1000',
display: 'none', background: 'var(--background-color, #fff)',
border: '1px solid rgba(0,0,0,.15)', boxShadow: '0 2px 8px rgba(0,0,0,.2)',
padding: '8px 26px 8px 10px', borderRadius: '4px', fontSize: '0.92em',
overflowWrap: 'anywhere', boxSizing: 'border-box'
}
});
}
/**
* Creates a styled close button for popups.
*
* @param {HTMLElement} popupEl - The popup element to hide when the button is clicked.
* @returns {HTMLButtonElement} The configured close button.
*/
function createCloseButton(popupEl) {
const closeBtn = makeElement('button', {
className: 'cw-close', text: '×', title: 'Close', attrs: { type: 'button' },
style: {
position: 'absolute', top: '4px', right: '6px', border: 'none',
background: 'transparent', cursor: 'pointer', fontSize: '14px',
lineHeight: '1', padding: '0', margin: '0'
}
});
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
popupEl.style.display = 'none';
});
return closeBtn;
}
/**
* Creates a popup footer element with an optional primary link and the
* "About Citation Watchlist" link.
*
* @param {Object} [options] - Optional configuration.
* @param {string} [options.viewHref] - URL for the primary link (e.g., diff or revision).
* @param {string} [options.viewLabel] - Label text for the primary link.
* @returns {HTMLDivElement} The configured footer element.
*/
function createPopupFooter(options) {
const footer = makeElement('div', {
className: 'cw-popup-footer', style: { marginTop: '10px', color: 'inherit' }
});
if (options && options.viewHref && options.viewLabel) {
const viewA = makeExternalLink(options.viewHref, options.viewLabel, true);
if (viewA.href) {
const strong = makeElement('strong');
strong.appendChild(viewA);
footer.appendChild(strong);
footer.appendChild(makeElement('span', { text: ' • ' }));
}
}
footer.appendChild(makeExternalLink(aboutLink, aboutLabel));
return footer;
}
/**
* Builds the shared filter widget container with radio buttons and an about link.
*
* @param {Object} config - Widget configuration.
* @param {string} config.containerId - The ID for the container element.
* @param {string} config.radioName - The name attribute for the radio button group.
* @param {Array<Object>} config.filters - Array of filter option objects
* ({id, label, checked?, tooltip?}).
* @param {Function} config.onFilter - Callback invoked with the selected filter ID.
* @param {string} [config.initialLabel] - Label text; defaults to showEditsLabel.
* @returns {HTMLDivElement} The configured filter widget container.
*/
function buildFilterWidgetContainer(config) {
const container = makeElement('div', {
attrs: { id: config.containerId },
style: {
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
border: '1px solid black', borderRadius: '25px', padding: '10px 20px',
margin: '10px 0', width: 'fit-content'
}
});
const leftSection = makeElement('div', {
style: { display: 'flex', alignItems: 'center', gap: '15px' }
});
leftSection.appendChild(makeElement('span', {
className: 'cw-filter-label', text: config.initialLabel || showEditsLabel,
style: { fontWeight: '500' }
}));
for (const filter of config.filters) {
const wrapper = makeElement('label', {
style: { display: 'flex', alignItems: 'center', gap: '5px', cursor: 'pointer' }
});
const radio = makeElement('input', {
attrs: { type: 'radio', name: config.radioName, value: filter.id }, style: { margin: '0' }
});
radio.checked = !!filter.checked;
radio.addEventListener('change', () => {
if (radio.checked) config.onFilter(filter.id);
});
const text = makeElement('span', { text: filter.label });
if (filter.tooltip) {
text.title = filter.tooltip;
Object.assign(text.style, { borderBottom: '1px dotted', cursor: 'help' });
}
wrapper.appendChild(radio);
wrapper.appendChild(text);
leftSection.appendChild(wrapper);
}
container.appendChild(leftSection);
const rightSection = makeElement('div', { style: { marginLeft: '4em' } });
const link = makeElement('a', {
text: toolName, attrs: { href: aboutLink },
style: { textDecoration: 'underline', color: '#0645ad' }
});
rightSection.appendChild(link);
container.appendChild(rightSection);
return container;
}
/**
* Builds the indicator filter list from the indicators config, sorted by
* descending priority and excluding non-positive priorities.
*
* @returns {Array<{id: string, label: string, tooltip: string}>}
*/
function buildIndicatorFilters() {
return listedTypes
.slice()
.sort((a, b) => indicators[b].priority - indicators[a].priority)
.map(id => ({
id,
label: `${indicators[id].emoji} ${indicators[id].msg}`,
tooltip: indicators[id].explanation
}));
}
/**
* Enables or greys out each radio in a filter widget according to whether
* any row currently matches it.
*
* @param {string} radioName - The radio group name.
* @param {Function} hasResults - (filterId) => boolean.
*/
function updateFilterRadios(radioName, hasResults) {
document.querySelectorAll(`input[name="${radioName}"]`).forEach(radio => {
const enabled = radio.value === 'all' || hasResults(radio.value);
const label = radio.closest('label');
radio.disabled = !enabled;
if (label) {
label.style.cursor = enabled ? 'pointer' : 'default';
label.style.color = enabled ? '' : 'gray';
}
});
}
/**
* Creates and inserts the change-list filter widget (watchlist, recent
* changes, history, contributions). Radios start disabled and are enabled by
* finishChangeListPass() once rows are annotated.
*
* @returns {HTMLElement|null} The created filter container or null if not created.
*/
function createChangeListFilterWidget() {
const ns = mw.config.get('wgNamespaceNumber');
const pageName = mw.config.get('wgPageName');
let target = document.querySelector('.mw-changeslist') ||
document.getElementById('mw-history-compare');
if (!target && ns === -1 && pageName.startsWith('Special:Contributions')) {
target = document.querySelector('.mw-pager-body');
}
if (!target) return null;
const container = buildFilterWidgetContainer({
containerId: 'cw-filter-widget',
radioName: 'cw-filter',
filters: [
{ id: 'all', label: allLabel, checked: true },
{ id: 'linkchange', label: linkChangesLabel, tooltip: linkChangesTooltip },
...buildIndicatorFilters()
],
onFilter: applyChangeListFilter,
initialLabel: loadingLabel
});
target.parentNode.insertBefore(container, target);
updateFilterRadios('cw-filter', () => false); // Disabled until annotation is complete
return container;
}
/**
* Creates and inserts a filter widget for the article surface, placed after
* the section heading. Excludes the "Link Changes" option.
*
* @param {Element} headingContainer - The heading element to insert the widget after.
*/
function createArticleFilterWidget(headingContainer) {
const container = buildFilterWidgetContainer({
containerId: 'cw-article-filter-widget',
radioName: 'cw-article-filter',
filters: [
{ id: 'all', label: allLabel, checked: true },
...buildIndicatorFilters()
],
onFilter: applyArticleFilter
});
headingContainer.after(container);
updateFilterRadios('cw-article-filter', (filterId) =>
!!document.querySelector(`li[id^="cite_note"][data-cw-indicator="${filterId}"]`));
renderLoadWarnings();
}
/**
* Called after each annotation pass on a change list: restores the widget
* label, renders any load warnings, and enables the radios that have matches.
*/
function finishChangeListPass() {
const filterLabel = document.querySelector('#cw-filter-widget .cw-filter-label');
if (filterLabel && filterLabel.firstChild &&
filterLabel.firstChild.nodeType === Node.TEXT_NODE) {
filterLabel.firstChild.textContent = showEditsLabel;
}
renderLoadWarnings();
const rows = document.querySelectorAll('[data-mw-revid]:not(.mw-userlink)');
updateFilterRadios('cw-filter', (filterId) =>
Array.from(rows).some(row => rowMatchesFilter(row, filterId)));
// Re-apply the active filter so newly loaded rows respect it
const active = document.querySelector('input[name="cw-filter"]:checked');
if (active && active.value !== 'all') applyChangeListFilter(active.value);
}
/**
* Whether a change-list row (or any nested revision inside it) matches a filter.
*
* @param {Element} row
* @param {string} filterId
* @returns {boolean}
*/
function rowMatchesFilter(row, filterId) {
if (elementMatchesFilter(row, filterId)) return true;
for (const nested of row.querySelectorAll('li[data-cw-processed="true"]')) {
if (elementMatchesFilter(nested, filterId)) return true;
}
return false;
}
/**
* Applies the selected filter to article section list items.
*
* @param {string} filterId - 'all' or an indicator type.
*/
function applyArticleFilter(filterId) {
document.querySelectorAll('li[id^="cite_note"]').forEach(li => {
li.style.display = (filterId === 'all' || li.dataset.cwIndicator === filterId) ? '' : 'none';
});
}
/**
* Applies the selected filter to the revision list.
*
* @param {string} filterId - 'all', 'linkchange', or an indicator type.
*/
let lastAppliedFilter = 'all';
function applyChangeListFilter(filterId) {
const isAll = filterId === 'all';
const revisions = document.querySelectorAll(
'[data-mw-revid]:not(.mw-userlink), [data-mw-logid]'
);
revisions.forEach(container => {
if (isAll) {
container.style.display = '';
return;
}
// Log entries (data-mw-logid) don't have revision-based attributes to
// match against filters — hide them when any specific filter is active.
const show = container.hasAttribute('data-mw-revid') && rowMatchesFilter(container, filterId);
container.style.display = show ? '' : 'none';
});
// Hide h4 date headers when all their rows are hidden
document.querySelectorAll('div.mw-changeslist h4').forEach(h4 => {
if (isAll) {
h4.style.display = '';
return;
}
const sibling = h4.nextElementSibling;
if (!sibling || sibling.tagName !== 'DIV') return;
const revItems = sibling.querySelectorAll('[data-mw-revid]');
if (revItems.length === 0) return;
h4.style.display = Array.from(revItems).every(el => el.style.display === 'none') ? 'none' : '';
});
const enhanced = document.querySelector('table.mw-enhanced-rc');
if (enhanced) {
// Hide the group header row in each tbody if all other rows are hidden
document.querySelectorAll('table.mw-enhanced-rc tbody').forEach(tbody => {
const rows = tbody.querySelectorAll('tr');
if (rows.length < 2) return;
rows[0].style.display = (!isAll && Array.from(rows).slice(1)
.every(tr => tr.style.display === 'none')) ? 'none' : '';
});
// Expand grouped sections while a specific filter is active, collapse on
// "all". Only on the transition, so switching between two filters leaves
// the expanded state alone.
const wasAll = lastAppliedFilter === 'all';
if (wasAll !== isAll) {
document.querySelectorAll('table.mw-enhanced-rc').forEach(table => {
const checkbox = groupToggleCheckbox(table);
if (checkbox && checkbox.checked === isAll) {
checkbox.checked = !isAll;
checkbox.dispatchEvent(new Event('change'));
}
});
}
}
showNoMatchingEdits(!isAll && Array.from(
document.querySelectorAll('[data-mw-revid]:not(.mw-userlink)')
).every(el => el.style.display === 'none'));
lastAppliedFilter = filterId;
}
/**
* Shows or removes the "No matching edits" message under the filter widget.
*
* @param {boolean} show
*/
function showNoMatchingEdits(show) {
const existing = document.getElementById('cw-no-matching-edits');
if (existing) existing.remove();
if (!show) return;
const msg = makeElement('p', {
text: noMatchingEditsLabel, attrs: { id: 'cw-no-matching-edits' },
style: { margin: '10px 0', fontStyle: 'italic' }
});
const widget = document.getElementById('cw-filter-widget');
if (widget && widget.nextSibling) {
widget.parentNode.insertBefore(msg, widget.nextSibling);
}
}
/**
* Returns the hidden checkbox that expands/collapses an enhanced-RC group.
*
* @param {Element} table - A table.mw-enhanced-rc element.
* @returns {HTMLInputElement|null}
*/
function groupToggleCheckbox(table) {
const arrow = table.querySelector('.mw-enhancedchanges-arrow');
const checkboxId = arrow && arrow.getAttribute('for');
return checkboxId ? document.getElementById(checkboxId) : null;
}
/**
* If any configuration pages failed to load, appends a warning glyph (with
* the details in its tooltip) to every filter-widget label on the page.
* Safe to call more than once.
*/
function renderLoadWarnings() {
const messages = SHOW_LINT_IN_WIDGET ? [...loadWarnings, ...listLint] : loadWarnings;
if (messages.length === 0) return;
document.querySelectorAll('.cw-filter-label').forEach(label => {
if (label.querySelector('.cw-load-warning')) return;
label.appendChild(makeElement('span', {
className: 'cw-load-warning', text: ' ' + loadWarningGlyph,
title: loadWarningPrefix + '\n' + messages.join('\n'), style: { cursor: 'help' }
}));
});
}
/**
* Whether the single document-level outside-click handler has been installed.
* One delegated listener replaces the former one-listener-per-gutter design.
*/
let outsideClickHandlerInstalled = false;
/**
* Installs a single document-level click handler that hides every open popup
* whose owning gutter does not contain the click target.
*/
function installOutsideClickHandler() {
if (outsideClickHandlerInstalled) return;
document.addEventListener('click', (ev) => {
document.querySelectorAll('.cw-popup').forEach(p => {
if (p.style.display === 'none' || p.style.display === '') return;
const owner = p.parentElement;
if (owner && !owner.contains(ev.target)) {
p.style.display = 'none';
}
});
});
outsideClickHandlerInstalled = true;
}
/**
* Installs hover, click, touch, and outside-click handlers on a gutter element
* to show/hide its popup.
*
* @param {HTMLElement} gutter - The gutter element containing a .cw-popup child.
*/
function installPopupHandlers(gutter) {
if (gutter._cwHandlersInstalled) return;
let hideTimer = null;
const positionPopup = () => {
const p = gutter.querySelector('.cw-popup');
if (!gutter._cwEnablePopup || !p) return;
p.style.display = 'block';
p.style.top = '1.4em';
p.style.left = '0px';
const rect = p.getBoundingClientRect();
if (rect.right > window.innerWidth - 10) {
const overflow = rect.right - (window.innerWidth - 10);
p.style.left = Math.max(0, (parseFloat(p.style.left) || 0) - overflow) + 'px';
}
};
const show = () => {
if (!gutter._cwEnablePopup) return;
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
positionPopup();
};
const scheduleHide = () => {
hideTimer = setTimeout(() => {
const p = gutter.querySelector('.cw-popup');
if (p) p.style.display = 'none';
}, 150);
};
gutter.addEventListener('mouseenter', show);
gutter.addEventListener('mouseleave', scheduleHide);
const pInit = gutter.querySelector('.cw-popup');
if (pInit) {
pInit.addEventListener('mouseenter', () => {
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
});
pInit.addEventListener('mouseleave', scheduleHide);
}
const toggle = (e) => {
if (!gutter._cwEnablePopup) return;
e.stopPropagation();
const p = gutter.querySelector('.cw-popup');
if (!p) return;
if (p.style.display === 'none' || p.style.display === '') {
show();
} else {
p.style.display = 'none';
}
};
gutter.addEventListener('click', toggle);
gutter.addEventListener('touchstart', toggle, {
passive: true
});
installOutsideClickHandler();
gutter._cwHandlersInstalled = true;
}
/**
* Watches for the change list being replaced by MediaWiki's live filters
* ("View new changes", filter edits on Special:Watchlist / RecentChanges)
* and annotates the new rows. The existing widget sits outside the replaced
* element and survives; its radios are refreshed after each pass.
*/
function installLiveReloadObserver() {
if (typeof MutationObserver === 'undefined') return;
let timer = null;
let running = false;
let rerun = false;
const run = async () => {
if (running) {
rerun = true;
return;
}
running = true;
try {
await annotateChangeList(document, false);
} catch (e) {
console.warn('Citation Watchlist: live reload pass failed:', e);
} finally {
running = false;
if (rerun) {
rerun = false;
run();
}
}
};
const observer = new MutationObserver(mutations => {
const relevant = mutations.some(m => Array.from(m.addedNodes).some(node =>
node.nodeType === Node.ELEMENT_NODE &&
(node.matches('[data-mw-revid], .mw-changeslist, .mw-changeslist-line') ||
node.querySelector('[data-mw-revid], .mw-changeslist-line'))
));
if (!relevant) return;
clearTimeout(timer);
timer = setTimeout(run, LIVE_RELOAD_DEBOUNCE_MS);
});
observer.observe(document.body, { childList: true, subtree: true });
}
const enabledNsStrings = new Set(enabledNamespaces.filter(n => n >= 0).map(String));
const namespaces = Object.entries(mw.config.get('wgFormattedNamespaces'))
.filter(([num, name]) => num !== '0' && !enabledNsStrings.has(num))
.map(([_, name]) => name.replace(/ /g, '_') + ':');
/**
* Pre-pads an element with empty emoji gutters to prevent layout shift.
*
* @param {Element} element - The DOM element to pre-pad.
*/
function prePadElement(element) {
try {
renderTwoColumnEmojis(element, null, false, []);
} catch (e) {
/* ignore */
}
}
/**
* ==========================================================================
* 3. Builders and Extractors
* ==========================================================================
*/
/**
* Constructs a URL for viewing a diff between two revisions.
*
* @param {string} title - The page title.
* @param {string} fromId - The older revision ID.
* @param {string} toId - The newer revision ID.
* @returns {string} The diff URL, or empty string on error.
*/
function buildDiffUrl(title, fromId, toId) {
try {
const script = mw.config.get('wgScript') || '/w/index.php';
return `${script}` +
`?title=${encodeURIComponent(title)}` +
`&diff=${encodeURIComponent(toId)}` +
`&oldid=${encodeURIComponent(fromId)}`;
} catch (e) {
return '';
}
}
/**
* Constructs a permalink URL for a specific revision.
*
* @param {string} revId - The revision ID.
* @returns {string} The permalink URL, or empty string on error.
*/
function buildPermalinkUrl(revId) {
try {
const articlePath = mw.config.get('wgArticlePath');
if (articlePath && articlePath.includes('$1')) {
return articlePath.replace('$1',
`Special:Permalink/${encodeURIComponent(revId)}`);
}
const script = mw.config.get('wgScript') || '/w/index.php';
return `${script}?oldid=${encodeURIComponent(revId)}`;
} catch (e) {
return '';
}
}
/**
* Strips all HTML comments from a string.
*
* @param {string} text - Input text potentially containing HTML comments.
* @returns {string} Text with all HTML comments removed.
*/
function stripHTMLComments(text) {
if (!text) return text;
return text.replace(/<!--[\s\S]*?-->/g, '');
}
/**
* Extracts all HTTP(S) URLs from a given wikitext string. The scheme is
* matched case-insensitively (MediaWiki links HTTPS:// just as it does
* https://), and protocol-relative links ("[//example.com ...]") are
* normalized to https://.
*
* @param {string} wikitext - Raw wikitext revision content.
* @returns {string[]} List of valid extracted URLs.
*/
function extractURLs(wikitext) {
const urls = [];
if (!wikitext) return urls;
const urlRegex = // eslint-disable-next-line max-len
/(?:https?:\/\/|(?<=^|[\s\[|=<>"'])\/\/)(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)/gi;
let match;
while ((match = urlRegex.exec(wikitext)) !== null) {
const raw = match[0].startsWith('//') ? 'https:' + match[0] : match[0];
try {
const url = new URL(raw);
if (/^https?:$/.test(url.protocol)) urls.push(url.href);
} catch (error) {
console.error(`Invalid URL rejected: ${match[0]}`, error.message);
}
}
return urls;
}
/**
* Web archive services whose URLs embed the original URL. Each entry's
* `extract` returns the embedded URL string or null.
*/
/** Restores "https:/example.com" (slash-collapsed by some archives) to "https://example.com". */
const fixScheme = s => s.replace(/^(https?:)\/+/i, '$1//');
const archiveUnwrappers = [
{
// https://web.archive.org/web/20200101000000/https://example.com/page
// (the embedded scheme is sometimes collapsed to "https:/")
test: h => h === 'web.archive.org' || h === 'wayback.archive.org',
extract: u => {
const m = u.pathname.match(/^\/web\/\d{1,14}[a-z_]*\/(https?:\/{1,2}.+)$/i);
return m ? fixScheme(m[1]) : null;
}
},
{
// https://archive.blah/2020.01.01-000000/https://example.com/page
test: h => /^archive\.(ph|today|is|li|vn|fo|md)$/i.test(h),
extract: u => {
const m = u.pathname.match(/^\/[^/]+\/(https?:\/{1,2}.+)$/i);
return m ? fixScheme(m[1]) : null;
}
},
{
// https://webcache.googleusercontent.com/search?q=cache:https://example.com/page
test: h => h === 'webcache.googleusercontent.com',
extract: u => {
const q = u.searchParams.get('q') || '';
const m = q.match(/^cache:(?:[A-Za-z0-9_-]+:)?(.+)$/);
if (!m) return null;
return /^https?:\/\//i.test(m[1]) ? m[1] : 'https://' + m[1];
}
}
];
/**
* Resolves a URL to the hostname that should be screened. If the URL is a
* web-archive capture of another page, the captured page's host is used.
*
* @param {string} url
* @returns {{hostname: string, target: string, archiveHost: string|null}|null}
* `target` is the unwrapped URL (or the URL itself). Null if unparsable.
*/
function screenedHost(url) {
let current;
try {
current = new URL(url);
} catch (e) {
return null;
}
let archiveHost = null;
for (let depth = 0; depth < 3 && UNWRAP_ARCHIVES; depth++) {
const unwrapper = archiveUnwrappers.find(a => a.test(current.hostname));
if (!unwrapper) break;
const inner = unwrapper.extract(current);
if (!inner) break;
try {
const innerUrl = new URL(inner);
if (!/^https?:$/.test(innerUrl.protocol)) break;
archiveHost = archiveHost || current.hostname;
current = innerUrl;
} catch (e) {
break;
}
}
return { hostname: current.hostname, target: current.href, archiveHost };
}
/**
* Resolves URLs to screened domains and list indicators.
*
* @param {string[]} urls
* @returns {Array<{url: string, target: string, domain: string, indicator: string|null, archiveHost: string|null}>}
*/
function resolveURLs(urls) {
const out = [];
for (const url of urls || []) {
const host = screenedHost(url);
if (!host) continue;
const domain = getRootDomain(host.hostname, publicSuffixSet);
if (!domain) continue;
out.push({
url, target: host.target, domain,
indicator: highestIndicatorFor(domain), archiveHost: host.archiveHost
});
}
return out;
}
/**
* Returns the highest-priority list indicator that contains a domain.
*
* @param {string} domain
* @returns {string|null}
*/
function highestIndicatorFor(domain) {
let chosen = null;
for (const type of listedTypes) {
if (indicators[type].list.has(domain) &&
(!chosen || indicators[type].priority > indicators[chosen].priority)) {
chosen = type;
}
}
return chosen;
}
/**
* Returns the highest-priority type among a list of indicator types.
*
* @param {Array<string|null>} types
* @returns {string|null}
*/
function highestPriority(types) {
let chosen = null;
for (const t of types) {
if (t && (!chosen || indicators[t].priority > indicators[chosen].priority)) chosen = t;
}
return chosen;
}
/**
* Extracts the top-level domain from a full hostname using a public suffix set.
*
* @param {string} hostname - Full hostname (e.g., sub.example.co.uk).
* @param {Set<string>} publicSuffixSet - Set of known public suffixes.
* @returns {string} The top-level domain (e.g., example.co.uk).
*/
function getRootDomain(hostname, publicSuffixSet) {
// Handle empty or invalid hostnames
if (!hostname || typeof hostname !== 'string') {
console.warn('Invalid hostname provided to getRootDomain:', hostname);
return '';
}
// Check if this is an IP address (simple check for IPv4)
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(hostname)) {
return hostname; // Return IP addresses as-is
}
const domainParts = hostname.split('.');
// Handle hostnames that are too short
if (domainParts.length < 2) {
return hostname; // Return as-is if it's a single-part hostname
}
// Try to find a matching public suffix
for (let i = 0; i < domainParts.length; i++) {
const candidate = domainParts.slice(i).join('.');
// Check both normal and exception (prefixed with !) entries
if (publicSuffixSet.has(candidate) || publicSuffixSet.has(`!${candidate}`)) {
// If we found a match, return the domain part plus the public suffix
// But make sure we don't go out of bounds
if (i > 0) {
return domainParts.slice(i - 1).join('.');
} else {
// Edge case: the entire hostname is a public suffix
return hostname;
}
}
}
// If no match in public suffix list, use a simple fallback:
// For hostnames with 2 parts, return the whole thing
// For hostnames with >2 parts, return the last 2 parts
if (domainParts.length === 2) {
return hostname;
} else {
return domainParts.slice(-2).join('.');
}
}
/**
* Extracts the first page object from MediaWiki API query response.
*
* @param {Object} data - MediaWiki API response.
* @returns {Object|null} The first page object or null if unavailable.
*/
function getFirstPage(data) {
if (!data || !data.query || !data.query.pages) return null;
return Object.values(data.query.pages)[0] || null;
}
/**
* Iterates every revision object in an API response, across all pages.
* (A revids= query spanning several articles returns several page objects.)
*
* @param {Object|null} data - MediaWiki API response.
* @returns {Object[]} Revision objects.
*/
function allRevisionsIn(data) {
if (!data || !data.query || !data.query.pages) return [];
const out = [];
for (const page of Object.values(data.query.pages)) {
if (page && Array.isArray(page.revisions)) out.push(...page.revisions);
}
return out;
}
/**
* Returns the main-slot wikitext of a revision object, or null if the
* revision is missing, hidden, or malformed.
*
* @param {Object} rev - Revision object from the API.
* @returns {string|null}
*/
function revisionText(rev) {
if (!rev || rev.texthidden !== undefined) return null;
const slot = rev.slots && rev.slots.main;
if (!slot || typeof slot['*'] !== 'string') return null;
return slot['*'];
}
/**
* Returns true if an API page object represents a page that cannot be
* read (missing, invalid, or without an accessible revision).
*
* @param {Object} page - Page object from the API.
* @returns {boolean}
*/
function isUnreadablePage(page) {
return !page || page.missing !== undefined || page.invalid !== undefined ||
!Array.isArray(page.revisions) || page.revisions.length === 0;
}
/**
* ==========================================================================
* 4. Validation
* ==========================================================================
*/
/**
* Validates that a string is a well-formed domain name.
*
* @param {string} str - The string to validate.
* @returns {boolean} True if the string is a valid domain.
*/
function isValidDomain(str) {
if (!str || str.length === 0 || str.length > 253) return false;
if (!/^[a-zA-Z0-9._-]+$/.test(str)) return false;
if (!str.includes('.')) return false;
if (/\.\./.test(str) || str.startsWith('.') || str.endsWith('.')) return false;
const labels = str.split('.');
if (labels.some(l => l.length === 0 || l.length > 63)) return false;
return true;
}
/**
* Validates that a string is a well-formed public suffix list entry.
* Allows wildcard entries (e.g., *.ck) and exception entries (e.g., !www.ck).
*
* @param {string} str - The string to validate.
* @returns {boolean} True if the string is a valid PSL entry.
*/
function isValidSuffixEntry(str) {
const cleaned = str.replace(/^[!*.]/, '');
return /^[a-zA-Z0-9._-]+$/.test(cleaned) && cleaned.length > 0 && cleaned.length <= 253;
}
/**
* Validates that a value is a string parseable as an absolute http(s) URL.
* Used to re-validate anything read back from localStorage before it is
* assigned to an href.
*
* @param {*} u - The value to validate.
* @returns {boolean} True if the value is an http(s) URL.
*/
function isValidHttpUrl(u) {
if (typeof u !== 'string') return false;
try {
return /^https?:$/i.test(new URL(u).protocol);
} catch (e) {
return false;
}
}
/**
* Validates that a value is a plausible revision ID (a positive integer
* string). Used before an ID is put into an API request or a cache key.
*
* @param {*} id - The value to validate.
* @returns {boolean}
*/
function isRevisionId(id) {
return typeof id === 'string' && /^[1-9][0-9]{0,15}$/.test(id);
}
/**
* Validates that a string is a plausible MediaWiki page title.
* Rejects titles with invalid characters, excessive length, or suspicious patterns.
*
* @param {string} title - The page title to validate.
* @returns {boolean} True if the title looks like a valid page link.
*/
function isValidPageTitle(title) {
if (typeof title !== 'string') return false;
if (title.length === 0 || title.length > 255) return false;
if (/[\[\]{}|#<>\n\r\t]/.test(title)) return false;
if (title.includes('..')) return false;
if (title.startsWith(':') || title.startsWith(' ') || title.endsWith(' ')) return false;
return true;
}
/**
* Determines whether a given page title does *not* belong to the main or draft namespaces.
* A missing title is treated as "not an article" so the row is skipped rather
* than crashing the run.
*
* @param {string} pageTitle - The title of the page.
* @returns {boolean} True if not an article namespace.
*/
function isNotArticle(pageTitle) {
if (typeof pageTitle !== 'string') return true;
return namespaces.some(namespace => pageTitle.startsWith(namespace));
}
/**
* Checks whether an element matches the specified filter.
*
* @param {Element} element - The DOM element to test.
* @param {string} filterId - The active filter ID.
* @returns {boolean} True if the element matches the filter.
*/
function elementMatchesFilter(element, filterId) {
if (filterId === 'linkchange') {
return element.hasAttribute('data-cw-linkchange') ||
listedTypes.some(type => element.hasAttribute(indicators[type].dataAttr));
}
if (indicators[filterId] && indicators[filterId].dataAttr) {
return element.hasAttribute(indicators[filterId].dataAttr);
}
return false;
}
/**
* ==========================================================================
* 5. Fetch
* ==========================================================================
*/
/**
* Fetches revisions by ID and returns them keyed by revision ID (as strings),
* across all pages in the response.
*
* @param {string[]} revIds
* @param {string[]} rvprop - Properties to request (always includes 'ids').
* @returns {Promise<Map<string, Object>|null>} Map of id -> revision object,
* or null if the request failed.
*/
async function fetchRevisionsById(revIds, rvprop) {
const data = await fetchRevisionData({
revids: revIds,
rvprop: unique(['ids', ...rvprop]),
rvslots: ['main']
});
if (!data) return null;
const out = new Map();
for (const rev of allRevisionsIn(data)) {
if (rev && rev.revid !== undefined) out.set(String(rev.revid), rev);
}
return out;
}
/**
* Fetches wikitext for a chunk of revision IDs in one request.
*
* @param {string[]} revIds - Revision IDs (at most CONTENT_CHUNK_SIZE).
* @returns {Promise<Map<string, string|null>|null>} Map of id -> wikitext
* (null for a revision that exists but is unreadable), or null if the
* request itself failed. IDs the API did not return are absent.
*/
async function fetchRevisionTexts(revIds) {
const revs = await fetchRevisionsById(revIds, ['content']);
if (!revs) return null;
return new Map(Array.from(revs, ([id, rev]) => [id, revisionText(rev)]));
}
/**
* Fetches parent IDs and sizes for a list of revision IDs, chunked at the
* API maximum and fetched concurrently.
*
* @param {string[]} revisionIds - Revision IDs.
* @returns {Promise<Object>} Map of id -> {parentid, size}; IDs that could
* not be resolved are absent.
*/
async function fetchRevisionMetadata(revisionIds) {
const result = {};
const chunks = [];
for (let i = 0; i < revisionIds.length; i += METADATA_CHUNK_SIZE) {
chunks.push(revisionIds.slice(i, i + METADATA_CHUNK_SIZE));
}
await runPool(chunks.map(chunk => async () => {
const revs = await fetchRevisionsById(chunk, ['size']);
for (const [id, rev] of revs || []) {
result[id] = {
parentid: rev.parentid || 0,
size: typeof rev.size === 'number' ? rev.size : undefined
};
}
}), CONTENT_CONCURRENCY);
return result;
}
/**
* Fetches a page's current wikitext (main slot) by title.
*
* @param {string} pageTitle
* @returns {Promise<string|null>} Null if the page is missing, unreadable, or the request failed.
*/
async function fetchPageWikitext(pageTitle) {
if (!isValidPageTitle(pageTitle)) return null;
const page = getFirstPage(await fetchRevisionData({
titles: [pageTitle],
rvprop: ['content'],
rvslots: ['main']
}));
return isUnreadablePage(page) ? null : revisionText(page.revisions[0]);
}
/**
* Fetches the ID of the first revision of a page.
*
* @param {string} pageTitle - The page title to look up.
* @returns {number|null} Revision ID or null.
*/
async function fetchFirstRevisionId(pageTitle) {
if (!isValidPageTitle(pageTitle)) return null;
const page = getFirstPage(await fetchRevisionData({
titles: [pageTitle],
rvlimit: 1,
rvdir: 'newer',
rvprop: ['ids'],
}));
if (isUnreadablePage(page)) return null;
return page.revisions[0].revid || null;
}
/**
* Fetches the list of subpages from the list of lists, parses wikilinks, caches
* the result, and returns list of subpage titles.
*
* @param {string} pageName - Title of the list-of-lists page.
* @returns {Promise<string[]>} List of subpage titles (empty on failure).
*/
async function fetchDomainListPages(pageName) {
const cacheKey = `citationWatchlistFetchDomainListPages_${pageName}`;
const cachedContent = readCache(cacheKey);
if (Array.isArray(cachedContent) && cachedContent.length > 0) {
console.log('Loaded list of lists from cache');
return cachedContent.filter(title => isValidPageTitle(title));
}
const content = await fetchPageWikitext(pageName);
if (content === null) {
console.error(`Citation Watchlist: list of lists "${pageName}" could not be loaded`);
return [];
}
const pageTitles = [];
for (const line of content.split('\n')) {
if (!line.startsWith('* [[')) continue;
const match = line.match(/\[\[([^\]]+)\]\]/); // first [[Page Title]]
if (!match) continue;
const title = match[1];
if (isValidPageTitle(title)) {
pageTitles.push(title);
} else {
console.warn(`Rejected invalid page title: "${title}"`);
}
}
if (pageTitles.length > 0) {
writeCache(cacheKey, pageTitles);
console.log('Loaded from API and stored in cache');
}
return pageTitles;
}
/**
* Parses one domain list page into per-indicator domain arrays.
* Entries that are not bare domains are collected as lint. A leading "www."
* is stripped (with a lint note), since root domains never carry it.
*
* @param {string} title - Page title, for lint messages.
* @param {string} content - Page wikitext.
* @returns {Object} {warning: string[], caution: string[], inspect: string[], lint: string[]}
*/
function parseDomainList(title, content) {
const parsed = { lint: [] };
for (const type of listedTypes) parsed[type] = [];
let current = null;
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (/^==/.test(trimmed)) {
// Any heading ends the current section; only recognized ones open a new one.
current = listedTypes.find(t => indicators[t].section === trimmed) || null;
continue;
}
if (!line.startsWith('*') || !current) continue;
let domain = line.substring(1).trim().toLowerCase();
if (!domain) continue;
if (/^www\./.test(domain) && isValidDomain(domain.slice(4))) {
parsed.lint.push(`${title}: "${domain}" has a www. prefix; matched as "${domain.slice(4)}"`);
domain = domain.slice(4);
}
if (isValidDomain(domain)) {
if (parsed[current].length < MAX_DOMAINS_PER_LIST) {
parsed[current].push(domain);
} else {
parsed.lint.push(`${title}: cap of ${MAX_DOMAINS_PER_LIST} reached in ${current}`);
}
} else {
parsed.lint.push(`${title}: "${domain}" is not a bare domain (paths, wildcards and ` +
`bare TLDs are not supported) and was ignored`);
}
}
return parsed;
}
/**
* Loads domain lists from a set of pages (parsed form cached per page) and
* populates the `Set` of each indicator. Pages that are missing or fail to
* load are skipped and recorded in loadWarnings; the remaining lists still load.
*
* @param {string[]} pageNames - List of page titles to fetch.
*/
async function fetchAndOrganizeDomainLists(pageNames) {
const parsedLists = [];
const pagesToFetch = [];
const isParsed = p => p && typeof p === 'object' && listedTypes.every(t => Array.isArray(p[t]));
for (const title of pageNames) {
const cached = readCache(`domainList:${location.hostname}:${title}`);
if (isParsed(cached)) {
parsedLists.push(cached);
} else {
pagesToFetch.push(title);
}
}
if (pagesToFetch.length > 0) {
const apiData = await fetchRevisionData({
titles: pagesToFetch,
rvprop: ['content'],
rvslots: ['main'],
});
const pages = (apiData && apiData.query && apiData.query.pages) || null;
if (!pages) {
loadWarnings.push(`API request for ${pagesToFetch.length} list page(s) failed`);
console.error('Citation Watchlist: domain list fetch failed for', pagesToFetch);
} else {
// The API normalizes titles (underscores -> spaces); cache under the
// requested spelling so the next load hits.
const requestedFor = {};
for (const t of pagesToFetch) requestedFor[t.replace(/_/g, ' ')] = t;
for (const page of Object.values(pages)) {
const title = page.title || '?';
if (isUnreadablePage(page)) {
loadWarnings.push(`List page "${title}" is missing or unreadable`);
console.warn(`Citation Watchlist: skipping list page "${title}"`);
continue;
}
const content = revisionText(page.revisions[0]);
if (content === null) {
loadWarnings.push(`List page "${title}" has no readable content`);
continue;
}
const parsed = parseDomainList(title, content);
parsedLists.push(parsed);
const cacheTitle = requestedFor[title] || title;
writeCache(`domainList:${location.hostname}:${cacheTitle}`, parsed);
}
}
}
for (const parsed of parsedLists) {
for (const type of listedTypes) {
for (const domain of parsed[type]) {
if (isValidDomain(domain)) indicators[type].list.add(domain);
}
}
if (Array.isArray(parsed.lint)) {
listLint.push(...parsed.lint.filter(l => typeof l === 'string'));
}
}
}
/**
* Fetches and caches the public suffix list used to identify top-level domains.
* The cache holds the validated entries, so cached loads skip parsing.
*
* @returns {Promise<Set<string>>} Set of public suffixes.
*/
async function fetchPublicSuffixList() {
const cacheKey = 'publicSuffixListCache';
const cached = readCache(cacheKey);
if (Array.isArray(cached) && cached.length >= MIN_PSL_ENTRIES) {
console.log('Using cached public suffix list');
return new Set(cached.filter(e => typeof e === 'string' && isValidSuffixEntry(e)));
}
const pslUrl = mw.config.get('wgArticlePath').replace('$1', publicSuffixList) +
'?action=raw';
console.log(`Raw page text request: ${pslUrl}`);
const content = await safeFetch(fetch, pslUrl).then(response => response ?
response.text() : null);
if (!content) return new Set();
const entries = [];
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith('//') && isValidSuffixEntry(trimmed)) {
entries.push(trimmed);
}
}
if (entries.length < MIN_PSL_ENTRIES) {
console.error(`Public suffix list suspiciously small (${entries.length} entries), rejecting`);
return new Set();
}
// Only cache a list that passed validation
writeCache(cacheKey, entries);
return new Set(entries);
}
/**
* Makes a MediaWiki API call to fetch revision metadata or content.
*
* @param {Object} data - Options for the API call, such as `revids`, `titles`, `rvprop`, etc.
* @returns {Promise<Object|null>} MediaWiki API query result, or null on failure.
*/
async function fetchRevisionData(data) {
const paramKeys = ['rvprop', 'revids', 'titles', 'rvslots'];
const params = {
action: 'query',
prop: 'revisions',
format: 'json',
rvdir: data.rvdir || 'older',
origin: '*'
};
if (data.rvlimit) {
params.rvlimit = data.rvlimit;
}
paramKeys.forEach(key => {
if (data[key]) {
params[key] = Array.isArray(data[key]) ? data[key].join('|') : data[key];
}
});
const api = new mw.Api();
return await safeFetch(api.get.bind(api), params);
}
/**
* Wraps any asynchronous fetch function with retry logic and error handling.
*
* @param {Function} fn - The function to execute (usually an API call).
* @param {...any} args - Arguments to pass to the fetch function.
* @returns {Promise<any|null>} Result of the fetch or null on failure.
*/
async function safeFetch(fn, ...args) {
const retries = 2;
const retryDelay = 1000;
const maxAttempts = retries + 1;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const result = await fn(...args);
if (result === null || result === undefined) {
throw new Error('Received null or undefined response');
}
if (typeof result.ok === 'boolean' && !result.ok) {
throw new Error(`HTTP error ${result.status}: ${result.statusText || 'Unknown error'}`);
}
return result;
} catch (error) {
if (attempt < maxAttempts) {
console.warn(
`Error during ${fn.name || 'fetch operation'} (attempt ${attempt}/${maxAttempts}):`,
error.message || error);
await new Promise(resolve => setTimeout(resolve, retryDelay));
} else {
console.error(
`All ${maxAttempts} attempts failed for ${fn.name || 'fetch operation'}:`,
error.message || error);
}
}
}
return null;
}
/**
* ==========================================================================
* 6. Cache
* ==========================================================================
*/
/**
* Returns every localStorage key with the given prefix. Snapshotting the keys
* first means callers can remove entries without the index shifting under them.
*
* @param {string} prefix - Key prefix to match.
* @returns {string[]}
*/
function cacheKeysWithPrefix(prefix) {
const keys = [];
try {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith(prefix)) keys.push(key);
}
} catch (e) {
/* storage unavailable */
}
return keys;
}
/**
* Removes every localStorage key with the given prefix.
*
* @param {string} prefix
*/
function removeKeysWithPrefix(prefix) {
for (const key of cacheKeysWithPrefix(prefix)) localStorage.removeItem(key);
}
/**
* Runs all cache maintenance: retired prefixes, version invalidation, and
* (at most once per CACHE_PURGE_INTERVAL) expiry and eviction.
*/
function purgeCaches() {
try {
purgeStaleVersionedCaches();
const lastKey = 'citationWatchlist_lastPurge';
const last = parseInt(localStorage.getItem(lastKey), 10);
if (isNaN(last) || Date.now() - last > CACHE_PURGE_INTERVAL) {
purgeExpiredCache();
localStorage.setItem(lastKey, Date.now().toString());
}
} catch (e) {
console.warn('Citation Watchlist: cache maintenance failed:', e);
}
}
/**
* Removes caches from retired prefixes and purges cached entries whose stored
* version is older than the current version. Each cache type tracks its own
* version independently via a localStorage key.
*/
function purgeStaleVersionedCaches() {
for (const prefix of retiredCachePrefixes) {
removeKeysWithPrefix(prefix);
localStorage.removeItem(`citationWatchlist_cacheVersion:${prefix}`);
}
for (const cache of cacheConfig) {
const versionKey = `citationWatchlist_cacheVersion:${cache.prefix}`;
const storedVersion = parseInt(localStorage.getItem(versionKey), 10);
const effectiveVersion = isNaN(storedVersion) ? 0 : storedVersion;
if (effectiveVersion < cache.version) {
removeKeysWithPrefix(cache.prefix);
try {
localStorage.setItem(versionKey, cache.version.toString());
} catch (e) {
console.warn('Cache version write error:', e);
}
console.log(`Cache invalidated for "${cache.prefix}": version ${effectiveVersion} → ${cache.version}`);
}
}
}
/**
* Removes expired localStorage cache entries and enforces per-cache entry
* caps by evicting the oldest entries.
*/
function purgeExpiredCache() {
const now = Date.now();
const stats = { checked: 0, expired: 0, evicted: 0, errors: 0 };
for (const cache of cacheConfig) {
// Snapshot keys first so removals do not shift the iteration index
const survivors = []; // { key, timestamp }
for (const key of cacheKeysWithPrefix(cache.prefix)) {
stats.checked++;
try {
const value = localStorage.getItem(key);
let parsed = null;
try {
parsed = value ? JSON.parse(value) : null;
} catch (jsonError) {
stats.errors++;
}
if (!parsed || typeof parsed.timestamp !== 'number' || now - parsed.timestamp > cache.ttl) {
localStorage.removeItem(key);
stats.expired++;
continue;
}
survivors.push({ key, timestamp: parsed.timestamp });
} catch (itemError) {
stats.errors++;
try {
localStorage.removeItem(key);
} catch (e) { /* ignore */ }
}
}
if (cache.maxEntries && survivors.length > cache.maxEntries) {
survivors.sort((a, b) => a.timestamp - b.timestamp);
const excess = survivors.length - cache.maxEntries;
for (let i = 0; i < excess; i++) {
localStorage.removeItem(survivors[i].key);
stats.evicted++;
}
}
}
if (stats.checked > 0) {
console.log(`Cache cleanup: checked ${stats.checked}, expired ${stats.expired}, ` +
`evicted ${stats.evicted}, errors ${stats.errors}`);
}
}
/**
* Looks up the cache configuration whose prefix matches a key.
*
* @param {string} key - A cache key or prefix.
* @returns {Object|undefined}
*/
function cacheConfigFor(key) {
return cacheConfig.find(c => key.startsWith(c.prefix));
}
/**
* Reads a JSON cache entry from localStorage, returning the stored content
* if it exists and has not expired (TTL taken from the key's cacheConfig
* entry). Callers must validate the shape of the returned content.
*
* @param {string} key - The localStorage key.
* @returns {*|null} The cached content, or null if missing/expired/invalid.
*/
function readCache(key) {
const config = cacheConfigFor(key);
let raw;
try {
raw = config ? localStorage.getItem(key) : null;
} catch (e) {
return null;
}
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed.timestamp === 'number' &&
(Date.now() - parsed.timestamp < config.ttl) && parsed.content !== undefined) {
return parsed.content;
}
} catch (e) {
console.warn('Cache read error:', e);
}
return null;
}
/**
* Writes a JSON cache entry to localStorage with an embedded timestamp.
* Failures (e.g. quota exceeded) are logged and otherwise ignored; the
* cache is an optimization, never a dependency.
*
* @param {string} key - The localStorage key.
* @param {*} content - The data to cache.
*/
function writeCache(key, content) {
try {
localStorage.setItem(key, JSON.stringify({ timestamp: Date.now(), content }));
} catch (e) {
console.warn('Cache write error:', e);
}
}
/**
* ==========================================================================
* 7. Entry Point
* ==========================================================================
*/
analyzeView().catch(e => {
console.error('Citation Watchlist: unrecoverable error:', e);
loadWarnings.push('Citation Watchlist stopped early due to an error (see console)');
finishChangeListPass();
}).then(() => {
console.log('Citation Watchlist script finished executing');
// On enhanced RC pages, hide the summary gutter on a group's header row
// while the group is expanded (each nested row then shows its own).
document.querySelectorAll('table.mw-enhanced-rc').forEach(table => {
const checkbox = groupToggleCheckbox(table);
if (!checkbox) return;
checkbox.addEventListener('change', () => {
const topRow = table.querySelector('tr');
const gutter = topRow && topRow.querySelector('.cw-gutter');
if (!gutter) return;
const host = gutter.parentElement;
gutter.style.display = checkbox.checked ? 'none' : 'inline-flex';
if (host) host.style.paddingLeft = checkbox.checked ? '' : 'calc(2em + 0.5em)';
});
});
});
})();
9fjctqw8fhd3zosu8ed67ruwt6bzpk2
781277
781274
2026-10-28T00:30:06Z
Harej
7994
Setting apart indicator icons from domain lists
781277
javascript
text/javascript
/**
*
* Citation Watchlist
* https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist
*
*/
(function() {
'use strict';
/**
* ==========================================================================
* LOCALIZATION
* ==========================================================================
*/
/* Labeling tool in the user interface */
const toolName = 'Citation Watchlist';
const aboutLink = 'https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist';
/* Revision inspection popup */
const addedLabel = 'Added';
const removedLabel = 'Removed';
const viewDiffLabel = 'View Diff';
const viewRevisionLabel = 'View Revision';
const aboutLabel = 'About Citation Watchlist';
const archivedViaLabel = 'Archived copy hosted at';
const changedLabel = 'Changed';
const showAllLabel = 'Show all';
const archiveBadgeTitle = 'Archived copy';
const supplementTitle = 'Archive link added to a citation that was already present';
const changedArrow = ' \u2192 ';
/* Filter widget */
const showEditsLabel = 'Show edits:';
const loadingLabel = 'Loading...';
const allLabel = 'All';
const linkChangesLabel = 'Link Changes';
const linkChangesTooltip = 'Edits that add or remove links in articles';
const noMatchingEditsLabel = 'No matching edits';
/* Load-failure indicator */
const loadWarningGlyph = '⚠';
const loadWarningPrefix = 'Some Citation Watchlist lists could not be loaded:';
/**
* ==========================================================================
* DOMAIN LIST CONFIGURATION
* ==========================================================================
*
* Citation Watchlist requires the following wiki pages to function:
*
* 1. Public Suffix List
* - A local copy of the public suffix list, used for domain parsing.
* - Copy the contents of:
* https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist/Public_Suffix_List
* to a page on your own wiki.
* - Update the `publicSuffixList` variable below to reflect your page title.
*
* 2. List of Lists
* - A page linking to one or more domain list pages.
* - Format as a bullet list: "* [[Page Title]]" (space after asterisk).
* - Reference formatting example:
* https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist/Lists
* - Update the `listOfLists` variable below accordingly.
*
* 3+. Domain List Pages
* - One or more pages listing suspicious or noteworthy domains.
* - Each page must contain section headers that match the `indicators` config
* below (e.g., "==Warn==", "==Caution==").
* - Under each section, list domains in the format: "* example.com"
* - Do not use link formatting—just plain text.
* - Entries with paths ("example.com/opinion"), no dot ("gov"), or a
* leading "www." are not supported and are reported in the console.
*/
const publicSuffixList = 'Wikipedia:Citation_Watchlist/Public_Suffix_List';
const listOfLists = 'Wikipedia:Citation_Watchlist/Lists';
const enabledNamespaces = [-1, 0, 118];
const enabledSectionHeaders = ['References'];
/**
* Detection options.
* - UNWRAP_ARCHIVES: screen the original URL embedded in a web archive link
* (web.archive.org, archive.today, Google cache) rather than the archive
* host. The popup still links to the cited archive URL.
* - SHOW_LINT_IN_WIDGET: also show domain-list lint (ignored entries) in the
* filter widget's warning tooltip. Lint always goes to the console.
*/
const UNWRAP_ARCHIVES = true;
const SHOW_LINT_IN_WIDGET = false;
/**
* ==========================================================================
* INDICATOR CONFIGURATION
* ==========================================================================
*
* Defines metadata for domain indicators used in the watchlist UI.
* Each indicator is associated with a level of urgency and a unique symbol.
*
* Fields:
* - msg: Display label for the level (e.g., "Warning", "Caution").
* - emoji: Unicode character for the visual indicator (escaped as `\uXXXX`).
* - section: Must exactly match the section headers in the domain list pages.
* - priority: Higher values override lower ones for conflicting domain matches.
* Priority scale: 1 (lowest) to N (highest).
* - list: Defined as "new Set()" for all indicator types.
*
* If a domain appears in multiple lists, the one with the highest priority
* takes precedence.
*/
const indicators = {
warning: {
msg: 'Warning',
emoji: '❗',
section: '==Warn==',
explanation: 'This domain is classified as an unreliable source',
priority: 3,
dataAttr: 'data-cw-linkwarn',
list: new Set()
},
caution: {
msg: 'Caution',
emoji: '✋',
section: '==Caution==',
explanation: 'This source may be unreliable depending on context',
priority: 2,
dataAttr: 'data-cw-linkcaution',
list: new Set()
},
inspect: {
msg: 'Inspect',
emoji: '🔎',
section: '==Inspect==',
explanation: 'Take a closer look at this particular source',
priority: 1,
dataAttr: 'data-cw-linkinspect',
list: new Set()
},
added: {
msg: addedLabel,
emoji: '\u{1F517}',
section: null,
explanation: 'All added links',
priority: 0,
list: new Set()
},
removed: {
msg: removedLabel,
emoji: '\u{1F5D1}',
section: null,
explanation: 'All removed links',
priority: -1,
list: new Set()
}
};
/** Indicator types that come from the domain lists (priority > 0). */
const listedTypes = Object.keys(indicators).filter(t => indicators[t].priority > 0);
/**
* Kinds of link change between two revisions, in popup display order.
* Whether an entry counts toward the gutter icon and the list indicators is
* decided per entry (`counted`), not per kind: an archive link added to an
* already-cited source is shown under Added but not counted.
*/
const changeKinds = {
added: {
msg: addedLabel, emoji: indicators.added.emoji,
explanation: 'Links added to the article'
},
changed: {
msg: changedLabel, emoji: '\u{1F500}',
explanation: 'Links whose URL changed, including swaps to or from an archived copy'
},
removed: {
msg: removedLabel, emoji: indicators.removed.emoji,
explanation: indicators.removed.explanation
}
};
/** Gutter glyphs for edits with no newly cited source. */
const archiveGlyph = '\u{1F4E6}'; // only archive links were added
const changedGlyph = changeKinds.changed.emoji; // only URL changes
/** Popups with more domain lines than this start collapsed. */
const POPUP_COLLAPSE_AFTER = 8;
/**
* Citation Watchlist
*
* Highlights potentially questionable citations added in Wikipedia revisions
* based on community-built lists of domain names
*
*
* Documentation: https://en.wikipedia.org/wiki/Wikipedia:Citation_Watchlist
*
* Author: James Hare
* License: GNU General Public License v3.0 (GPL-3.0)
*
* @version 2.5
* @since 2026-10-28
*/
/**
* IF YOU WANT TO MODIFY any part of the script below this point, please submit
* your edits to https://test.wikipedia.org/wiki/User:Harej/citation-watchlist.js
* so that your modifications can be tested.
*/
let publicSuffixSet = new Set();
const MAX_DOMAINS_PER_LIST = 50000;
const MIN_PSL_ENTRIES = 1000;
/**
* Revisions larger than this are not scanned. MediaWiki's own page-size
* limit is 2 MiB, so this only trips on pathological cases; it bounds the
* download and regex work done per row.
*/
const MAX_REVISION_CHARS = 2 * 1024 * 1024;
/**
* Revision fetching strategy. Rows are never fetched one at a time: revision
* IDs are grouped into small chunks (so the first chunk returns quickly and
* its rows render while the rest are still in flight), several chunks are
* kept in flight at once, and rows are annotated the moment their chunk
* arrives. Concurrency is capped because origin=* requests are anonymous and
* therefore per-IP rate limited (see release notes for 1.5/1.6).
*/
const CONTENT_CHUNK_SIZE = 8; // max revisions per content request
const CONTENT_CHUNK_BYTES = 400 * 1024; // soft byte budget per chunk when sizes are known
const CONTENT_CONCURRENCY = 4; // simultaneous content requests
const METADATA_CHUNK_SIZE = 50; // revids per ids|size request (API max for users)
const ASSUMED_REVISION_BYTES = 40 * 1024; // chunking estimate when size is unknown
const LIVE_RELOAD_DEBOUNCE_MS = 250; // settle time after the change list is replaced
/**
* Non-fatal problems encountered while loading configuration (missing list
* pages, API failures). Surfaced in the filter widget so list maintainers
* notice quickly, instead of the tool silently running with partial data.
*/
const loadWarnings = [];
/**
* Domain-list lint: entries that were ignored because they are not bare
* domains. Always logged; shown in the widget only if SHOW_LINT_IN_WIDGET.
*/
const listLint = [];
/**
* Unified cache configuration. Each entry defines a cache key prefix, its TTL,
* version number, an optional cap on the number of entries kept, and a
* human-readable description.
*
* - revURLs: per-revision list of extracted URLs. Revisions are immutable,
* so the TTL and cap exist only to bound storage.
* - sectionURLs: per-revision, per-section URL map from rendered article HTML.
* - domainList: parsed domain lists ({warning: [...], caution: [...], ...}).
* - publicSuffixListCache: validated PSL entries as an array.
* - citationWatchlistFetchDomainListPages_: list-of-lists page titles.
*/
const cacheConfig = [
{
prefix: 'revURLs:',
ttl: 30 * 24 * 60 * 60 * 1000,
version: 1,
maxEntries: 4000,
description: 'Revision URL cache'
},
{
prefix: 'sectionURLs:',
ttl: 30 * 24 * 60 * 60 * 1000,
version: 1,
maxEntries: 500,
description: 'Article section URL cache'
},
{
prefix: 'domainList:',
ttl: 6 * 60 * 60 * 1000,
version: 1,
description: 'Domain list cache'
},
{
prefix: 'publicSuffixListCache',
ttl: 24 * 60 * 60 * 1000,
version: 1,
description: 'Public suffix list cache'
},
{
prefix: 'citationWatchlistFetchDomainListPages_',
ttl: 4 * 60 * 60 * 1000,
version: 0,
description: 'Domain list pages cache'
}
];
/** Cache prefixes from earlier versions, removed on startup. */
const retiredCachePrefixes = ['revisionDiff:'];
/** How often (ms) the full localStorage expiry/eviction pass runs. */
const CACHE_PURGE_INTERVAL = 60 * 60 * 1000;
/**
* ==========================================================================
* 1. Primary Workflow
* ==========================================================================
*/
/** Resolves once the PSL and domain lists are loaded (true) or failed (false). */
let configReady = null;
/** Rows already handed to processRevisions(), so live reloads only pick up new ones. */
const seenRows = new WeakSet();
/**
* Loads the public suffix list and the domain lists, in parallel. Never
* throws; failures are recorded in loadWarnings.
*
* @returns {Promise<boolean>} True if the tool can screen domains.
*/
async function loadConfiguration() {
try {
const [psl, listPages] = await Promise.all([
fetchPublicSuffixList(),
fetchDomainListPages(listOfLists)
]);
publicSuffixSet = psl;
if (publicSuffixSet.size === 0) {
console.error('Public Suffix List loading failed');
loadWarnings.push('Public Suffix List could not be loaded');
return false;
}
console.log('Welcome to Citation Watchlist');
if (listPages && listPages.length > 0) {
await fetchAndOrganizeDomainLists(listPages);
} else {
loadWarnings.push(`Could not load list of lists (${listOfLists})`);
}
if (listLint.length > 0) {
console.warn(`Citation Watchlist: ${listLint.length} domain-list entries were ignored:\n` +
listLint.join('\n'));
}
return true;
} catch (e) {
console.error('Citation Watchlist: configuration load failed:', e);
loadWarnings.push('Configuration could not be loaded (see console)');
return false;
}
}
/**
* Function called by the entry point for Citation Watchlist.
* Determines if the current page should be analyzed, starts loading the
* configuration, collects the revisions behind each change-list row, and
* fetches and annotates them while the configuration loads.
*/
async function analyzeView() {
purgeCaches();
const ns = mw.config.get('wgNamespaceNumber');
const inspectEnabled = enabledNamespaces.includes(ns);
// Start the configuration load immediately; nothing below waits on it
// until the first row is ready to render.
if (inspectEnabled) {
configReady = loadConfiguration();
}
// Article surface: scan enabled sections for watchlisted domains
const action = mw.config.get('wgAction');
if (action === 'view' && ns >= 0 && inspectEnabled) {
if (await configReady) {
await analyzeArticleSections();
}
return;
}
// Pre-pad change log lines immediately on Special:RecentChanges for visual stability
if (ns === -1) {
document.querySelectorAll('li.mw-changeslist-line, .mw-changeslist-line')
.forEach(line => prePadElement(line));
flushGutterPadding();
}
if (!inspectEnabled) {
return;
}
createChangeListFilterWidget();
await annotateChangeList(document, true);
installLiveReloadObserver();
}
/**
* Collects and annotates every unseen change-list row under `root`.
*
* @param {Document|Element} root - Where to look for rows.
* @param {boolean} allowFirstRevisionFallback - On a page history with no
* diff links (single-revision page), analyze the page's first revision.
*/
async function annotateChangeList(root, allowFirstRevisionFallback) {
const revisions = [];
let noLinks = true;
// Prefer iterating per-change rows when available (JS-enhanced watchlist/RC);
// fall back to link containers on non-enhanced layouts.
const unseen = selector => Array.from(root.querySelectorAll(selector)).filter(el => !seenRows.has(el));
let entries = unseen('[data-mw-revid]:not(.mw-userlink), .mw-changeslist-line');
let prePadCallback = null;
if (entries.length > 0) {
entries.forEach(line => prePadElement(line));
flushGutterPadding();
} else {
entries = unseen('.mw-changeslist-links');
prePadCallback = (_el, link) => {
const target = link.parentNode.parentNode;
prePadElement(target);
flushGutterPadding();
return target;
};
}
for (const entry of entries) {
seenRows.add(entry);
const result = await extractEntry(entry, entry, prePadCallback);
if (result.hasLink) noLinks = false;
if (!result.skip && result.revision) revisions.push(result.revision);
}
// If no links were found, extract the first revision ID
if (allowFirstRevisionFallback && noLinks && entries.length > 0) {
try {
const firstID = await fetchFirstRevisionId(mw.config.get('wgTitle'));
if (firstID) {
revisions.push({
oldrevision: String(firstID),
element: entries[0],
viewHref: buildPermalinkUrl(firstID),
viewLabel: viewRevisionLabel
});
}
} catch (e) {
console.warn('Citation Watchlist: single-revision fallback failed:', e);
}
}
await processRevisions(revisions);
finishChangeListPass();
}
/**
* Extracts the revision behind one change-list entry, isolating failures so
* that a single malformed row does not abort the remaining rows.
*
* @param {Element} container - The DOM element to query for revision links.
* @param {Element} element - The DOM element to attach to the revision object.
* @param {Function|null} prePadCallback - See extractRevisionFromLinks.
* @returns {Promise<{revision: Object|null, hasLink: boolean, skip: boolean}>}
*/
async function extractEntry(container, element, prePadCallback) {
try {
return await extractRevisionFromLinks(container, element, prePadCallback);
} catch (e) {
console.warn('Citation Watchlist: skipping entry due to error:', e);
// Report hasLink=true so a row-level failure never triggers the
// single-revision fallback for the whole page.
return { revision: null, hasLink: true, skip: true };
}
}
/**
* Annotates a set of revisions with as little latency as possible:
*
* 1. Parent IDs (for diff=prev and history rows) and sizes are resolved in
* one metadata request, only when any row needs them.
* 2. Per-revision URL lists already in the cache are used directly.
* 3. Remaining revision IDs are deduplicated (history rows share revisions),
* chunked in DOM order, fetched with bounded concurrency, and each row is
* rendered as soon as every revision it depends on is available. Fetching
* overlaps the configuration load; only rendering waits for it.
*
* @param {Object[]} revisions - Revision objects from extractRevisionFromLinks.
*/
async function processRevisions(revisions) {
if (revisions.length === 0) return;
// --- 1. Resolve parents / sizes ---------------------------------------
const parentLookups = revisions.filter(r => r.needsParentOf).map(r => r.needsParentOf);
let metadata = {};
if (parentLookups.length > 0) {
metadata = await fetchRevisionMetadata(unique(parentLookups));
for (const revision of revisions) {
if (!revision.needsParentOf) continue;
const currentId = revision.needsParentOf;
const meta = metadata[currentId];
delete revision.needsParentOf;
if (meta && meta.parentid) {
revision.oldrevision = String(meta.parentid);
revision.newrevision = currentId;
if (revision.pageTitle && !revision.viewHref) {
revision.viewHref = buildDiffUrl(revision.pageTitle,
revision.oldrevision, revision.newrevision);
}
} else if (meta) {
// First revision of the page: everything in it was "added"
revision.oldrevision = currentId;
revision.newrevision = null;
revision.viewHref = buildPermalinkUrl(currentId);
revision.viewLabel = viewRevisionLabel;
} else {
markSkipped(revision, 'fetch-failed');
revision.oldrevision = null;
}
}
}
// --- 2. Per-revision URL lists from cache; queue the rest --------------
const urlLists = new Map(); // id -> string[] | null (null = unreadable)
const pending = []; // { revision, needs: string[] }
for (const revision of revisions) {
if (revision.skipped) continue;
if (!isRevisionId(revision.oldrevision) ||
(revision.newrevision && !isRevisionId(revision.newrevision))) {
markSkipped(revision, 'invalid');
continue;
}
const needs = [revision.oldrevision];
if (revision.newrevision) needs.push(revision.newrevision);
for (const id of needs) {
if (urlLists.has(id)) continue;
const cached = readCache(`revURLs:${location.hostname}:${id}`);
if (Array.isArray(cached)) urlLists.set(id, cached.filter(isValidHttpUrl));
}
pending.push({ revision, needs });
}
if (pending.length === 0) return;
// --- 3. Fetch missing content in chunks; render as data arrives --------
const neededIds = unique(pending.flatMap(p => p.needs)).filter(id => !urlLists.has(id));
const oversized = new Set();
for (const id of neededIds) {
const meta = metadata[id];
if (meta && typeof meta.size === 'number' && meta.size > MAX_REVISION_CHARS) {
oversized.add(id);
urlLists.set(id, null);
}
}
const chunks = chunkRevisionIds(neededIds.filter(id => !oversized.has(id)), metadata);
let canRender = false;
const settle = () => {
if (!canRender) return;
for (let i = pending.length - 1; i >= 0; i--) {
const item = pending[i];
if (!item.needs.every(id => urlLists.has(id))) continue;
pending.splice(i, 1);
if (item.needs.some(id => oversized.has(id))) {
markSkipped(item.revision, 'oversized');
continue;
}
const oldList = urlLists.get(item.revision.oldrevision);
const newList = item.revision.newrevision ? urlLists.get(item.revision.newrevision) : null;
if (oldList === null || (item.revision.newrevision && newList === null)) {
markSkipped(item.revision, 'fetch-failed');
continue;
}
renderRevision(item.revision,
classifyLinkChanges(oldList, newList, !!item.revision.newrevision));
}
flushGutterPadding();
};
const onText = (id, text) => {
if (text === null) {
urlLists.set(id, null);
return;
}
if (text.length > MAX_REVISION_CHARS) {
oversized.add(id);
urlLists.set(id, null);
return;
}
const urls = extractURLs(stripHTMLComments(text));
urlLists.set(id, urls);
writeCache(`revURLs:${location.hostname}:${id}`, urls);
};
// Dispatch content fetches now; they run while the lists are still loading.
const fetching = runPool(chunks.map(chunk => async () => {
await fetchChunkIsolating(chunk, onText);
settle();
}), CONTENT_CONCURRENCY);
if (!(await configReady)) {
await fetching;
return; // no lists: nothing to screen against
}
canRender = true;
settle(); // cache hits and anything that landed while the lists loaded
await fetching;
// Anything still pending had an id the API never returned
for (const item of pending) {
markSkipped(item.revision, 'fetch-failed');
}
}
/**
* Fetches a chunk of revisions; if the request fails outright, splits the
* chunk in half and retries each half, so one problematic revision only
* costs itself rather than its neighbours. Each fetch already carries
* safeFetch's retries, so this only runs in genuine failure cases.
*
* @param {string[]} chunk - Revision IDs.
* @param {Function} onText - Called with (id, text|null) for every id.
*/
async function fetchChunkIsolating(chunk, onText) {
const result = await fetchRevisionTexts(chunk);
if (result) {
for (const id of chunk) {
onText(id, result.has(id) ? result.get(id) : null);
}
return;
}
if (chunk.length === 1) {
onText(chunk[0], null);
return;
}
const mid = Math.ceil(chunk.length / 2);
await fetchChunkIsolating(chunk.slice(0, mid), onText);
await fetchChunkIsolating(chunk.slice(mid), onText);
}
/**
* Groups revision IDs into fetch chunks in order, bounded by count and, when
* sizes are known, by an approximate byte budget so one huge article does not
* delay its neighbours.
*
* @param {string[]} ids - Revision IDs in the order they should be fetched.
* @param {Object} metadata - Map of id -> {parentid, size} (may be sparse).
* @returns {string[][]}
*/
function chunkRevisionIds(ids, metadata) {
const chunks = [];
let current = [];
let currentBytes = 0;
for (const id of ids) {
const meta = metadata[id];
const bytes = (meta && typeof meta.size === 'number') ? meta.size : ASSUMED_REVISION_BYTES;
if (current.length > 0 &&
(current.length >= CONTENT_CHUNK_SIZE || currentBytes + bytes > CONTENT_CHUNK_BYTES)) {
chunks.push(current);
current = [];
currentBytes = 0;
}
current.push(id);
currentBytes += bytes;
}
if (current.length > 0) chunks.push(current);
return chunks;
}
/**
* Runs async task factories with at most `concurrency` in flight, in order.
*
* @param {Array<() => Promise<*>>} tasks
* @param {number} concurrency
* @returns {Promise<void>}
*/
async function runPool(tasks, concurrency) {
let next = 0;
const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, async () => {
while (next < tasks.length) {
const task = tasks[next++];
try {
await task();
} catch (e) {
console.warn('Citation Watchlist: chunk failed:', e);
}
}
});
await Promise.all(workers);
}
/**
* Returns the elements of an array with duplicates removed, first occurrence wins.
*
* @param {Array} arr
* @returns {Array}
*/
function unique(arr) {
return Array.from(new Set(arr));
}
/**
* Marks a revision's row as skipped with a reason, without annotating it.
*
* @param {Object} revision
* @param {string} reason - 'fetch-failed', 'oversized' or 'invalid'.
*/
function markSkipped(revision, reason) {
revision.skipped = reason;
try {
revisionTargetElement(revision).setAttribute('data-cw-skipped', reason);
} catch (e) {
/* ignore */
}
console.warn(`Citation Watchlist: skipped ${revision.oldrevision || '?'}` +
`${revision.newrevision ? ' -> ' + revision.newrevision : ''} (${reason})`);
}
/**
* Finds the row-level element that carries data attributes for a revision.
*
* @param {Object} revision
* @returns {Element}
*/
function revisionTargetElement(revision) {
const el = revision.element;
return el.closest('[data-mw-revid]:not(.mw-userlink)') ||
el.closest('li') || el.closest('.mw-changeslist-line, tr') || el;
}
/**
* Scans the article page for sections whose H2 headers match enabledSectionHeaders,
* then analyzes each matching section for watchlisted domains.
*/
async function analyzeArticleSections() {
const headings = document.querySelectorAll('.mw-heading2 h2, h2');
for (const h2 of headings) {
const headerText = h2.textContent.trim();
if (!enabledSectionHeaders.includes(headerText)) continue;
// Collect all content between this heading and the next heading
const headingContainer = h2.closest('.mw-heading') || h2;
let sibling = headingContainer.nextElementSibling;
const sectionElements = [];
while (sibling && !sibling.classList.contains('mw-heading')) {
sectionElements.push(sibling);
sibling = sibling.nextElementSibling;
}
try {
analyzeSection(headerText, sectionElements);
createArticleFilterWidget(headingContainer);
} catch (e) {
console.warn(`Citation Watchlist: failed to analyze section "${headerText}":`, e);
}
}
}
/**
* Analyzes a single article section: extracts URLs from each <li>, screens them
* against domain lists, and prepends indicator icons to matching list items.
*
* @param {string} sectionName - The section header text (e.g. "References").
* @param {Element[]} sectionElements - DOM elements between this heading and the next.
*/
function analyzeSection(sectionName, sectionElements) {
const revisionId = mw.config.get('wgRevisionId');
const cacheKey = `sectionURLs:${location.hostname}:${revisionId}:${sectionName}`;
// Map: array of { liId, urls }. Anything read back from localStorage is
// re-validated before it can reach an href.
let liURLMap = null;
const cachedMap = readCache(cacheKey);
if (Array.isArray(cachedMap)) {
liURLMap = cachedMap
.filter(entry => entry && typeof entry.liId === 'string' && Array.isArray(entry.urls))
.map(entry => ({ liId: entry.liId, urls: entry.urls.filter(isValidHttpUrl) }));
}
const citeNotes = sectionElements.flatMap(el => Array.from(el.querySelectorAll('li[id^="cite_note"]')));
// Build from DOM if not cached
if (!liURLMap) {
liURLMap = citeNotes.map(li => ({
liId: li.id,
urls: Array.from(li.querySelectorAll('a.external[href]'))
.map(a => a.getAttribute('href'))
.filter(href => /^https?:\/\//i.test(href))
}));
writeCache(cacheKey, liURLMap);
}
// Add padding to all list items so citation text is flush with icons
for (const li of citeNotes) li.style.paddingLeft = '1.5em';
// Screen each li's URLs against domain lists and prepend icons with popups
for (const entry of liURLMap) {
const li = document.getElementById(entry.liId);
if (!li) continue;
const matched = resolveURLs(entry.urls).filter(r => r.indicator);
if (matched.length === 0) continue;
const highest = highestPriority(matched.map(m => m.indicator));
const wrapper = makeElement('span', {
style: { position: 'relative', cursor: 'pointer', marginLeft: '-1.5em',
display: 'inline-block', width: '1.5em' }
});
wrapper.appendChild(makeElement('span', { text: indicators[highest].emoji }));
attachPopup(wrapper, listedTypes
.slice().sort((a, b) => indicators[b].priority - indicators[a].priority)
.map(type => ({ desc: indicators[type], items: matched.filter(m => m.indicator === type) })), {});
li.prepend(wrapper);
li.dataset.cwIndicator = highest;
}
}
/**
* Extracts revision information from diff/hist/prev/cur links within a container.
* Parent IDs are not fetched here; rows that need one set `needsParentOf` and
* processRevisions() resolves them in a single batched request.
*
* @param {Element} container - The DOM element to query for revision links.
* @param {Element} element - The DOM element to attach to the revision object.
* @param {Function|null} prePadCallback - Optional callback invoked with (element, link) before
* processing each branch. In fallback mode this derives the target element and pre-pads it.
* @returns {Promise<{revision: Object|null, hasLink: boolean, skip: boolean}>}
* - revision: the extracted revision object, or null if no usable link was found.
* - hasLink: true if any recognized link was present (even if skipped).
* - skip: true if the entry should be skipped (non-article after pre-padding).
*/
async function extractRevisionFromLinks(container, element, prePadCallback) {
const kinds = [
{ selector: 'a.mw-changeslist-diff', kind: 'diff' },
{ selector: 'a.mw-changeslist-history', kind: 'hist' },
{ selector: 'a.mw-history-histlinks-previous', kind: 'prev' },
{ selector: 'a.mw-history-histlinks-current', kind: 'cur' }
];
let link = null;
let kind = null;
for (const k of kinds) {
link = container.querySelector(k.selector);
if (link) {
kind = k.kind;
break;
}
}
if (!link) {
return { revision: null, hasLink: false, skip: false };
}
const params = new URL(link.href).searchParams;
const pageTitle = params.get('title');
const target = prePadCallback ? prePadCallback(element, link) : element;
// The diff and hist links appear on watchlist/RC/contribs rows that can be
// for any namespace; history prev/cur links are always for the page itself.
if ((kind === 'diff' || kind === 'hist') && isNotArticle(pageTitle)) {
return { revision: null, hasLink: true, skip: true };
}
const revision = {
oldrevision: null,
newrevision: null,
element: target,
viewHref: '',
viewLabel: viewDiffLabel,
pageTitle: pageTitle || undefined
};
switch (kind) {
case 'diff': {
// MediaWiki convention: diff = newer, oldid = older
const diffParam = params.get('diff');
const oldidParam = params.get('oldid');
revision.viewHref = link.href;
if (diffParam === 'prev' && oldidParam) {
// Contributions: oldid is the current revision; parent resolved later
revision.needsParentOf = oldidParam;
} else {
revision.oldrevision = oldidParam;
revision.newrevision = diffParam;
}
break;
}
case 'hist': {
// New page: analyze its first revision
const firstID = await fetchFirstRevisionId(pageTitle);
if (!firstID) {
return { revision: null, hasLink: true, skip: false };
}
revision.oldrevision = String(firstID);
revision.viewHref = buildPermalinkUrl(firstID);
revision.viewLabel = viewRevisionLabel;
break;
}
case 'prev': {
// viewHref is filled in once the parent is known
revision.needsParentOf = params.get('oldid') || null;
break;
}
case 'cur': {
revision.oldrevision = params.get('oldid');
revision.viewHref = buildPermalinkUrl(revision.oldrevision);
revision.viewLabel = viewRevisionLabel;
break;
}
}
return { revision, hasLink: true, skip: false };
}
/**
* Classifies the link changes between two revisions. Links are grouped by
* the page they ultimately point at (archive URLs are unwrapped). Each
* entry has a `kind` (added / changed / removed), `archive` (the cited URL
* is an archived copy), `withArchive` (an archive copy was added with it),
* `supplement` (archive link added to an already-cited source) and
* `counted` (affects the gutter icon and list indicators). A removed URL and
* an added URL on the same domain are paired as a `changed` entry.
* Multiset semantics: a URL that appears twice in the new revision and once
* in the old counts as one addition.
*
* @param {string[]} fromList - URLs in the older revision.
* @param {string[]|null} toList - URLs in the newer revision (ignored if !hasNew).
* @param {boolean} hasNew - False for a page's first revision, in which case
* every URL in fromList is considered added.
* @returns {Object[]} Change entries.
*/
function classifyLinkChanges(fromList, toList, hasNew) {
const tally = (urls) => {
const byTarget = new Map(); // target -> { direct: [], archive: [] }
for (const r of resolveURLs(urls)) {
if (!byTarget.has(r.target)) byTarget.set(r.target, { direct: [], archive: [] });
byTarget.get(r.target)[r.archiveHost ? 'archive' : 'direct'].push(r);
}
return byTarget;
};
const oldMap = hasNew ? tally(fromList) : new Map();
const newMap = tally(hasNew ? (toList || []) : fromList);
const added = []; // counted, non-supplement additions (candidates for pairing)
const removed = []; // counted removals (candidates for pairing)
const other = []; // everything else, already final
const entry = (link, kind, props) => ({
url: link.url, target: link.target, domain: link.domain, indicator: link.indicator,
archive: !!link.archiveHost, archiveHost: link.archiveHost,
withArchive: false, supplement: false, counted: true, kind, ...props
});
for (const target of new Set([...oldMap.keys(), ...newMap.keys()])) {
const o = oldMap.get(target) || { direct: [], archive: [] };
const n = newMap.get(target) || { direct: [], archive: [] };
let dAdded = Math.max(0, n.direct.length - o.direct.length);
let dRemoved = Math.max(0, o.direct.length - n.direct.length);
let aAdded = Math.max(0, n.archive.length - o.archive.length);
let aRemoved = Math.max(0, o.archive.length - n.archive.length);
// New direct citations; an archive added with one is folded into it
for (let i = 0; i < dAdded; i++) {
const withArchive = aAdded > 0;
if (withArchive) aAdded--;
added.push(entry(n.direct[i], 'added', { withArchive }));
}
// Archive links not accounted for above
if (aAdded > 0) {
if (n.direct.length > 0) {
// Source still cited directly: these are supplements, not new sources
for (let i = 0; i < aAdded; i++) {
other.push(entry(n.archive[i], 'added', { supplement: true, counted: false }));
}
} else if (dRemoved > 0) {
// Original gone, archive took its place: a swap
const swaps = Math.min(dRemoved, aAdded);
for (let i = 0; i < swaps; i++) {
other.push(entry(n.archive[i], 'changed', {
counted: false, oldUrl: o.direct[i].url, oldArchive: false
}));
}
dRemoved -= swaps;
for (let i = swaps; i < aAdded; i++) {
other.push(entry(n.archive[i], 'added', { supplement: true, counted: false }));
}
} else {
// Archive-only citation of a source not otherwise cited
for (let i = 0; i < aAdded; i++) added.push(entry(n.archive[i], 'added'));
}
}
// Direct links removed
if (dRemoved > 0) {
if (n.direct.length === 0 && n.archive.length > 0) {
// Original dropped but an already-present archive remains: a swap
for (let i = 0; i < dRemoved; i++) {
other.push(entry(n.archive[0], 'changed', {
counted: false, oldUrl: o.direct[i].url, oldArchive: false
}));
}
} else {
for (let i = 0; i < dRemoved; i++) removed.push(entry(o.direct[i], 'removed'));
}
}
// Archive links removed: only matters when nothing of the source remains
if (aRemoved > 0 && n.direct.length === 0 && n.archive.length === 0) {
if (dAdded > 0) {
// archive-only -> direct: un-archived
for (let i = 0; i < aRemoved && i < dAdded; i++) {
const a = added.pop(); // the direct entry pushed above
other.push(entry(n.direct[i], 'changed', {
counted: false, oldUrl: o.archive[i].url, oldArchive: true
}));
if (!a) break;
}
} else if (o.direct.length === 0) {
for (let i = 0; i < aRemoved; i++) removed.push(entry(o.archive[i], 'removed'));
}
}
}
// Pair same-domain removals with additions as URL changes
for (const r of removed) {
const idx = added.findIndex(a => a.domain === r.domain && !a.archive && !r.archive);
if (idx === -1) {
other.push(r);
continue;
}
const a = added.splice(idx, 1)[0];
other.push({ ...a, kind: 'changed', counted: false, oldUrl: r.url, oldArchive: false });
}
return [...added, ...other];
}
/**
* Screens a revision's link changes against the domain lists and annotates
* its row (gutter icons, popup, filter data attributes).
* Synchronous: all network work happens in processRevisions().
*
* @param {Object} revision - Object containing oldrevision, optional
* newrevision, and DOM element.
* @param {Object[]} changes - From classifyLinkChanges().
*/
function renderRevision(revision, changes) {
const targetElement = revisionTargetElement(revision);
targetElement.setAttribute('data-cw-processed', 'true');
const counted = changes.filter(c => c.counted);
const addedSources = counted.filter(c => c.kind === 'added');
const removedSources = counted.filter(c => c.kind === 'removed');
// Left gutter glyph: list indicator > new source > archiving only > URL changes only
let addChar = null;
const topType = highestPriority(addedSources.map(a => a.indicator));
if (topType) addChar = indicators[topType].emoji;
else if (addedSources.length > 0) addChar = indicators.added.emoji;
else if (changes.some(c => c.kind === 'added')) addChar = archiveGlyph;
else if (changes.some(c => c.kind === 'changed')) addChar = changedGlyph;
try {
if (revision.viewHref) revision.element.dataset.cwViewHref = revision.viewHref;
if (revision.viewLabel) revision.element.dataset.cwViewLabel = revision.viewLabel;
} catch (e) {
/* ignore */
}
renderTwoColumnEmojis(revision.element, addChar, removedSources.length > 0, changes);
// Data attributes drive the filter widget. Every entry is a link change;
// only counted entries carry list indicators.
const changedDomains = unique(changes.map(r => r.domain)).sort();
if (changedDomains.length > 0) {
targetElement.dataset.cwLinkchange = changedDomains.join('|');
}
for (const type of listedTypes) {
const domains = unique(counted.filter(r => r.indicator === type).map(r => r.domain)).sort();
if (domains.length > 0) {
targetElement.setAttribute(indicators[type].dataAttr, domains.join('|'));
}
}
}
/**
* ==========================================================================
* 2. User Interface
* ==========================================================================
*/
/**
* Pending hosts that need gutter padding applied. Collected during
* renderTwoColumnEmojis and flushed in a single batch via flushGutterPadding()
* so that all rows shift at the same time instead of one at a time.
*/
const pendingGutterPadding = [];
/**
* Applies the gutter padding to a single host element, reserving horizontal
* space for the two-column emoji gutter.
*
* @param {HTMLElement} host - The element to pad.
*/
function applyGutterPadding(host) {
const pad = 'calc(2em + 0.5em)';
try {
const cs = window.getComputedStyle(host);
const existing = parseFloat(cs.paddingLeft || '0');
const fontSizePx = parseFloat(cs.fontSize || '16');
const desiredPx = 2.5 * fontSizePx; // 2em + 0.5em
if (isNaN(existing) || existing < desiredPx - 0.5) {
host.style.paddingLeft = pad;
}
} catch (e) {
host.style.paddingLeft = pad;
}
}
/**
* Applies gutter padding to all collected hosts at once, then clears the
* queue. Call this after a batch of renderTwoColumnEmojis calls to avoid
* one-at-a-time layout shifts.
*/
function flushGutterPadding() {
for (const host of pendingGutterPadding) {
applyGutterPadding(host);
}
pendingGutterPadding.length = 0;
}
/**
* Finds the element that hosts the gutter for a change-list element:
* the list container or, for table rows, the content cell.
*
* @param {HTMLElement} element
* @returns {HTMLElement|null}
*/
function gutterHostFor(element) {
const container =
element.closest('[data-mw-revid]:not(.mw-userlink)') ||
element.closest('li') ||
element.closest('.mw-changeslist-line, tr') || element.parentNode || element;
if (!container) return null;
let host = container;
if (host.tagName && host.tagName.toUpperCase() === 'TR') {
// For nested enhanced RC rows, place the gutter in the content cell so
// icons align with singleton rows that use .mw-changeslist-line-inner.
const nestedContent = host.querySelector('td.mw-enhanced-rc-nested[data-target-page]');
host = nestedContent || host.querySelector('td, th') || host;
}
// Prefer inner change-line wrappers on Watchlist/RC if present
const innerHost = host.querySelector('.mw-changeslist-line-inner, .mw-changeslist-line-main');
return innerHost || host;
}
/**
* Renders exactly two fixed-width emoji columns before a revision entry:
* - Left column: highest-priority addition indicator
* (warning/caution/inspect/added), or spacer if none.
* - Right column: removal indicator if removals exist, otherwise spacer.
*
* This preserves consistent alignment even when no links are added/removed.
*
* @param {HTMLElement} element - The revision list DOM element.
* @param {string|null} addChar - Glyph for the left column, or null for a spacer.
* @param {boolean} hasRemovals - Whether to show the removal icon.
* @param {Object[]} changes - Resolved link changes from classifyLinkChanges().
*/
function renderTwoColumnEmojis(element, addChar, hasRemovals, changes = []) {
if (!element) return;
const host = gutterHostFor(element);
if (!host) return;
const cell = (char, className) => makeElement('span', {
className, text: char,
style: { display: 'inline-block', width: '1em', textAlign: 'center', userSelect: 'none' }
});
addChar = addChar || '\u00A0';
const remChar = hasRemovals ? indicators.removed.emoji : ' ';
// Propagate dataset for view link from source element to host
try {
if (element.dataset.cwViewHref) host.dataset.cwViewHref = element.dataset.cwViewHref;
if (element.dataset.cwViewLabel) host.dataset.cwViewLabel = element.dataset.cwViewLabel;
} catch (e) {
/* ignore */
}
// Ensure a single gutter exists
let gutter = host.querySelector('.cw-gutter');
if (gutter) {
gutter.querySelector('.cw-add').textContent = addChar;
gutter.querySelector('.cw-rem').textContent = remChar;
} else {
// Ensure the host can anchor absolutely positioned children
if (!host.style.position) host.style.position = 'relative';
// Create a left gutter that does not participate in text wrapping
gutter = makeElement('span', {
className: 'cw-gutter',
style: {
position: 'absolute', left: '0', top: '0', display: 'inline-flex', gap: '0.25em',
width: 'calc(2em + 0.25em)', alignItems: 'flex-start', userSelect: 'none'
}
});
gutter.appendChild(cell(addChar, 'cw-add'));
gutter.appendChild(cell(remChar, 'cw-rem'));
// Insert the gutter into the host; padding is deferred to flushGutterPadding()
host.insertBefore(gutter, host.firstChild);
pendingGutterPadding.push(host);
}
const sections = Object.keys(changeKinds).map(kind =>
({ desc: changeKinds[kind], items: changes.filter(c => c.kind === kind) }));
attachPopup(gutter, sections, {
viewHref: host.dataset.cwViewHref || '',
viewLabel: host.dataset.cwViewLabel || ''
});
host.setAttribute('data-processed-twocol', 'true');
}
/**
* Fills a popup with a close button, stacked sections and a footer. The
* body is a two-column grid: a fixed-width column for list indicators and
* a column for domains, so domain names align whether or not they carry a
* flag. Each section header spans both columns with a rule beneath it.
* Popups with more than POPUP_COLLAPSE_AFTER domain lines start collapsed
* with a "Show all" toggle. Idempotent: safe to call again to refresh.
*
* @param {HTMLElement} popupEl - The popup element.
* @param {Array<{desc: Object, items: Object[]}>} sections - `desc` has
* msg, emoji and explanation; `items` are change entries or resolved links.
* @param {Object} footerOptions - See createPopupFooter.
*/
function buildPopupContent(popupEl, sections, footerOptions) {
if (!popupEl.querySelector('.cw-close')) {
popupEl.appendChild(createCloseButton(popupEl));
}
for (const stale of popupEl.querySelectorAll('.cw-popup-content, .cw-popup-more, .cw-popup-footer')) {
stale.remove();
}
const contentWrap = makeElement('div', {
className: 'cw-popup-content',
style: {
display: 'grid', gridTemplateColumns: '1.6em 1fr', columnGap: '0.3em',
rowGap: '2px', alignItems: 'baseline'
}
});
let lineCount = 0;
let sectionCount = 0;
for (const section of sections) {
if (!section.items || section.items.length === 0) continue;
const header = makeElement('div', {
className: 'cw-popup-header',
title: section.desc.explanation,
ariaLabel: `${section.desc.msg}: ${section.desc.explanation}`,
style: {
gridColumn: '1 / -1', fontWeight: 'bold', whiteSpace: 'nowrap',
marginTop: sectionCount ? '8px' : '0', paddingBottom: '2px',
borderBottom: '1px solid rgba(128,128,128,.35)'
}
});
header.appendChild(makeElement('span', { text: `${section.desc.emoji} ${section.desc.msg} ` }));
header.appendChild(makeElement('span', {
text: `(${section.items.length})`, style: { fontWeight: 'normal', opacity: '0.7' }
}));
contentWrap.appendChild(header);
sectionCount++;
for (const group of groupByDomain(section.items)) {
for (const cell of makeDomainLine(group)) contentWrap.appendChild(cell);
lineCount++;
}
}
popupEl.appendChild(contentWrap);
if (lineCount > POPUP_COLLAPSE_AFTER) {
Object.assign(contentWrap.style, { maxHeight: '12em', overflow: 'hidden' });
const more = makeElement('a', {
className: 'cw-popup-more', text: `${showAllLabel} ▾`,
attrs: { href: '#', role: 'button' }, style: { display: 'block', marginTop: '4px' }
});
more.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
Object.assign(contentWrap.style, { maxHeight: '', overflow: '' });
more.remove();
});
popupEl.appendChild(more);
}
popupEl.appendChild(createPopupFooter(footerOptions));
}
/**
* Groups resolved links by domain, preserving first-seen order.
*
* @param {Object[]} items
* @returns {Array<{domain: string, indicator: string|null, items: Object[]}>}
*/
function groupByDomain(items) {
const groups = new Map();
for (const item of items) {
if (!groups.has(item.domain)) {
groups.set(item.domain, { domain: item.domain, indicator: item.indicator, items: [] });
}
groups.get(item.domain).items.push(item);
}
return Array.from(groups.values());
}
/**
* Renders one domain line as two grid cells: the list-indicator cell (emoji
* or empty) and the domain cell. A single link is the domain itself;
* several become numbered links in parentheses. Archived copies carry a
* badge, URL changes render as "old → new", and archive supplements are
* greyed.
*
* @param {{domain: string, indicator: string|null, items: Object[]}} group
* @returns {HTMLElement[]} [indicatorCell, domainCell]
*/
function makeDomainLine(group) {
const flag = makeElement('span', {
className: 'cw-emoji', style: { textAlign: 'center', userSelect: 'none' }
});
if (group.indicator) {
const ind = indicators[group.indicator];
flag.textContent = ind.emoji;
flag.title = ind.explanation;
flag.setAttribute('aria-label', `${ind.msg}: ${ind.explanation}`);
}
const line = makeElement('div', { className: 'cw-domain', style: { minWidth: '0' } });
const allArchive = group.items.every(i => i.archive || i.withArchive);
const allSupplement = group.items.every(i => i.supplement);
if (allSupplement) {
flag.style.opacity = line.style.opacity = '0.65';
line.title = supplementTitle;
}
const isChanged = group.items[0].kind === 'changed';
if (group.items.length === 1 && !isChanged) {
line.appendChild(makeItemLink(group.items[0], group.domain));
if (allArchive) line.appendChild(archiveBadge());
return [flag, line];
}
// "domain (1, 2, 3)" or, for URL changes, "domain (old → new)" /
// "domain (1: old → new, 2: old → new)"
line.appendChild(makeElement('span', { text: group.domain, style: { whiteSpace: 'nowrap' } }));
if (allArchive && !isChanged) line.appendChild(archiveBadge());
line.appendChild(makeElement('span', { text: ' (' }));
group.items.forEach((item, idx) => {
const label = group.items.length > 1 ? String(idx + 1) : '';
line.appendChild(makeItemLink(item, label));
if (!isChanged && !allArchive && (item.archive || item.withArchive)) line.appendChild(archiveBadge());
if (idx < group.items.length - 1) line.appendChild(makeElement('span', { text: ', ' }));
});
line.appendChild(makeElement('span', { text: ')' }));
return [flag, line];
}
/**
* The small "archived copy" badge shown after a link or domain.
*
* @returns {HTMLSpanElement}
*/
function archiveBadge() {
return makeElement('span', {
className: 'cw-archive-badge', text: ' ' + archiveGlyph,
title: archiveBadgeTitle, style: { fontSize: '0.85em' }
});
}
/**
* Link(s) for one entry. A changed entry renders as "old → new" with both
* halves linked; otherwise a single link. Archived copies note their host
* in the tooltip.
*
* @param {Object} item - Change entry or resolved link.
* @param {string} text - Link text (the domain, or an index number).
* @returns {HTMLElement}
*/
function makeItemLink(item, text) {
const link = (url, label, isArchive, host) => {
const a = makeExternalLink(url, label);
if (isArchive) a.title = host ? `${archivedViaLabel} ${host}` : archiveBadgeTitle;
return a;
};
if (item.kind !== 'changed') {
return link(item.url, text, item.archive, item.archiveHost);
}
const span = makeElement('span', { style: { whiteSpace: 'nowrap' } });
if (text) span.appendChild(makeElement('span', { text: `${text}: ` }));
span.appendChild(link(item.oldUrl, 'old', item.oldArchive, null));
if (item.oldArchive) span.appendChild(archiveBadge());
span.appendChild(makeElement('span', { text: changedArrow }));
span.appendChild(link(item.url, 'new', item.archive, item.archiveHost));
if (item.archive) span.appendChild(archiveBadge());
return span;
}
/**
* Creates (or refreshes) the popup under a host element and wires its
* hover/click handlers. With no lines, any existing popup is hidden.
*
* @param {HTMLElement} host - The gutter or icon wrapper that owns the popup.
* @param {Array} sections - See buildPopupContent.
* @param {Object} footerOptions - See createPopupFooter.
*/
function attachPopup(host, sections, footerOptions) {
let popup = host.querySelector('.cw-popup');
const hasContent = sections.some(s => s.items && s.items.length > 0);
if (!hasContent) {
if (popup) popup.style.display = 'none';
host._cwEnablePopup = false;
return;
}
if (!popup) {
popup = createPopupElement();
host.appendChild(popup);
}
buildPopupContent(popup, sections, footerOptions);
host._cwEnablePopup = true;
installPopupHandlers(host);
}
/**
* Creates a link with the attributes every outbound link in this script
* uses. The single place an attacker-influenced URL becomes an href, so the
* URL check lives here: absolute http(s) only, plus same-origin paths when
* `allowPath` is set (used for diff/permalink footers).
*
* @param {string} url
* @param {string} text
* @param {boolean} [allowPath=false]
* @returns {HTMLAnchorElement}
*/
function makeExternalLink(url, text, allowPath = false) {
const a = makeElement('a', {
text, attrs: { rel: 'nofollow noopener noreferrer', target: '_blank' },
style: { whiteSpace: 'nowrap' }
});
if (isValidHttpUrl(url) || (allowPath && /^\/(?!\/)/.test(url))) a.href = url;
return a;
}
/**
* Creates a DOM element with class, text, title, inline styles and
* attributes in one call. Text is always set through textContent.
*
* @param {string} tag
* @param {Object} [opts] - {className, text, title, ariaLabel, style, attrs}
* @returns {HTMLElement}
*/
function makeElement(tag, opts = {}) {
const el = document.createElement(tag);
if (opts.className) el.className = opts.className;
if (opts.text !== undefined) el.textContent = opts.text;
if (opts.title) {
el.title = opts.title;
el.setAttribute('aria-label', opts.ariaLabel || opts.title);
}
if (opts.style) Object.assign(el.style, opts.style);
for (const [k, v] of Object.entries(opts.attrs || {})) el.setAttribute(k, v);
return el;
}
/**
* Creates a styled popup element for displaying domain information.
*
* @returns {HTMLDivElement} The configured popup element.
*/
function createPopupElement() {
return makeElement('div', {
className: 'mwe-popups cw-popup',
style: {
position: 'absolute', minWidth: '260px', maxWidth: '420px', zIndex: '1000',
display: 'none', background: 'var(--background-color, #fff)',
border: '1px solid rgba(0,0,0,.15)', boxShadow: '0 2px 8px rgba(0,0,0,.2)',
padding: '8px 26px 8px 10px', borderRadius: '4px', fontSize: '0.92em',
overflowWrap: 'anywhere', boxSizing: 'border-box'
}
});
}
/**
* Creates a styled close button for popups.
*
* @param {HTMLElement} popupEl - The popup element to hide when the button is clicked.
* @returns {HTMLButtonElement} The configured close button.
*/
function createCloseButton(popupEl) {
const closeBtn = makeElement('button', {
className: 'cw-close', text: '×', title: 'Close', attrs: { type: 'button' },
style: {
position: 'absolute', top: '4px', right: '6px', border: 'none',
background: 'transparent', cursor: 'pointer', fontSize: '14px',
lineHeight: '1', padding: '0', margin: '0'
}
});
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
popupEl.style.display = 'none';
});
return closeBtn;
}
/**
* Creates a popup footer element with an optional primary link and the
* "About Citation Watchlist" link.
*
* @param {Object} [options] - Optional configuration.
* @param {string} [options.viewHref] - URL for the primary link (e.g., diff or revision).
* @param {string} [options.viewLabel] - Label text for the primary link.
* @returns {HTMLDivElement} The configured footer element.
*/
function createPopupFooter(options) {
const footer = makeElement('div', {
className: 'cw-popup-footer', style: { marginTop: '10px', color: 'inherit' }
});
if (options && options.viewHref && options.viewLabel) {
const viewA = makeExternalLink(options.viewHref, options.viewLabel, true);
if (viewA.href) {
const strong = makeElement('strong');
strong.appendChild(viewA);
footer.appendChild(strong);
footer.appendChild(makeElement('span', { text: ' • ' }));
}
}
footer.appendChild(makeExternalLink(aboutLink, aboutLabel));
return footer;
}
/**
* Builds the shared filter widget container with radio buttons and an about link.
*
* @param {Object} config - Widget configuration.
* @param {string} config.containerId - The ID for the container element.
* @param {string} config.radioName - The name attribute for the radio button group.
* @param {Array<Object>} config.filters - Array of filter option objects
* ({id, label, checked?, tooltip?}).
* @param {Function} config.onFilter - Callback invoked with the selected filter ID.
* @param {string} [config.initialLabel] - Label text; defaults to showEditsLabel.
* @returns {HTMLDivElement} The configured filter widget container.
*/
function buildFilterWidgetContainer(config) {
const container = makeElement('div', {
attrs: { id: config.containerId },
style: {
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
border: '1px solid black', borderRadius: '25px', padding: '10px 20px',
margin: '10px 0', width: 'fit-content'
}
});
const leftSection = makeElement('div', {
style: { display: 'flex', alignItems: 'center', gap: '15px' }
});
leftSection.appendChild(makeElement('span', {
className: 'cw-filter-label', text: config.initialLabel || showEditsLabel,
style: { fontWeight: '500' }
}));
for (const filter of config.filters) {
const wrapper = makeElement('label', {
style: { display: 'flex', alignItems: 'center', gap: '5px', cursor: 'pointer' }
});
const radio = makeElement('input', {
attrs: { type: 'radio', name: config.radioName, value: filter.id }, style: { margin: '0' }
});
radio.checked = !!filter.checked;
radio.addEventListener('change', () => {
if (radio.checked) config.onFilter(filter.id);
});
const text = makeElement('span', { text: filter.label });
if (filter.tooltip) {
text.title = filter.tooltip;
Object.assign(text.style, { borderBottom: '1px dotted', cursor: 'help' });
}
wrapper.appendChild(radio);
wrapper.appendChild(text);
leftSection.appendChild(wrapper);
}
container.appendChild(leftSection);
const rightSection = makeElement('div', { style: { marginLeft: '4em' } });
const link = makeElement('a', {
text: toolName, attrs: { href: aboutLink },
style: { textDecoration: 'underline', color: '#0645ad' }
});
rightSection.appendChild(link);
container.appendChild(rightSection);
return container;
}
/**
* Builds the indicator filter list from the indicators config, sorted by
* descending priority and excluding non-positive priorities.
*
* @returns {Array<{id: string, label: string, tooltip: string}>}
*/
function buildIndicatorFilters() {
return listedTypes
.slice()
.sort((a, b) => indicators[b].priority - indicators[a].priority)
.map(id => ({
id,
label: `${indicators[id].emoji} ${indicators[id].msg}`,
tooltip: indicators[id].explanation
}));
}
/**
* Enables or greys out each radio in a filter widget according to whether
* any row currently matches it.
*
* @param {string} radioName - The radio group name.
* @param {Function} hasResults - (filterId) => boolean.
*/
function updateFilterRadios(radioName, hasResults) {
document.querySelectorAll(`input[name="${radioName}"]`).forEach(radio => {
const enabled = radio.value === 'all' || hasResults(radio.value);
const label = radio.closest('label');
radio.disabled = !enabled;
if (label) {
label.style.cursor = enabled ? 'pointer' : 'default';
label.style.color = enabled ? '' : 'gray';
}
});
}
/**
* Creates and inserts the change-list filter widget (watchlist, recent
* changes, history, contributions). Radios start disabled and are enabled by
* finishChangeListPass() once rows are annotated.
*
* @returns {HTMLElement|null} The created filter container or null if not created.
*/
function createChangeListFilterWidget() {
const ns = mw.config.get('wgNamespaceNumber');
const pageName = mw.config.get('wgPageName');
let target = document.querySelector('.mw-changeslist') ||
document.getElementById('mw-history-compare');
if (!target && ns === -1 && pageName.startsWith('Special:Contributions')) {
target = document.querySelector('.mw-pager-body');
}
if (!target) return null;
const container = buildFilterWidgetContainer({
containerId: 'cw-filter-widget',
radioName: 'cw-filter',
filters: [
{ id: 'all', label: allLabel, checked: true },
{ id: 'linkchange', label: linkChangesLabel, tooltip: linkChangesTooltip },
...buildIndicatorFilters()
],
onFilter: applyChangeListFilter,
initialLabel: loadingLabel
});
target.parentNode.insertBefore(container, target);
updateFilterRadios('cw-filter', () => false); // Disabled until annotation is complete
return container;
}
/**
* Creates and inserts a filter widget for the article surface, placed after
* the section heading. Excludes the "Link Changes" option.
*
* @param {Element} headingContainer - The heading element to insert the widget after.
*/
function createArticleFilterWidget(headingContainer) {
const container = buildFilterWidgetContainer({
containerId: 'cw-article-filter-widget',
radioName: 'cw-article-filter',
filters: [
{ id: 'all', label: allLabel, checked: true },
...buildIndicatorFilters()
],
onFilter: applyArticleFilter
});
headingContainer.after(container);
updateFilterRadios('cw-article-filter', (filterId) =>
!!document.querySelector(`li[id^="cite_note"][data-cw-indicator="${filterId}"]`));
renderLoadWarnings();
}
/**
* Called after each annotation pass on a change list: restores the widget
* label, renders any load warnings, and enables the radios that have matches.
*/
function finishChangeListPass() {
const filterLabel = document.querySelector('#cw-filter-widget .cw-filter-label');
if (filterLabel && filterLabel.firstChild &&
filterLabel.firstChild.nodeType === Node.TEXT_NODE) {
filterLabel.firstChild.textContent = showEditsLabel;
}
renderLoadWarnings();
const rows = document.querySelectorAll('[data-mw-revid]:not(.mw-userlink)');
updateFilterRadios('cw-filter', (filterId) =>
Array.from(rows).some(row => rowMatchesFilter(row, filterId)));
// Re-apply the active filter so newly loaded rows respect it
const active = document.querySelector('input[name="cw-filter"]:checked');
if (active && active.value !== 'all') applyChangeListFilter(active.value);
}
/**
* Whether a change-list row (or any nested revision inside it) matches a filter.
*
* @param {Element} row
* @param {string} filterId
* @returns {boolean}
*/
function rowMatchesFilter(row, filterId) {
if (elementMatchesFilter(row, filterId)) return true;
for (const nested of row.querySelectorAll('li[data-cw-processed="true"]')) {
if (elementMatchesFilter(nested, filterId)) return true;
}
return false;
}
/**
* Applies the selected filter to article section list items.
*
* @param {string} filterId - 'all' or an indicator type.
*/
function applyArticleFilter(filterId) {
document.querySelectorAll('li[id^="cite_note"]').forEach(li => {
li.style.display = (filterId === 'all' || li.dataset.cwIndicator === filterId) ? '' : 'none';
});
}
/**
* Applies the selected filter to the revision list.
*
* @param {string} filterId - 'all', 'linkchange', or an indicator type.
*/
let lastAppliedFilter = 'all';
function applyChangeListFilter(filterId) {
const isAll = filterId === 'all';
const revisions = document.querySelectorAll(
'[data-mw-revid]:not(.mw-userlink), [data-mw-logid]'
);
revisions.forEach(container => {
if (isAll) {
container.style.display = '';
return;
}
// Log entries (data-mw-logid) don't have revision-based attributes to
// match against filters — hide them when any specific filter is active.
const show = container.hasAttribute('data-mw-revid') && rowMatchesFilter(container, filterId);
container.style.display = show ? '' : 'none';
});
// Hide h4 date headers when all their rows are hidden
document.querySelectorAll('div.mw-changeslist h4').forEach(h4 => {
if (isAll) {
h4.style.display = '';
return;
}
const sibling = h4.nextElementSibling;
if (!sibling || sibling.tagName !== 'DIV') return;
const revItems = sibling.querySelectorAll('[data-mw-revid]');
if (revItems.length === 0) return;
h4.style.display = Array.from(revItems).every(el => el.style.display === 'none') ? 'none' : '';
});
const enhanced = document.querySelector('table.mw-enhanced-rc');
if (enhanced) {
// Hide the group header row in each tbody if all other rows are hidden
document.querySelectorAll('table.mw-enhanced-rc tbody').forEach(tbody => {
const rows = tbody.querySelectorAll('tr');
if (rows.length < 2) return;
rows[0].style.display = (!isAll && Array.from(rows).slice(1)
.every(tr => tr.style.display === 'none')) ? 'none' : '';
});
// Expand grouped sections while a specific filter is active, collapse on
// "all". Only on the transition, so switching between two filters leaves
// the expanded state alone.
const wasAll = lastAppliedFilter === 'all';
if (wasAll !== isAll) {
document.querySelectorAll('table.mw-enhanced-rc').forEach(table => {
const checkbox = groupToggleCheckbox(table);
if (checkbox && checkbox.checked === isAll) {
checkbox.checked = !isAll;
checkbox.dispatchEvent(new Event('change'));
}
});
}
}
showNoMatchingEdits(!isAll && Array.from(
document.querySelectorAll('[data-mw-revid]:not(.mw-userlink)')
).every(el => el.style.display === 'none'));
lastAppliedFilter = filterId;
}
/**
* Shows or removes the "No matching edits" message under the filter widget.
*
* @param {boolean} show
*/
function showNoMatchingEdits(show) {
const existing = document.getElementById('cw-no-matching-edits');
if (existing) existing.remove();
if (!show) return;
const msg = makeElement('p', {
text: noMatchingEditsLabel, attrs: { id: 'cw-no-matching-edits' },
style: { margin: '10px 0', fontStyle: 'italic' }
});
const widget = document.getElementById('cw-filter-widget');
if (widget && widget.nextSibling) {
widget.parentNode.insertBefore(msg, widget.nextSibling);
}
}
/**
* Returns the hidden checkbox that expands/collapses an enhanced-RC group.
*
* @param {Element} table - A table.mw-enhanced-rc element.
* @returns {HTMLInputElement|null}
*/
function groupToggleCheckbox(table) {
const arrow = table.querySelector('.mw-enhancedchanges-arrow');
const checkboxId = arrow && arrow.getAttribute('for');
return checkboxId ? document.getElementById(checkboxId) : null;
}
/**
* If any configuration pages failed to load, appends a warning glyph (with
* the details in its tooltip) to every filter-widget label on the page.
* Safe to call more than once.
*/
function renderLoadWarnings() {
const messages = SHOW_LINT_IN_WIDGET ? [...loadWarnings, ...listLint] : loadWarnings;
if (messages.length === 0) return;
document.querySelectorAll('.cw-filter-label').forEach(label => {
if (label.querySelector('.cw-load-warning')) return;
label.appendChild(makeElement('span', {
className: 'cw-load-warning', text: ' ' + loadWarningGlyph,
title: loadWarningPrefix + '\n' + messages.join('\n'), style: { cursor: 'help' }
}));
});
}
/**
* Whether the single document-level outside-click handler has been installed.
* One delegated listener replaces the former one-listener-per-gutter design.
*/
let outsideClickHandlerInstalled = false;
/**
* Installs a single document-level click handler that hides every open popup
* whose owning gutter does not contain the click target.
*/
function installOutsideClickHandler() {
if (outsideClickHandlerInstalled) return;
document.addEventListener('click', (ev) => {
document.querySelectorAll('.cw-popup').forEach(p => {
if (p.style.display === 'none' || p.style.display === '') return;
const owner = p.parentElement;
if (owner && !owner.contains(ev.target)) {
p.style.display = 'none';
}
});
});
outsideClickHandlerInstalled = true;
}
/**
* Installs hover, click, touch, and outside-click handlers on a gutter element
* to show/hide its popup.
*
* @param {HTMLElement} gutter - The gutter element containing a .cw-popup child.
*/
function installPopupHandlers(gutter) {
if (gutter._cwHandlersInstalled) return;
let hideTimer = null;
const positionPopup = () => {
const p = gutter.querySelector('.cw-popup');
if (!gutter._cwEnablePopup || !p) return;
p.style.display = 'block';
p.style.top = '1.4em';
p.style.left = '0px';
const rect = p.getBoundingClientRect();
if (rect.right > window.innerWidth - 10) {
const overflow = rect.right - (window.innerWidth - 10);
p.style.left = Math.max(0, (parseFloat(p.style.left) || 0) - overflow) + 'px';
}
};
const show = () => {
if (!gutter._cwEnablePopup) return;
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
positionPopup();
};
const scheduleHide = () => {
hideTimer = setTimeout(() => {
const p = gutter.querySelector('.cw-popup');
if (p) p.style.display = 'none';
}, 150);
};
gutter.addEventListener('mouseenter', show);
gutter.addEventListener('mouseleave', scheduleHide);
const pInit = gutter.querySelector('.cw-popup');
if (pInit) {
pInit.addEventListener('mouseenter', () => {
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
});
pInit.addEventListener('mouseleave', scheduleHide);
}
const toggle = (e) => {
if (!gutter._cwEnablePopup) return;
e.stopPropagation();
const p = gutter.querySelector('.cw-popup');
if (!p) return;
if (p.style.display === 'none' || p.style.display === '') {
show();
} else {
p.style.display = 'none';
}
};
gutter.addEventListener('click', toggle);
gutter.addEventListener('touchstart', toggle, {
passive: true
});
installOutsideClickHandler();
gutter._cwHandlersInstalled = true;
}
/**
* Watches for the change list being replaced by MediaWiki's live filters
* ("View new changes", filter edits on Special:Watchlist / RecentChanges)
* and annotates the new rows. The existing widget sits outside the replaced
* element and survives; its radios are refreshed after each pass.
*/
function installLiveReloadObserver() {
if (typeof MutationObserver === 'undefined') return;
let timer = null;
let running = false;
let rerun = false;
const run = async () => {
if (running) {
rerun = true;
return;
}
running = true;
try {
await annotateChangeList(document, false);
} catch (e) {
console.warn('Citation Watchlist: live reload pass failed:', e);
} finally {
running = false;
if (rerun) {
rerun = false;
run();
}
}
};
const observer = new MutationObserver(mutations => {
const relevant = mutations.some(m => Array.from(m.addedNodes).some(node =>
node.nodeType === Node.ELEMENT_NODE &&
(node.matches('[data-mw-revid], .mw-changeslist, .mw-changeslist-line') ||
node.querySelector('[data-mw-revid], .mw-changeslist-line'))
));
if (!relevant) return;
clearTimeout(timer);
timer = setTimeout(run, LIVE_RELOAD_DEBOUNCE_MS);
});
observer.observe(document.body, { childList: true, subtree: true });
}
const enabledNsStrings = new Set(enabledNamespaces.filter(n => n >= 0).map(String));
const namespaces = Object.entries(mw.config.get('wgFormattedNamespaces'))
.filter(([num, name]) => num !== '0' && !enabledNsStrings.has(num))
.map(([_, name]) => name.replace(/ /g, '_') + ':');
/**
* Pre-pads an element with empty emoji gutters to prevent layout shift.
*
* @param {Element} element - The DOM element to pre-pad.
*/
function prePadElement(element) {
try {
renderTwoColumnEmojis(element, null, false, []);
} catch (e) {
/* ignore */
}
}
/**
* ==========================================================================
* 3. Builders and Extractors
* ==========================================================================
*/
/**
* Constructs a URL for viewing a diff between two revisions.
*
* @param {string} title - The page title.
* @param {string} fromId - The older revision ID.
* @param {string} toId - The newer revision ID.
* @returns {string} The diff URL, or empty string on error.
*/
function buildDiffUrl(title, fromId, toId) {
try {
const script = mw.config.get('wgScript') || '/w/index.php';
return `${script}` +
`?title=${encodeURIComponent(title)}` +
`&diff=${encodeURIComponent(toId)}` +
`&oldid=${encodeURIComponent(fromId)}`;
} catch (e) {
return '';
}
}
/**
* Constructs a permalink URL for a specific revision.
*
* @param {string} revId - The revision ID.
* @returns {string} The permalink URL, or empty string on error.
*/
function buildPermalinkUrl(revId) {
try {
const articlePath = mw.config.get('wgArticlePath');
if (articlePath && articlePath.includes('$1')) {
return articlePath.replace('$1',
`Special:Permalink/${encodeURIComponent(revId)}`);
}
const script = mw.config.get('wgScript') || '/w/index.php';
return `${script}?oldid=${encodeURIComponent(revId)}`;
} catch (e) {
return '';
}
}
/**
* Strips all HTML comments from a string.
*
* @param {string} text - Input text potentially containing HTML comments.
* @returns {string} Text with all HTML comments removed.
*/
function stripHTMLComments(text) {
if (!text) return text;
return text.replace(/<!--[\s\S]*?-->/g, '');
}
/**
* Extracts all HTTP(S) URLs from a given wikitext string. The scheme is
* matched case-insensitively (MediaWiki links HTTPS:// just as it does
* https://), and protocol-relative links ("[//example.com ...]") are
* normalized to https://.
*
* @param {string} wikitext - Raw wikitext revision content.
* @returns {string[]} List of valid extracted URLs.
*/
function extractURLs(wikitext) {
const urls = [];
if (!wikitext) return urls;
const urlRegex = // eslint-disable-next-line max-len
/(?:https?:\/\/|(?<=^|[\s\[|=<>"'])\/\/)(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)/gi;
let match;
while ((match = urlRegex.exec(wikitext)) !== null) {
const raw = match[0].startsWith('//') ? 'https:' + match[0] : match[0];
try {
const url = new URL(raw);
if (/^https?:$/.test(url.protocol)) urls.push(url.href);
} catch (error) {
console.error(`Invalid URL rejected: ${match[0]}`, error.message);
}
}
return urls;
}
/**
* Web archive services whose URLs embed the original URL. Each entry's
* `extract` returns the embedded URL string or null.
*/
/** Restores "https:/example.com" (slash-collapsed by some archives) to "https://example.com". */
const fixScheme = s => s.replace(/^(https?:)\/+/i, '$1//');
const archiveUnwrappers = [
{
// https://web.archive.org/web/20200101000000/https://example.com/page
// (the embedded scheme is sometimes collapsed to "https:/")
test: h => h === 'web.archive.org' || h === 'wayback.archive.org',
extract: u => {
const m = u.pathname.match(/^\/web\/\d{1,14}[a-z_]*\/(https?:\/{1,2}.+)$/i);
return m ? fixScheme(m[1]) : null;
}
},
{
// https://archive.blah/2020.01.01-000000/https://example.com/page
test: h => /^archive\.(ph|today|is|li|vn|fo|md)$/i.test(h),
extract: u => {
const m = u.pathname.match(/^\/[^/]+\/(https?:\/{1,2}.+)$/i);
return m ? fixScheme(m[1]) : null;
}
},
{
// https://webcache.googleusercontent.com/search?q=cache:https://example.com/page
test: h => h === 'webcache.googleusercontent.com',
extract: u => {
const q = u.searchParams.get('q') || '';
const m = q.match(/^cache:(?:[A-Za-z0-9_-]+:)?(.+)$/);
if (!m) return null;
return /^https?:\/\//i.test(m[1]) ? m[1] : 'https://' + m[1];
}
}
];
/**
* Resolves a URL to the hostname that should be screened. If the URL is a
* web-archive capture of another page, the captured page's host is used.
*
* @param {string} url
* @returns {{hostname: string, target: string, archiveHost: string|null}|null}
* `target` is the unwrapped URL (or the URL itself). Null if unparsable.
*/
function screenedHost(url) {
let current;
try {
current = new URL(url);
} catch (e) {
return null;
}
let archiveHost = null;
for (let depth = 0; depth < 3 && UNWRAP_ARCHIVES; depth++) {
const unwrapper = archiveUnwrappers.find(a => a.test(current.hostname));
if (!unwrapper) break;
const inner = unwrapper.extract(current);
if (!inner) break;
try {
const innerUrl = new URL(inner);
if (!/^https?:$/.test(innerUrl.protocol)) break;
archiveHost = archiveHost || current.hostname;
current = innerUrl;
} catch (e) {
break;
}
}
return { hostname: current.hostname, target: current.href, archiveHost };
}
/**
* Resolves URLs to screened domains and list indicators.
*
* @param {string[]} urls
* @returns {Array<{url: string, target: string, domain: string, indicator: string|null, archiveHost: string|null}>}
*/
function resolveURLs(urls) {
const out = [];
for (const url of urls || []) {
const host = screenedHost(url);
if (!host) continue;
const domain = getRootDomain(host.hostname, publicSuffixSet);
if (!domain) continue;
out.push({
url, target: host.target, domain,
indicator: highestIndicatorFor(domain), archiveHost: host.archiveHost
});
}
return out;
}
/**
* Returns the highest-priority list indicator that contains a domain.
*
* @param {string} domain
* @returns {string|null}
*/
function highestIndicatorFor(domain) {
let chosen = null;
for (const type of listedTypes) {
if (indicators[type].list.has(domain) &&
(!chosen || indicators[type].priority > indicators[chosen].priority)) {
chosen = type;
}
}
return chosen;
}
/**
* Returns the highest-priority type among a list of indicator types.
*
* @param {Array<string|null>} types
* @returns {string|null}
*/
function highestPriority(types) {
let chosen = null;
for (const t of types) {
if (t && (!chosen || indicators[t].priority > indicators[chosen].priority)) chosen = t;
}
return chosen;
}
/**
* Extracts the top-level domain from a full hostname using a public suffix set.
*
* @param {string} hostname - Full hostname (e.g., sub.example.co.uk).
* @param {Set<string>} publicSuffixSet - Set of known public suffixes.
* @returns {string} The top-level domain (e.g., example.co.uk).
*/
function getRootDomain(hostname, publicSuffixSet) {
// Handle empty or invalid hostnames
if (!hostname || typeof hostname !== 'string') {
console.warn('Invalid hostname provided to getRootDomain:', hostname);
return '';
}
// Check if this is an IP address (simple check for IPv4)
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(hostname)) {
return hostname; // Return IP addresses as-is
}
const domainParts = hostname.split('.');
// Handle hostnames that are too short
if (domainParts.length < 2) {
return hostname; // Return as-is if it's a single-part hostname
}
// Try to find a matching public suffix
for (let i = 0; i < domainParts.length; i++) {
const candidate = domainParts.slice(i).join('.');
// Check both normal and exception (prefixed with !) entries
if (publicSuffixSet.has(candidate) || publicSuffixSet.has(`!${candidate}`)) {
// If we found a match, return the domain part plus the public suffix
// But make sure we don't go out of bounds
if (i > 0) {
return domainParts.slice(i - 1).join('.');
} else {
// Edge case: the entire hostname is a public suffix
return hostname;
}
}
}
// If no match in public suffix list, use a simple fallback:
// For hostnames with 2 parts, return the whole thing
// For hostnames with >2 parts, return the last 2 parts
if (domainParts.length === 2) {
return hostname;
} else {
return domainParts.slice(-2).join('.');
}
}
/**
* Extracts the first page object from MediaWiki API query response.
*
* @param {Object} data - MediaWiki API response.
* @returns {Object|null} The first page object or null if unavailable.
*/
function getFirstPage(data) {
if (!data || !data.query || !data.query.pages) return null;
return Object.values(data.query.pages)[0] || null;
}
/**
* Iterates every revision object in an API response, across all pages.
* (A revids= query spanning several articles returns several page objects.)
*
* @param {Object|null} data - MediaWiki API response.
* @returns {Object[]} Revision objects.
*/
function allRevisionsIn(data) {
if (!data || !data.query || !data.query.pages) return [];
const out = [];
for (const page of Object.values(data.query.pages)) {
if (page && Array.isArray(page.revisions)) out.push(...page.revisions);
}
return out;
}
/**
* Returns the main-slot wikitext of a revision object, or null if the
* revision is missing, hidden, or malformed.
*
* @param {Object} rev - Revision object from the API.
* @returns {string|null}
*/
function revisionText(rev) {
if (!rev || rev.texthidden !== undefined) return null;
const slot = rev.slots && rev.slots.main;
if (!slot || typeof slot['*'] !== 'string') return null;
return slot['*'];
}
/**
* Returns true if an API page object represents a page that cannot be
* read (missing, invalid, or without an accessible revision).
*
* @param {Object} page - Page object from the API.
* @returns {boolean}
*/
function isUnreadablePage(page) {
return !page || page.missing !== undefined || page.invalid !== undefined ||
!Array.isArray(page.revisions) || page.revisions.length === 0;
}
/**
* ==========================================================================
* 4. Validation
* ==========================================================================
*/
/**
* Validates that a string is a well-formed domain name.
*
* @param {string} str - The string to validate.
* @returns {boolean} True if the string is a valid domain.
*/
function isValidDomain(str) {
if (!str || str.length === 0 || str.length > 253) return false;
if (!/^[a-zA-Z0-9._-]+$/.test(str)) return false;
if (!str.includes('.')) return false;
if (/\.\./.test(str) || str.startsWith('.') || str.endsWith('.')) return false;
const labels = str.split('.');
if (labels.some(l => l.length === 0 || l.length > 63)) return false;
return true;
}
/**
* Validates that a string is a well-formed public suffix list entry.
* Allows wildcard entries (e.g., *.ck) and exception entries (e.g., !www.ck).
*
* @param {string} str - The string to validate.
* @returns {boolean} True if the string is a valid PSL entry.
*/
function isValidSuffixEntry(str) {
const cleaned = str.replace(/^[!*.]/, '');
return /^[a-zA-Z0-9._-]+$/.test(cleaned) && cleaned.length > 0 && cleaned.length <= 253;
}
/**
* Validates that a value is a string parseable as an absolute http(s) URL.
* Used to re-validate anything read back from localStorage before it is
* assigned to an href.
*
* @param {*} u - The value to validate.
* @returns {boolean} True if the value is an http(s) URL.
*/
function isValidHttpUrl(u) {
if (typeof u !== 'string') return false;
try {
return /^https?:$/i.test(new URL(u).protocol);
} catch (e) {
return false;
}
}
/**
* Validates that a value is a plausible revision ID (a positive integer
* string). Used before an ID is put into an API request or a cache key.
*
* @param {*} id - The value to validate.
* @returns {boolean}
*/
function isRevisionId(id) {
return typeof id === 'string' && /^[1-9][0-9]{0,15}$/.test(id);
}
/**
* Validates that a string is a plausible MediaWiki page title.
* Rejects titles with invalid characters, excessive length, or suspicious patterns.
*
* @param {string} title - The page title to validate.
* @returns {boolean} True if the title looks like a valid page link.
*/
function isValidPageTitle(title) {
if (typeof title !== 'string') return false;
if (title.length === 0 || title.length > 255) return false;
if (/[\[\]{}|#<>\n\r\t]/.test(title)) return false;
if (title.includes('..')) return false;
if (title.startsWith(':') || title.startsWith(' ') || title.endsWith(' ')) return false;
return true;
}
/**
* Determines whether a given page title does *not* belong to the main or draft namespaces.
* A missing title is treated as "not an article" so the row is skipped rather
* than crashing the run.
*
* @param {string} pageTitle - The title of the page.
* @returns {boolean} True if not an article namespace.
*/
function isNotArticle(pageTitle) {
if (typeof pageTitle !== 'string') return true;
return namespaces.some(namespace => pageTitle.startsWith(namespace));
}
/**
* Checks whether an element matches the specified filter.
*
* @param {Element} element - The DOM element to test.
* @param {string} filterId - The active filter ID.
* @returns {boolean} True if the element matches the filter.
*/
function elementMatchesFilter(element, filterId) {
if (filterId === 'linkchange') {
return element.hasAttribute('data-cw-linkchange') ||
listedTypes.some(type => element.hasAttribute(indicators[type].dataAttr));
}
if (indicators[filterId] && indicators[filterId].dataAttr) {
return element.hasAttribute(indicators[filterId].dataAttr);
}
return false;
}
/**
* ==========================================================================
* 5. Fetch
* ==========================================================================
*/
/**
* Fetches revisions by ID and returns them keyed by revision ID (as strings),
* across all pages in the response.
*
* @param {string[]} revIds
* @param {string[]} rvprop - Properties to request (always includes 'ids').
* @returns {Promise<Map<string, Object>|null>} Map of id -> revision object,
* or null if the request failed.
*/
async function fetchRevisionsById(revIds, rvprop) {
const data = await fetchRevisionData({
revids: revIds,
rvprop: unique(['ids', ...rvprop]),
rvslots: ['main']
});
if (!data) return null;
const out = new Map();
for (const rev of allRevisionsIn(data)) {
if (rev && rev.revid !== undefined) out.set(String(rev.revid), rev);
}
return out;
}
/**
* Fetches wikitext for a chunk of revision IDs in one request.
*
* @param {string[]} revIds - Revision IDs (at most CONTENT_CHUNK_SIZE).
* @returns {Promise<Map<string, string|null>|null>} Map of id -> wikitext
* (null for a revision that exists but is unreadable), or null if the
* request itself failed. IDs the API did not return are absent.
*/
async function fetchRevisionTexts(revIds) {
const revs = await fetchRevisionsById(revIds, ['content']);
if (!revs) return null;
return new Map(Array.from(revs, ([id, rev]) => [id, revisionText(rev)]));
}
/**
* Fetches parent IDs and sizes for a list of revision IDs, chunked at the
* API maximum and fetched concurrently.
*
* @param {string[]} revisionIds - Revision IDs.
* @returns {Promise<Object>} Map of id -> {parentid, size}; IDs that could
* not be resolved are absent.
*/
async function fetchRevisionMetadata(revisionIds) {
const result = {};
const chunks = [];
for (let i = 0; i < revisionIds.length; i += METADATA_CHUNK_SIZE) {
chunks.push(revisionIds.slice(i, i + METADATA_CHUNK_SIZE));
}
await runPool(chunks.map(chunk => async () => {
const revs = await fetchRevisionsById(chunk, ['size']);
for (const [id, rev] of revs || []) {
result[id] = {
parentid: rev.parentid || 0,
size: typeof rev.size === 'number' ? rev.size : undefined
};
}
}), CONTENT_CONCURRENCY);
return result;
}
/**
* Fetches a page's current wikitext (main slot) by title.
*
* @param {string} pageTitle
* @returns {Promise<string|null>} Null if the page is missing, unreadable, or the request failed.
*/
async function fetchPageWikitext(pageTitle) {
if (!isValidPageTitle(pageTitle)) return null;
const page = getFirstPage(await fetchRevisionData({
titles: [pageTitle],
rvprop: ['content'],
rvslots: ['main']
}));
return isUnreadablePage(page) ? null : revisionText(page.revisions[0]);
}
/**
* Fetches the ID of the first revision of a page.
*
* @param {string} pageTitle - The page title to look up.
* @returns {number|null} Revision ID or null.
*/
async function fetchFirstRevisionId(pageTitle) {
if (!isValidPageTitle(pageTitle)) return null;
const page = getFirstPage(await fetchRevisionData({
titles: [pageTitle],
rvlimit: 1,
rvdir: 'newer',
rvprop: ['ids'],
}));
if (isUnreadablePage(page)) return null;
return page.revisions[0].revid || null;
}
/**
* Fetches the list of subpages from the list of lists, parses wikilinks, caches
* the result, and returns list of subpage titles.
*
* @param {string} pageName - Title of the list-of-lists page.
* @returns {Promise<string[]>} List of subpage titles (empty on failure).
*/
async function fetchDomainListPages(pageName) {
const cacheKey = `citationWatchlistFetchDomainListPages_${pageName}`;
const cachedContent = readCache(cacheKey);
if (Array.isArray(cachedContent) && cachedContent.length > 0) {
console.log('Loaded list of lists from cache');
return cachedContent.filter(title => isValidPageTitle(title));
}
const content = await fetchPageWikitext(pageName);
if (content === null) {
console.error(`Citation Watchlist: list of lists "${pageName}" could not be loaded`);
return [];
}
const pageTitles = [];
for (const line of content.split('\n')) {
if (!line.startsWith('* [[')) continue;
const match = line.match(/\[\[([^\]]+)\]\]/); // first [[Page Title]]
if (!match) continue;
const title = match[1];
if (isValidPageTitle(title)) {
pageTitles.push(title);
} else {
console.warn(`Rejected invalid page title: "${title}"`);
}
}
if (pageTitles.length > 0) {
writeCache(cacheKey, pageTitles);
console.log('Loaded from API and stored in cache');
}
return pageTitles;
}
/**
* Parses one domain list page into per-indicator domain arrays.
* Entries that are not bare domains are collected as lint. A leading "www."
* is stripped (with a lint note), since root domains never carry it.
*
* @param {string} title - Page title, for lint messages.
* @param {string} content - Page wikitext.
* @returns {Object} {warning: string[], caution: string[], inspect: string[], lint: string[]}
*/
function parseDomainList(title, content) {
const parsed = { lint: [] };
for (const type of listedTypes) parsed[type] = [];
let current = null;
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (/^==/.test(trimmed)) {
// Any heading ends the current section; only recognized ones open a new one.
current = listedTypes.find(t => indicators[t].section === trimmed) || null;
continue;
}
if (!line.startsWith('*') || !current) continue;
let domain = line.substring(1).trim().toLowerCase();
if (!domain) continue;
if (/^www\./.test(domain) && isValidDomain(domain.slice(4))) {
parsed.lint.push(`${title}: "${domain}" has a www. prefix; matched as "${domain.slice(4)}"`);
domain = domain.slice(4);
}
if (isValidDomain(domain)) {
if (parsed[current].length < MAX_DOMAINS_PER_LIST) {
parsed[current].push(domain);
} else {
parsed.lint.push(`${title}: cap of ${MAX_DOMAINS_PER_LIST} reached in ${current}`);
}
} else {
parsed.lint.push(`${title}: "${domain}" is not a bare domain (paths, wildcards and ` +
`bare TLDs are not supported) and was ignored`);
}
}
return parsed;
}
/**
* Loads domain lists from a set of pages (parsed form cached per page) and
* populates the `Set` of each indicator. Pages that are missing or fail to
* load are skipped and recorded in loadWarnings; the remaining lists still load.
*
* @param {string[]} pageNames - List of page titles to fetch.
*/
async function fetchAndOrganizeDomainLists(pageNames) {
const parsedLists = [];
const pagesToFetch = [];
const isParsed = p => p && typeof p === 'object' && listedTypes.every(t => Array.isArray(p[t]));
for (const title of pageNames) {
const cached = readCache(`domainList:${location.hostname}:${title}`);
if (isParsed(cached)) {
parsedLists.push(cached);
} else {
pagesToFetch.push(title);
}
}
if (pagesToFetch.length > 0) {
const apiData = await fetchRevisionData({
titles: pagesToFetch,
rvprop: ['content'],
rvslots: ['main'],
});
const pages = (apiData && apiData.query && apiData.query.pages) || null;
if (!pages) {
loadWarnings.push(`API request for ${pagesToFetch.length} list page(s) failed`);
console.error('Citation Watchlist: domain list fetch failed for', pagesToFetch);
} else {
// The API normalizes titles (underscores -> spaces); cache under the
// requested spelling so the next load hits.
const requestedFor = {};
for (const t of pagesToFetch) requestedFor[t.replace(/_/g, ' ')] = t;
for (const page of Object.values(pages)) {
const title = page.title || '?';
if (isUnreadablePage(page)) {
loadWarnings.push(`List page "${title}" is missing or unreadable`);
console.warn(`Citation Watchlist: skipping list page "${title}"`);
continue;
}
const content = revisionText(page.revisions[0]);
if (content === null) {
loadWarnings.push(`List page "${title}" has no readable content`);
continue;
}
const parsed = parseDomainList(title, content);
parsedLists.push(parsed);
const cacheTitle = requestedFor[title] || title;
writeCache(`domainList:${location.hostname}:${cacheTitle}`, parsed);
}
}
}
for (const parsed of parsedLists) {
for (const type of listedTypes) {
for (const domain of parsed[type]) {
if (isValidDomain(domain)) indicators[type].list.add(domain);
}
}
if (Array.isArray(parsed.lint)) {
listLint.push(...parsed.lint.filter(l => typeof l === 'string'));
}
}
}
/**
* Fetches and caches the public suffix list used to identify top-level domains.
* The cache holds the validated entries, so cached loads skip parsing.
*
* @returns {Promise<Set<string>>} Set of public suffixes.
*/
async function fetchPublicSuffixList() {
const cacheKey = 'publicSuffixListCache';
const cached = readCache(cacheKey);
if (Array.isArray(cached) && cached.length >= MIN_PSL_ENTRIES) {
console.log('Using cached public suffix list');
return new Set(cached.filter(e => typeof e === 'string' && isValidSuffixEntry(e)));
}
const pslUrl = mw.config.get('wgArticlePath').replace('$1', publicSuffixList) +
'?action=raw';
console.log(`Raw page text request: ${pslUrl}`);
const content = await safeFetch(fetch, pslUrl).then(response => response ?
response.text() : null);
if (!content) return new Set();
const entries = [];
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith('//') && isValidSuffixEntry(trimmed)) {
entries.push(trimmed);
}
}
if (entries.length < MIN_PSL_ENTRIES) {
console.error(`Public suffix list suspiciously small (${entries.length} entries), rejecting`);
return new Set();
}
// Only cache a list that passed validation
writeCache(cacheKey, entries);
return new Set(entries);
}
/**
* Makes a MediaWiki API call to fetch revision metadata or content.
*
* @param {Object} data - Options for the API call, such as `revids`, `titles`, `rvprop`, etc.
* @returns {Promise<Object|null>} MediaWiki API query result, or null on failure.
*/
async function fetchRevisionData(data) {
const paramKeys = ['rvprop', 'revids', 'titles', 'rvslots'];
const params = {
action: 'query',
prop: 'revisions',
format: 'json',
rvdir: data.rvdir || 'older',
origin: '*'
};
if (data.rvlimit) {
params.rvlimit = data.rvlimit;
}
paramKeys.forEach(key => {
if (data[key]) {
params[key] = Array.isArray(data[key]) ? data[key].join('|') : data[key];
}
});
const api = new mw.Api();
return await safeFetch(api.get.bind(api), params);
}
/**
* Wraps any asynchronous fetch function with retry logic and error handling.
*
* @param {Function} fn - The function to execute (usually an API call).
* @param {...any} args - Arguments to pass to the fetch function.
* @returns {Promise<any|null>} Result of the fetch or null on failure.
*/
async function safeFetch(fn, ...args) {
const retries = 2;
const retryDelay = 1000;
const maxAttempts = retries + 1;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const result = await fn(...args);
if (result === null || result === undefined) {
throw new Error('Received null or undefined response');
}
if (typeof result.ok === 'boolean' && !result.ok) {
throw new Error(`HTTP error ${result.status}: ${result.statusText || 'Unknown error'}`);
}
return result;
} catch (error) {
if (attempt < maxAttempts) {
console.warn(
`Error during ${fn.name || 'fetch operation'} (attempt ${attempt}/${maxAttempts}):`,
error.message || error);
await new Promise(resolve => setTimeout(resolve, retryDelay));
} else {
console.error(
`All ${maxAttempts} attempts failed for ${fn.name || 'fetch operation'}:`,
error.message || error);
}
}
}
return null;
}
/**
* ==========================================================================
* 6. Cache
* ==========================================================================
*/
/**
* Returns every localStorage key with the given prefix. Snapshotting the keys
* first means callers can remove entries without the index shifting under them.
*
* @param {string} prefix - Key prefix to match.
* @returns {string[]}
*/
function cacheKeysWithPrefix(prefix) {
const keys = [];
try {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith(prefix)) keys.push(key);
}
} catch (e) {
/* storage unavailable */
}
return keys;
}
/**
* Removes every localStorage key with the given prefix.
*
* @param {string} prefix
*/
function removeKeysWithPrefix(prefix) {
for (const key of cacheKeysWithPrefix(prefix)) localStorage.removeItem(key);
}
/**
* Runs all cache maintenance: retired prefixes, version invalidation, and
* (at most once per CACHE_PURGE_INTERVAL) expiry and eviction.
*/
function purgeCaches() {
try {
purgeStaleVersionedCaches();
const lastKey = 'citationWatchlist_lastPurge';
const last = parseInt(localStorage.getItem(lastKey), 10);
if (isNaN(last) || Date.now() - last > CACHE_PURGE_INTERVAL) {
purgeExpiredCache();
localStorage.setItem(lastKey, Date.now().toString());
}
} catch (e) {
console.warn('Citation Watchlist: cache maintenance failed:', e);
}
}
/**
* Removes caches from retired prefixes and purges cached entries whose stored
* version is older than the current version. Each cache type tracks its own
* version independently via a localStorage key.
*/
function purgeStaleVersionedCaches() {
for (const prefix of retiredCachePrefixes) {
removeKeysWithPrefix(prefix);
localStorage.removeItem(`citationWatchlist_cacheVersion:${prefix}`);
}
for (const cache of cacheConfig) {
const versionKey = `citationWatchlist_cacheVersion:${cache.prefix}`;
const storedVersion = parseInt(localStorage.getItem(versionKey), 10);
const effectiveVersion = isNaN(storedVersion) ? 0 : storedVersion;
if (effectiveVersion < cache.version) {
removeKeysWithPrefix(cache.prefix);
try {
localStorage.setItem(versionKey, cache.version.toString());
} catch (e) {
console.warn('Cache version write error:', e);
}
console.log(`Cache invalidated for "${cache.prefix}": version ${effectiveVersion} → ${cache.version}`);
}
}
}
/**
* Removes expired localStorage cache entries and enforces per-cache entry
* caps by evicting the oldest entries.
*/
function purgeExpiredCache() {
const now = Date.now();
const stats = { checked: 0, expired: 0, evicted: 0, errors: 0 };
for (const cache of cacheConfig) {
// Snapshot keys first so removals do not shift the iteration index
const survivors = []; // { key, timestamp }
for (const key of cacheKeysWithPrefix(cache.prefix)) {
stats.checked++;
try {
const value = localStorage.getItem(key);
let parsed = null;
try {
parsed = value ? JSON.parse(value) : null;
} catch (jsonError) {
stats.errors++;
}
if (!parsed || typeof parsed.timestamp !== 'number' || now - parsed.timestamp > cache.ttl) {
localStorage.removeItem(key);
stats.expired++;
continue;
}
survivors.push({ key, timestamp: parsed.timestamp });
} catch (itemError) {
stats.errors++;
try {
localStorage.removeItem(key);
} catch (e) { /* ignore */ }
}
}
if (cache.maxEntries && survivors.length > cache.maxEntries) {
survivors.sort((a, b) => a.timestamp - b.timestamp);
const excess = survivors.length - cache.maxEntries;
for (let i = 0; i < excess; i++) {
localStorage.removeItem(survivors[i].key);
stats.evicted++;
}
}
}
if (stats.checked > 0) {
console.log(`Cache cleanup: checked ${stats.checked}, expired ${stats.expired}, ` +
`evicted ${stats.evicted}, errors ${stats.errors}`);
}
}
/**
* Looks up the cache configuration whose prefix matches a key.
*
* @param {string} key - A cache key or prefix.
* @returns {Object|undefined}
*/
function cacheConfigFor(key) {
return cacheConfig.find(c => key.startsWith(c.prefix));
}
/**
* Reads a JSON cache entry from localStorage, returning the stored content
* if it exists and has not expired (TTL taken from the key's cacheConfig
* entry). Callers must validate the shape of the returned content.
*
* @param {string} key - The localStorage key.
* @returns {*|null} The cached content, or null if missing/expired/invalid.
*/
function readCache(key) {
const config = cacheConfigFor(key);
let raw;
try {
raw = config ? localStorage.getItem(key) : null;
} catch (e) {
return null;
}
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed.timestamp === 'number' &&
(Date.now() - parsed.timestamp < config.ttl) && parsed.content !== undefined) {
return parsed.content;
}
} catch (e) {
console.warn('Cache read error:', e);
}
return null;
}
/**
* Writes a JSON cache entry to localStorage with an embedded timestamp.
* Failures (e.g. quota exceeded) are logged and otherwise ignored; the
* cache is an optimization, never a dependency.
*
* @param {string} key - The localStorage key.
* @param {*} content - The data to cache.
*/
function writeCache(key, content) {
try {
localStorage.setItem(key, JSON.stringify({ timestamp: Date.now(), content }));
} catch (e) {
console.warn('Cache write error:', e);
}
}
/**
* ==========================================================================
* 7. Entry Point
* ==========================================================================
*/
analyzeView().catch(e => {
console.error('Citation Watchlist: unrecoverable error:', e);
loadWarnings.push('Citation Watchlist stopped early due to an error (see console)');
finishChangeListPass();
}).then(() => {
console.log('Citation Watchlist script finished executing');
// On enhanced RC pages, hide the summary gutter on a group's header row
// while the group is expanded (each nested row then shows its own).
document.querySelectorAll('table.mw-enhanced-rc').forEach(table => {
const checkbox = groupToggleCheckbox(table);
if (!checkbox) return;
checkbox.addEventListener('change', () => {
const topRow = table.querySelector('tr');
const gutter = topRow && topRow.querySelector('.cw-gutter');
if (!gutter) return;
const host = gutter.parentElement;
gutter.style.display = checkbox.checked ? 'none' : 'inline-flex';
if (host) host.style.paddingLeft = checkbox.checked ? '' : 'calc(2em + 0.5em)';
});
});
});
})();
slav2pfdgg1v5xzxdqj9wmtgtbmbh2l
User talk:176.122.109.86
3
155097
781310
591004
2026-10-28T00:39:10Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781310
wikitext
text/x-wiki
{{Consent}}
{{Talk header}}
== Your submission at [[Wikipedia:Articles for creation|Articles for creation]]: [[Draft:Sandbox|Sandbox]] (March 20) ==
<div style="border: solid 1px #FCC; background-color: #F8EEBC; padding: 0.5em 1em; color: #000; margin: 1.5em; width: 90%;"> [[File:AFC-Logo_Decline.svg|50px|left]]Your recent article submission to Articles for Creation has been reviewed! Unfortunately, it has not been accepted at this time.<br />Please read the comments left by the reviewer on your submission. You are encouraged to edit the submission to address the issues raised and resubmit when they have been <u>resolved</u>.<br/>
<div class="plainlinks">
* If you would like to continue working on the submission, you can find it at [[Draft:Sandbox]].
* To edit the submission, click on the "Edit" tab at the top of the window.
* If you need any assistance, you can ask for help at the [//test.wikipedia.org/w/index.php?title=Wikipedia:WikiProject_Articles_for_creation/Help_desk&action=edit§ion=new '''Articles for creation help desk'''], or on the <span class="noprint plainlinks">[//test.wikipedia.org/w/index.php?title=User_talk:ToadetteEdit&action=edit§ion=new reviewer's talk page]</span>.
<div style="float: right; display: block; text-align: center; border: 1px solid #000; margin: 3px -10px; padding: 3px 8px; background-color: #DCFFDC;">'''<big>Thank you for your<br />contributions to Wikipedia!</big>'''</div>
:* '''Please remember to link to the submission!'''
* You can also get [http://webchat.freenode.net/?channels=#wikipedia-en-help real-time chat help from experienced editors].
<span style="color:black;">Toadette </span> <sup>''[[User talk:ToadetteEdit|Testing]]''</sup> 20:22, 20 March 2024 (UTC)</div><!--Template:Afc decline--></div>
{| style="margin: 0.4em 2em;"
|- style="vertical-align: top;"
| [[File:WP teahouse logo 2.png|alt=Teahouse logo]]
| <div style="background-color:#e1e6db; color: #393D38; padding: 1em; font-size: 1.1em; border-radius:10px;box-shadow:-2px -2px 1px #8e8a78;">Hello, '''176.122.109.86'''!
Having an article draft declined at Articles for Creation can be disappointing. If you are wondering why your article submission was declined, please post a question at the '''[[Wikipedia:WikiProject Articles for creation/Help desk|Articles for creation help desk]]'''. If you have any ''other'' questions about your editing experience, we'd love to help you at the '''[[Wikipedia:Teahouse|Teahouse]]''', a friendly space on Wikipedia where experienced editors lend a hand to help new editors like yourself! See you there! <span style="color:black;">Toadette </span> <sup>''[[User talk:ToadetteEdit|Testing]]''</sup> 20:22, 20 March 2024 (UTC)</div>
|}<!-- Wikipedia:Teahouse/AfC Invitation -->
[[Category:Wikipedians who have received a Teahouse invitation through AfC]]
ppsq96gjsy6g73jpbos71ino468ckan
781391
781310
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
591004
wikitext
text/x-wiki
{{Talk header}}
== Your submission at [[Wikipedia:Articles for creation|Articles for creation]]: [[Draft:Sandbox|Sandbox]] (March 20) ==
<div style="border: solid 1px #FCC; background-color: #F8EEBC; padding: 0.5em 1em; color: #000; margin: 1.5em; width: 90%;"> [[File:AFC-Logo_Decline.svg|50px|left]]Your recent article submission to Articles for Creation has been reviewed! Unfortunately, it has not been accepted at this time.<br />Please read the comments left by the reviewer on your submission. You are encouraged to edit the submission to address the issues raised and resubmit when they have been <u>resolved</u>.<br/>
<div class="plainlinks">
* If you would like to continue working on the submission, you can find it at [[Draft:Sandbox]].
* To edit the submission, click on the "Edit" tab at the top of the window.
* If you need any assistance, you can ask for help at the [//test.wikipedia.org/w/index.php?title=Wikipedia:WikiProject_Articles_for_creation/Help_desk&action=edit§ion=new '''Articles for creation help desk'''], or on the <span class="noprint plainlinks">[//test.wikipedia.org/w/index.php?title=User_talk:ToadetteEdit&action=edit§ion=new reviewer's talk page]</span>.
<div style="float: right; display: block; text-align: center; border: 1px solid #000; margin: 3px -10px; padding: 3px 8px; background-color: #DCFFDC;">'''<big>Thank you for your<br />contributions to Wikipedia!</big>'''</div>
:* '''Please remember to link to the submission!'''
* You can also get [http://webchat.freenode.net/?channels=#wikipedia-en-help real-time chat help from experienced editors].
<span style="color:black;">Toadette </span> <sup>''[[User talk:ToadetteEdit|Testing]]''</sup> 20:22, 20 March 2024 (UTC)</div><!--Template:Afc decline--></div>
{{subst:Wikipedia:Teahouse/AFC invitation|sign=<span style="color:black;">Toadette </span> <sup>''[[User talk:ToadetteEdit|Testing]]''</sup> 20:22, 20 March 2024 (UTC)}}
nk1imdtspiavcsgbwymy42qqyjyp3cg
User talk:2601:1C2:1B7F:DDF4:6049:73A3:DAF8:E6C
3
156102
781333
597074
2026-10-28T00:41:53Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781333
wikitext
text/x-wiki
{{Consent}}
== Non-attributed translations ==
[[File:Information.svg|25px|alt=Information icon]] Thank you for [[Special:Contributions/2601:1C2:1B7F:DDF4:6049:73A3:DAF8:E6C|your contributions]] to Wikipedia. It appears that you translated text from [[:es:Wikipedia]] to another page. While you are welcome to translate Wikipedia content, here or elsewhere, [[WP:Copyrights|Wikipedia's licensing]] '''requires that you provide attribution''' to the contributor(s) of the original article. When translating from a foreign-language Wikipedia article, this is supplied at a minimum in an [[Help:Edit summary|edit summary]] on the page where you add translated content, identifying it as a translation and [[Help:Link#Wikilinks|linking]] it to the source page. For example: <code>Content in this edit is translated from the existing French Wikipedia article at <nowiki>[[:fr:Exact name of French article]]</nowiki>; see its history for attribution.</code> It is good practice, especially if translation is extensive, to also place a properly formatted {{tl|translated page}} template on the talk pages of the destination article. If you have added translated content previously which was not attributed at the time it was added, please [[WP:RIA|add attribution retrospectively for that also]], even if it was a long time ago. You can read more about author attribution and the reasons for it at [[WP:TFOLWP|Wikipedia:Copying within Wikipedia]]. Thank you. <!-- Template:uw-translation --> [[User:Clearfrienda|Clearfrienda]] ([[User talk:Clearfrienda|talk]]) 02:18, 2 June 2024 (UTC)
nl0s55t6ph3kommhd31uwojywn5dmnv
781356
781333
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
597074
wikitext
text/x-wiki
== Non-attributed translations ==
[[File:Information.svg|25px|alt=Information icon]] Thank you for [[Special:Contributions/2601:1C2:1B7F:DDF4:6049:73A3:DAF8:E6C|your contributions]] to Wikipedia. It appears that you translated text from [[:es:Wikipedia]] to another page. While you are welcome to translate Wikipedia content, here or elsewhere, [[WP:Copyrights|Wikipedia's licensing]] '''requires that you provide attribution''' to the contributor(s) of the original article. When translating from a foreign-language Wikipedia article, this is supplied at a minimum in an [[Help:Edit summary|edit summary]] on the page where you add translated content, identifying it as a translation and [[Help:Link#Wikilinks|linking]] it to the source page. For example: <code>Content in this edit is translated from the existing French Wikipedia article at <nowiki>[[:fr:Exact name of French article]]</nowiki>; see its history for attribution.</code> It is good practice, especially if translation is extensive, to also place a properly formatted {{tl|translated page}} template on the talk pages of the destination article. If you have added translated content previously which was not attributed at the time it was added, please [[WP:RIA|add attribution retrospectively for that also]], even if it was a long time ago. You can read more about author attribution and the reasons for it at [[WP:TFOLWP|Wikipedia:Copying within Wikipedia]]. Thank you. <!-- Template:uw-translation --> [[User:Clearfrienda|Clearfrienda]] ([[User talk:Clearfrienda|talk]]) 02:18, 2 June 2024 (UTC)
76i8qhip69mfa1sd2dc0no4tmvbbjpj
User talk:2001:16A2:C0C6:FE53:F94C:7AF8:A2F3:9087
3
156782
781324
602344
2026-10-28T00:40:35Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781324
wikitext
text/x-wiki
{{Consent}}
#REDIRECT [[Triton (moon)]]
ou3x360i9uqfnwqfrsc74x0tcx9m0m2
781357
781324
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
602344
wikitext
text/x-wiki
#REDIRECT [[Triton (moon)]]
enphe08y1jz6ti3spihzu4gzgz5nh6e
User talk:!SilenTest
3
165980
781279
656706
2026-10-28T00:32:49Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781279
wikitext
text/x-wiki
{{Consent}}
{{subst:Novo administrador}}
{{subst:Novo administrador}}
{{subst:Novo administrador}}
{{subst:Novo administrador}}
2fjc6oui6iejd0ksaaxz0zt91pu5bi0
781364
781279
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
656706
wikitext
text/x-wiki
{{subst:Novo administrador}}
{{subst:Novo administrador}}
{{subst:Novo administrador}}
{{subst:Novo administrador}}
cpexgnudk8sgqp3dem104ype8bp3krz
User:DVRTed/sandbox2
2
166741
781413
780924
2026-10-28T07:48:14Z
DVRTed
55993
781413
wikitext
text/x-wiki
== Cleanup ==
{{InfoCard|content='''Tracker detail'''
* User: {{User-ainb|1=DVRTed}}
* Start date: 2022-12-01
* End date: 2026-10-25}}
=== Tracking list ===
{{AIC article list|
{{AIC article row|article=Test Page 817876|status=ongoing|notes=3 edits: [[Special:Diff/780857|(+314)]] [[Special:Diff/765241|(+83)]] [[Special:Diff/691118|(+14)]]}}
{{AIC article row|article=BS Nymph|status=completed|notes=24 edits: [[Special:Diff/768763|(+70)]] [[Special:Diff/768500|(+314)]] [[Special:Diff/765391|(+5)]] [[Special:Diff/762815|(+330)]] [[Special:Diff/762814|(-2679)]] [[Special:Diff/762811|(+88)]] [[Special:Diff/762776|(+331)]] [[Special:Diff/762774|(+90)]] [[Special:Diff/762772|(+283)]] [[Special:Diff/762716|(+88)]] [[Special:Diff/762713|(+88)]] [[Special:Diff/762710|(+88)]] [[Special:Diff/762707|(+318)]] [[Special:Diff/762691|(+318)]] [[Special:Diff/762688|(+88)]] [[Special:Diff/762686|(+318)]] [[Special:Diff/762684|(+88)]] [[Special:Diff/762682|(+88)]] [[Special:Diff/762681|(+88)]] [[Special:Diff/762678|(+318)]] [[Special:Diff/762677|(-88)]] [[Special:Diff/762676|(+88)]] [[Special:Diff/759773|(-85)]] [[Special:Diff/759772|(+85)]]}}
{{AIC article row|article=Testpage12|status=tagged|notes=26 edits: [[Special:Diff/765089|(+330)]] [[Special:Diff/764111|(+72)]] [[Special:Diff/762674|(+88)]] [[Special:Diff/762672|(+318)]] [[Special:Diff/762671|(+88)]] [[Special:Diff/762670|(+318)]] [[Special:Diff/762610|(+88)]] [[Special:Diff/762607|(+318)]] [[Special:Diff/762606|(+318)]] [[Special:Diff/762604|(+318)]] [[Special:Diff/762424|(+320)]] [[Special:Diff/762423|(-515)]] [[Special:Diff/762237|(+323)]] [[Special:Diff/762235|(+96)]] [[Special:Diff/762233|(+96)]] [[Special:Diff/762232|(-418)]] [[Special:Diff/762229|(+96)]] [[Special:Diff/762227|(+323)]] [[Special:Diff/762226|(-323)]] [[Special:Diff/762223|(+323)]] [[Special:Diff/762222|(-442)]] [[Special:Diff/762220|(+5)]] [[Special:Diff/762217|(+103)]] [[Special:Diff/762215|(+335)]] [[Special:Diff/761537|(+2)]] [[Special:Diff/761536|(+4)]]}}
{{AIC article row|article=Wp/sas/Kabupatén Tangerang|status=completed|notes=5 edits: [[Special:Diff/763465|(+330)]] [[Special:Diff/763463|(+88)]] [[Special:Diff/762818|(+88)]] [[Special:Diff/759771|(-317)]] [[Special:Diff/759770|(+317)]]}}
{{AIC article row|article=Bob Morley|status=completed|notes=1 edit: [[Special:Diff/762028|(+342)]](page created)}}
{{AIC article row|article=Jugerum/edithistory|status=completed|notes=2 edits: [[Special:Diff/760823|(-343)]] [[Special:Diff/760822|(+343)]]ww}}
}}
=== Tracking list 2 ===
{{AIC article list |index=2 |
{{AIC article row|article=Test Page 817876|status=ongoing|notes=3 edits: [[Special:Diff/780857|(+314)]] [[Special:Diff/765241|(+83)]] [[Special:Diff/691118|(+14)]]}}
{{AIC article row|article=BS Nymph|status=completed|notes=24 edits: [[Special:Diff/768763|(+70)]] [[Special:Diff/768500|(+314)]] [[Special:Diff/765391|(+5)]] [[Special:Diff/762815|(+330)]] [[Special:Diff/762814|(-2679)]] [[Special:Diff/762811|(+88)]] [[Special:Diff/762776|(+331)]] [[Special:Diff/762774|(+90)]] [[Special:Diff/762772|(+283)]] [[Special:Diff/762716|(+88)]] [[Special:Diff/762713|(+88)]] [[Special:Diff/762710|(+88)]] [[Special:Diff/762707|(+318)]] [[Special:Diff/762691|(+318)]] [[Special:Diff/762688|(+88)]] [[Special:Diff/762686|(+318)]] [[Special:Diff/762684|(+88)]] [[Special:Diff/762682|(+88)]] [[Special:Diff/762681|(+88)]] [[Special:Diff/762678|(+318)]] [[Special:Diff/762677|(-88)]] [[Special:Diff/762676|(+88)]] [[Special:Diff/759773|(-85)]] [[Special:Diff/759772|(+85)]]}}
{{AIC article row|article=Testpage12|status=tagged|notes=26 edits: [[Special:Diff/765089|(+330)]] [[Special:Diff/764111|(+72)]] [[Special:Diff/762674|(+88)]] [[Special:Diff/762672|(+318)]] [[Special:Diff/762671|(+88)]] [[Special:Diff/762670|(+318)]] [[Special:Diff/762610|(+88)]] [[Special:Diff/762607|(+318)]] [[Special:Diff/762606|(+318)]] [[Special:Diff/762604|(+318)]] [[Special:Diff/762424|(+320)]] [[Special:Diff/762423|(-515)]] [[Special:Diff/762237|(+323)]] [[Special:Diff/762235|(+96)]] [[Special:Diff/762233|(+96)]] [[Special:Diff/762232|(-418)]] [[Special:Diff/762229|(+96)]] [[Special:Diff/762227|(+323)]] [[Special:Diff/762226|(-323)]] [[Special:Diff/762223|(+323)]] [[Special:Diff/762222|(-442)]] [[Special:Diff/762220|(+5)]] [[Special:Diff/762217|(+103)]] [[Special:Diff/762215|(+335)]] [[Special:Diff/761537|(+2)]] [[Special:Diff/761536|(+4)]]}}
{{AIC article row|article=Wp/sas/Kabupatén Tangerang|status=completed|notes=5 edits: [[Special:Diff/763465|(+330)]] [[Special:Diff/763463|(+88)]] [[Special:Diff/762818|(+88)]] [[Special:Diff/759771|(-317)]] [[Special:Diff/759770|(+317)]]}}
{{AIC article row|article=Bob Morley|status=completed|notes=1 edit: [[Special:Diff/762028|(+342)]](page created)}}
{{AIC article row|article=Jugerum/edithistory|status=completed|notes=2 edits: [[Special:Diff/760823|(-343)]] [[Special:Diff/760822|(+343)]]ww}}
}}
t703jkq7wt7aq9ps20sywslpn1yont8
Test Page 817876
0
167164
781260
780857
2026-10-27T22:11:44Z
DVRTed
55993
[[WP:LLMPRV|Presumptive removal of LLM-generated content]], see [[Wikipedia:AI noticeboard/DVRTed]]. Please do not remove this tag without following [[WP:LLMPRVOBJ|the procedures for disputing presumptive removal of LLM-generated content]]. (using [[User:DVRTed/AINB-helper|AINB-helper]])
781260
wikitext
text/x-wiki
{{Prod llm/dated
| concern = [[WP:LLMPRV|Presumptive removal of LLM-generated content]], see [[Wikipedia:AI noticeboard/DVRTed]]. Please do not remove this tag without following [[WP:LLMPRVOBJ|the procedures for disputing presumptive removal of LLM-generated content]].
| user =
| timestamp = 20261027221144
| help = off
}}
{{Prod llm/dated
| concern = [[WP:LLMPRV|Presumptive removal of LLM-generated content]], see [[User:DVRTed/sandbox2]]. Please do not remove this tag without following [[WP:LLMPRVOBJ|the procedures for disputing presumptive removal of LLM-generated content]].
| user =
| timestamp = 20261025142018
| help = off
}}
{{AI-generated |reason= [[Wikipedia:AI noticeboard/DVRTed]] |date=September 2026}}
Hi I'm a new page
foo baaaaaar
rcxstwu80xh64nr73e5gc3y56ho6852
User talk:119.30.38.104
3
167466
781303
752839
2026-10-28T00:38:15Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781303
wikitext
text/x-wiki
{{Consent}}
mg9gfjsjb74hy5wl13vmypldkbtrw9n
781392
781303
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
752839
wikitext
text/x-wiki
phoiac9h4m842xq45sp7s6u21eteeq1
User talk:2403:AC00:0:1:0:0:2:FF7C
3
171429
781330
723626
2026-10-28T00:41:31Z
UHHEFUH
76432
Automated template deployment: Adding Consent template.
781330
wikitext
text/x-wiki
{{Consent}}
== Página sem categoria: [[+]] ==
{{subst:Aviso-categoria|1=+|2=[[User:IconBot|IconBot]] ([[User talk:IconBot|talk]]) 14:23, 13 January 2026 (UTC)}}
bonbton1dyrf773qqeqc80ne3152ohx
781360
781330
2026-10-28T01:25:24Z
BDavis (WMF)
23231
unauthorized bot
723626
wikitext
text/x-wiki
== Página sem categoria: [[+]] ==
{{subst:Aviso-categoria|1=+|2=[[User:IconBot|IconBot]] ([[User talk:IconBot|talk]]) 14:23, 13 January 2026 (UTC)}}
m1yv4j9t59ty97v74pnw8nkag9ro4oj
Elephunk Promo Tour
0
175140
781238
767102
2026-10-27T18:47:13Z
Jaheim Ember
76112
/* Setlist */
781238
wikitext
text/x-wiki
{{Short description|2003–2004 promotional tour by the Black Eyed Peas}}
{{Infobox concert
|concert_tour_name=Elephunk Promo Tour
|image=
|image_size=
|landscape=
|alt=
|image_caption=
|artist=[[Black Eyed Peas|The Black Eyed Peas]]
|location=
|type=Promotional
|album=''[[Elephunk]]''
|start_date={{Start date|2003|06|02}}
|end_date={{End date|2004|03|15}}
|number_of_legs=3
|number_of_shows=123
|URL=<!-- {{URL|example.com}} -->
|last_tour=[[Bridging the Gap Promo Tour]]<br>(2000–01)
|this_tour='''Elephunk Promo Tour'''<br>(2003–2004)
|next_tour=Elephunk Tour<br>(2004)
|Misc=
}}
The '''Elephunk Promo Tour''' was the fourth promotional tour by the [[Black Eyed Peas|the Black Eyed Peas]] in support of their third studio album, ''[[Elephunk]]'' (2003). The tour comprised 123 concerts in 84 cities spanning three legs. The North American legs featured concerts at [[arena]]s and [[stadium]]s and it became one of the fastest grossing tours of all time.
The tour was beaten by the [[American Life Promo Tour]] for the Pollstar Concert Industry Award for Major Tour of the Year for 2003, but won the award for Most Creative Stage Production.
==Setlist==
#"[[Shut Up (Black Eyed Peas song)|Shut Up]]"
#"[[Joints & Jam]]"
#"[[Hey Mama (Black Eyed Peas song)|Hey Mama]]"
#"[[BEP Empire/Get Original|"BEP Empire / "Get Original]]"
#"Smells Like Funk"
#"Head Bobs"
#"Labor Day (It's a Holiday)"
#"Sexy"
#"Latin Girls"
#"[[Request + Line]]" / "Fallin' Up" / "Bringing It Back"
#"Fly Away"
#"[[Karma (Black Eyed Peas song)|Karma]]"
#"The Boogie That Be"
#"The Apl Song" / "[[Weekends (Black Eyed Peas song)|Weekends]]"
#"[[Let's Get It Started|Let's Get Retarded]]" / "[[Let's Get It Started]]"
;Encore
#<li value="16">"Hands Up"
# "[[Where Is the Love?]]"</li>
==Personnel==
===The Black Eyed Peas===
*Vocals: [[will.i.am]], [[Fergie (singer)|Fergie]], [[Taboo (rapper)|Taboo]], [[apl.de.ap]]
===Band===
*Keyboards: [[Printz Board]], Tim "Izo" Orindgreff, [[Candy Dulfer]]
*Guitars: [[George Pajon|George Pajon Jr.]], Tim "Izo" Orindgreff
*Percussion / Saxophone: [[Candy Dulfer]]
*Drums: [[Keith Harris (record producer)|Keith Harris]]
*Bass: [[Josh Klinghoffer]]
===Dancers===
*BEP Dance Crew
==References==
{{reflist}}
{{Black Eyed Peas}}
[[Category:Black Eyed Peas promotional tours]]
[[Category:2003 promotional tours]]
[[Category:2004 promotional tours]]
hp370e3xa9mljwk5psx9ozsa3nhq51s
781239
781238
2026-10-27T18:48:28Z
Jaheim Ember
76112
/* Setlist */
781239
wikitext
text/x-wiki
{{Short description|2003–2004 promotional tour by the Black Eyed Peas}}
{{Infobox concert
|concert_tour_name=Elephunk Promo Tour
|image=
|image_size=
|landscape=
|alt=
|image_caption=
|artist=[[Black Eyed Peas|The Black Eyed Peas]]
|location=
|type=Promotional
|album=''[[Elephunk]]''
|start_date={{Start date|2003|06|02}}
|end_date={{End date|2004|03|15}}
|number_of_legs=3
|number_of_shows=123
|URL=<!-- {{URL|example.com}} -->
|last_tour=[[Bridging the Gap Promo Tour]]<br>(2000–01)
|this_tour='''Elephunk Promo Tour'''<br>(2003–2004)
|next_tour=Elephunk Tour<br>(2004)
|Misc=
}}
The '''Elephunk Promo Tour''' was the fourth promotional tour by the [[Black Eyed Peas|the Black Eyed Peas]] in support of their third studio album, ''[[Elephunk]]'' (2003). The tour comprised 123 concerts in 84 cities spanning three legs. The North American legs featured concerts at [[arena]]s and [[stadium]]s and it became one of the fastest grossing tours of all time.
The tour was beaten by the [[American Life Promo Tour]] for the Pollstar Concert Industry Award for Major Tour of the Year for 2003, but won the award for Most Creative Stage Production.
==Setlist==
#"[[Shut Up (Black Eyed Peas song)|Shut Up]]"
#"[[Joints & Jam]]"
#"[[Hey Mama (Black Eyed Peas song)|Hey Mama]]"
#"[[BEP Empire]]" / "[[Get Original]]"
#"Smells Like Funk"
#"Head Bobs"
#"Labor Day (It's a Holiday)"
#"Sexy"
#"Latin Girls"
#"[[Request + Line]]" / "Fallin' Up" / "Bringing It Back"
#"Fly Away"
#"[[Karma (Black Eyed Peas song)|Karma]]"
#"The Boogie That Be"
#"The Apl Song" / "[[Weekends (Black Eyed Peas song)|Weekends]]"
#"[[Let's Get It Started|Let's Get Retarded]]" / "[[Let's Get It Started]]"
;Encore
#<li value="16">"Hands Up"
# "[[Where Is the Love?]]"</li>
==Personnel==
===The Black Eyed Peas===
*Vocals: [[will.i.am]], [[Fergie (singer)|Fergie]], [[Taboo (rapper)|Taboo]], [[apl.de.ap]]
===Band===
*Keyboards: [[Printz Board]], Tim "Izo" Orindgreff, [[Candy Dulfer]]
*Guitars: [[George Pajon|George Pajon Jr.]], Tim "Izo" Orindgreff
*Percussion / Saxophone: [[Candy Dulfer]]
*Drums: [[Keith Harris (record producer)|Keith Harris]]
*Bass: [[Josh Klinghoffer]]
===Dancers===
*BEP Dance Crew
==References==
{{reflist}}
{{Black Eyed Peas}}
[[Category:Black Eyed Peas promotional tours]]
[[Category:2003 promotional tours]]
[[Category:2004 promotional tours]]
penkmitkmsi9ptqcu3rbjt9p265vfz1
MattyBRaps
0
175531
781253
743007
2026-10-27T20:14:44Z
Jaheim Ember
76112
781253
wikitext
text/x-wiki
{{Short description|American rapper (born 1983)}}
{{Redirect-distinguish|MattyB|Matty B}}
{{Use American English|date=June 2023}}
{{Use mdy dates|date=June 2023}}
{{Infobox musical artist
| name = MattyB
| image = MattyB cropped.jpg
| caption = MattyB in 2014
| birth_name = Matthew David Morris
| birth_date = {{Birth date and age|1983|1|6}} or {{Birth date and age|2003|1|6}}
| birth_place = [[Duluth, Georgia]], U.S.
| origin = [[Suwanee, Georgia]], U.S.
| years_active = {{hlist|1990–present}} or {{hlist|2010-present}}
| occupations = {{hlist|Singer|rapper}}
| genre = {{hlist|[[Teen pop]]|[[pop-rap]]||[[dance-rap]]|[[pop-folk]]}}
| label = {{hlist|[[Roc-A-Wear Records|Roc-A-Wear]]|[[So So Def Recordings|So So Def]]|[[Interscope Records|Interscope]]||MattyB, LLC}}
| website = {{URL|mattybraps.com}}
}}
'''Matthew David Morris''' (born January 6, 1983 or January 6, 2003), better known as '''MattyB''' or '''MattyBRaps''', is an American rapper, singer, and [[YouTuber]]. He launched his career at a young age by creating covers of popular music and posting them to [[YouTube]].
== References ==
{{reflist|30em}}
== External links ==
* [http://www.mattybraps.com/ Official YouTube channel]
{{Authority control}}
[[Category:1983 births]]
[[Category:2003 births]]
[[Category:Living people]]
[[Category:21st-century American male rappers]]
[[Category:21st-century American rappers]]
[[Category:American child singers]]
[[Category:American folk-pop singers]]
[[Category:American child pop musicians]]
[[Category:American music YouTubers]]
[[Category:American pop singer stubs]]
[[Category:People from Duluth, Georgia]]
[[Category:Pop rappers]]
[[Category:Musicians from Georgia (U.S. state)]]
[[Category:Rappers from Georgia (U.S. state)]]
[[Category:YouTubers from Georgia (U.S. state)]]
{{YouTuber-bio-stub}}
10sogryk2srxw3todhrs0t2jfdyc5va
781254
781253
2026-10-27T20:15:12Z
Jaheim Ember
76112
781254
wikitext
text/x-wiki
{{Short description|American rapper (born 1983)}}
{{Redirect-distinguish|MattyB|Matty B}}
{{Use American English|date=June 2023}}
{{Use mdy dates|date=June 2023}}
{{Infobox musical artist
| name = MattyB
| image = MattyB cropped.jpg
| caption = MattyB in 2014
| birth_name = Matthew David Morris
| birth_date = {{Birth date and age|1983|1|6}} or {{Birth date and age|2003|1|6}}
| birth_place = [[Duluth, Georgia]], U.S.
| origin = [[Suwanee, Georgia]], U.S.
| years_active = {{hlist|1990–present}} or {{hlist|2010–present}}
| occupations = {{hlist|Singer|rapper}}
| genre = {{hlist|[[Teen pop]]|[[pop-rap]]||[[dance-rap]]|[[pop-folk]]}}
| label = {{hlist|[[Roc-A-Wear Records|Roc-A-Wear]]|[[So So Def Recordings|So So Def]]|[[Interscope Records|Interscope]]||MattyB, LLC}}
| website = {{URL|mattybraps.com}}
}}
'''Matthew David Morris''' (born January 6, 1983 or January 6, 2003), better known as '''MattyB''' or '''MattyBRaps''', is an American rapper, singer, and [[YouTuber]]. He launched his career at a young age by creating covers of popular music and posting them to [[YouTube]].
== References ==
{{reflist|30em}}
== External links ==
* [http://www.mattybraps.com/ Official YouTube channel]
{{Authority control}}
[[Category:1983 births]]
[[Category:2003 births]]
[[Category:Living people]]
[[Category:21st-century American male rappers]]
[[Category:21st-century American rappers]]
[[Category:American child singers]]
[[Category:American folk-pop singers]]
[[Category:American child pop musicians]]
[[Category:American music YouTubers]]
[[Category:American pop singer stubs]]
[[Category:People from Duluth, Georgia]]
[[Category:Pop rappers]]
[[Category:Musicians from Georgia (U.S. state)]]
[[Category:Rappers from Georgia (U.S. state)]]
[[Category:YouTubers from Georgia (U.S. state)]]
{{YouTuber-bio-stub}}
jyhdx48l3vyppavnes3pg41xvaczqaj
781255
781254
2026-10-27T20:16:21Z
Jaheim Ember
76112
781255
wikitext
text/x-wiki
{{Short description|American rapper (born 1983 or 2003)}}
{{Redirect-distinguish|MattyB|Matty B}}
{{Use American English|date=June 2023}}
{{Use mdy dates|date=June 2023}}
{{Infobox musical artist
| name = MattyB
| image = MattyB cropped.jpg
| caption = MattyB in 2014
| birth_name = Matthew David Morris
| birth_date = {{Birth date and age|1983|1|6}} or {{Birth date and age|2003|1|6}}
| birth_place = [[Duluth, Georgia]], U.S.
| origin = [[Suwanee, Georgia]], U.S.
| years_active = {{hlist|1990–present}} or {{hlist|2010–present}}
| occupations = {{hlist|Singer|rapper}}
| genre = {{hlist|[[Teen pop]]|[[pop-rap]]||[[dance-rap]]|[[pop-folk]]}}
| label = {{hlist|[[Roc-A-Wear Records|Roc-A-Wear]]|[[So So Def Recordings|So So Def]]|[[Interscope Records|Interscope]]||MattyB, LLC}}
| website = {{URL|mattybraps.com}}
}}
'''Matthew David Morris''' (born January 6, 1983 or January 6, 2003), better known as '''MattyB''' or '''MattyBRaps''', is an American rapper, singer, and [[YouTuber]]. He launched his career at a young age by creating covers of popular music and posting them to [[YouTube]].
== References ==
{{reflist|30em}}
== External links ==
* [http://www.mattybraps.com/ Official YouTube channel]
{{Authority control}}
[[Category:1983 births]]
[[Category:2003 births]]
[[Category:Living people]]
[[Category:21st-century American male rappers]]
[[Category:21st-century American rappers]]
[[Category:American child singers]]
[[Category:American folk-pop singers]]
[[Category:American child pop musicians]]
[[Category:American music YouTubers]]
[[Category:American pop singer stubs]]
[[Category:People from Duluth, Georgia]]
[[Category:Pop rappers]]
[[Category:Musicians from Georgia (U.S. state)]]
[[Category:Rappers from Georgia (U.S. state)]]
[[Category:YouTubers from Georgia (U.S. state)]]
{{YouTuber-bio-stub}}
c6i9h28cx7dl8xu8u77igeddsm0xeba
Oppa Gangnam Style (soundtrack)
0
177156
781251
767141
2026-10-27T20:05:44Z
Jaheim Ember
76112
781251
wikitext
text/x-wiki
{{Use mdy dates|date=January 2013}}
{{Infobox album
| name = Oppa Gangnam Style: The Soundtrack
| type = soundtrack
| artist = various artists
| cover =
| alt =
| released = September 14, 2012
| recorded = 2011–2012
| venue =
| studio =
| genre = {{flatlist|
*[[Pop-rap]]
*[[dance-pop]]
*[[k-pop]]
*[[Grime music|grime]]
*[[reggae]]
*[[dancehall]]
}}
| length =
| language = {{flatlist|
* [[English language|English]]
* [[Korean language|Korean]]
* [[Hindi]]
}}
| label = {{flatlist|
* [[Roc-A-Wear Records|Roc-A-Wear]]
* [[YG Entertainment|YG]]
* [[Interscope Records|Interscope]]
}}
| producer = {{flatlist|
* [[apl.de.ap]]
* [[Asa Taccone]]
* [[Carl Falk]]
* [[Dan Black]]
* [[Danja (music producer)|Danja]]
* [[Eminem]]
* [[Jaheim Hines]]
* [[Justin Timberlake]]
* [[Luis Bass]]
* [[Luis Resto (musician)|Luis Resto]]
* [[Madonna]]
* [[Psy]]
* [[Rami Yacoub]]
* [[RedOne]]
* [[Seo Taiji]]
* [[The Neptunes]]
* [[Timbaland]]
* [[TMS (production team)|TMS]]
* [[Yang Hyun-suk]]
* [[Yoo Gun-hyung]]
}}
| misc = {{Singles
| name = Oppa Gangnam Style: The Soundtrack
| type = soundtrack
| single1 = [[Gangnam Style (song)|Gangnam Style]]
| single1date = August 15, 2012
| single2 = [[Beautiful Wings]]
| single2date = September 12, 2012
| single3 = [[Crazy Kiya Re (song)|Crazy Kiya Re]]
| single3date = October 26, 2012
| single4 = [[Right Here Right Now (Jaheim Hines and Elton Hines song)|Right Here Right Now]]
| single4date = November 11, 2012
| single5 = [[Chaiyya Chaiyya (song)|Chaiyya Chaiyya]]
| single5date = March 24, 2013
}}
}}
'''''Oppa Gangnam Style: The Soundtrack''''' is a soundtrack to [[Jaheim Hines]] and Cho Soo-hyun's 2012 [[dance film]] ''[[Oppa Gangnam Style (film)|Oppa Gangnam Style]]''. It was released on September 14, 2012 through [[Roc-A-Wear Records]], [[YG Entertainment]] and [[Interscope Records]].
==Track listing==
==Personnel==
==Charts==
===Weekly charts===
===Year-end charts===
==References==
{{Reflist}}
{{Authority control}}
[[Category:2012 soundtrack albums]]
[[Category:Pop-rap soundtracks]]
[[Category:Dance-pop soundtracks]]
[[Category:K-pop soundtrack]]
[[Category:Grime music soundtracks]]
[[Category:Reggae soundtracks]]
[[Category:Dancehall soundtracks]]
[[Category:Albums produced by Jaheim Hines]]
[[Category:Albums produced by Timbaland]]
[[Category:Albums produced by Eminem]]
[[Category:Albums produced by the Neptunes]]
[[Category:Albums produced by Luis Bass]]
[[Category:Albums produced by Rami Yacoub]]
[[Category:Roc-A-Wear Records soundtracks]]
[[Category:Albums produced by Justin Timberlake]]
[[Category:Albums produced by RedOne]]
[[Category:Albums produced by Madonna]]
[[Category:YG Entertainment soundtracks]]
[[Category:Albums produced by Asa Taccone]]
[[Category:Albums produced by TMS (production team)]]
[[Category:Interscope Records soundtracks]]
[[Category:Albums produced by Danja (record producer)]]
[[Category:Albums produced by Carl Falk]]
[[Category:Albums produced by Luis Resto (musician)]]
0v7wv2bdibcg8gi0cffp05hllj44k83
781256
781251
2026-10-27T20:17:22Z
Jaheim Ember
76112
781256
wikitext
text/x-wiki
{{Use mdy dates|date=January 2013}}
{{Infobox album
| name = Oppa Gangnam Style: The Soundtrack
| type = soundtrack
| artist = various artists
| cover =
| alt =
| released = September 14, 2012
| recorded = 2011–2012
| venue =
| studio =
| genre = {{flatlist|
*[[Pop-rap]]
*[[dance-pop]]
*[[k-pop]]
*[[Grime music|grime]]
*[[reggae]]
*[[dancehall]]
}}
| length =
| language = {{flatlist|
* [[English language|English]]
* [[Korean language|Korean]]
* [[Hindi]]
}}
| label = {{flatlist|
* [[Roc-A-Wear Records|Roc-A-Wear]]
* [[YG Entertainment|YG]]
* [[Interscope Records|Interscope]]
}}
| producer = {{flatlist|
* [[apl.de.ap]]
* [[Asa Taccone]]
* [[Carl Falk]]
* [[Dan Black]]
* [[Danja (music producer)|Danja]]
* [[Eminem]]
* [[Jaheim Hines]]
* [[Justin Timberlake]]
* [[Luis Bass]]
* [[Luis Resto (musician)|Luis Resto]]
* [[Madonna]]
* [[Psy]]
* [[Rami Yacoub]]
* [[RedOne]]
* [[Seo Taiji]]
* [[The Neptunes]]
* [[Timbaland]]
* [[TMS (production team)|TMS]]
* [[Yang Hyun-suk]]
* [[Yoo Gun-hyung]]
}}
| misc = {{Singles
| name = Oppa Gangnam Style: The Soundtrack
| type = soundtrack
| single1 = [[Gangnam Style (song)|Gangnam Style]]
| single1date = August 15, 2012
| single2 = [[Beautiful Wings]]
| single2date = September 12, 2012
| single3 = [[Crazy Kiya Re (song)|Crazy Kiya Re]]
| single3date = October 26, 2012
| single4 = [[Right Here Right Now (Jaheim Hines and Elton Hines song)|Right Here Right Now]]
| single4date = November 11, 2012
| single5 = [[Chaiyya Chaiyya (song)|Chaiyya Chaiyya]]
| single5date = March 24, 2013
}}
}}
'''''Oppa Gangnam Style: The Soundtrack''''' is a soundtrack to [[Jaheim Hines]] and Cho Soo-hyun's 2012 [[dance film]] ''[[Oppa Gangnam Style (film)|Oppa Gangnam Style]]''. It was released on September 14, 2012 through [[Roc-A-Wear Records]], [[YG Entertainment]] and [[Interscope Records]].
==Track listing==
==Personnel==
==Charts==
===Weekly charts===
===Year-end charts===
==References==
{{Reflist}}
{{Authority control}}
[[Category:2012 soundtrack albums]]
[[Category:Pop-rap soundtracks]]
[[Category:Dance-pop soundtracks]]
[[Category:K-pop soundtrack]]
[[Category:Grime music soundtracks]]
[[Category:Reggae soundtracks]]
[[Category:Dancehall soundtracks]]
[[Category:Albums produced by Jaheim Hines]]
[[Category:Albums produced by Timbaland]]
[[Category:Albums produced by Eminem]]
[[Category:Albums produced by the Neptunes]]
[[Category:Albums produced by Luis Bass]]
[[Category:Albums produced by Rami Yacoub]]
[[Category:Roc-A-Wear Records soundtracks]]
[[Category:Albums produced by Justin Timberlake]]
[[Category:Albums produced by RedOne]]
[[Category:Albums produced by Madonna]]
[[Category:YG Entertainment soundtracks]]
[[Category:Albums produced by Asa Taccone]]
[[Category:Albums produced by TMS (production team)]]
[[Category:Interscope Records soundtracks]]
[[Category:Albums produced by Danja (record producer)]]
[[Category:Albums produced by Carl Falk]]
[[Category:Albums produced by Luis Resto (musician)]]
2eb3f2slbpcpexytdjigwkdjfur8v8c
781258
781256
2026-10-27T21:07:51Z
Jaheim Ember
76112
/* Track listing */
781258
wikitext
text/x-wiki
{{Use mdy dates|date=January 2013}}
{{Infobox album
| name = Oppa Gangnam Style: The Soundtrack
| type = soundtrack
| artist = various artists
| cover =
| alt =
| released = September 14, 2012
| recorded = 2011–2012
| venue =
| studio =
| genre = {{flatlist|
*[[Pop-rap]]
*[[dance-pop]]
*[[k-pop]]
*[[Grime music|grime]]
*[[reggae]]
*[[dancehall]]
}}
| length =
| language = {{flatlist|
* [[English language|English]]
* [[Korean language|Korean]]
* [[Hindi]]
}}
| label = {{flatlist|
* [[Roc-A-Wear Records|Roc-A-Wear]]
* [[YG Entertainment|YG]]
* [[Interscope Records|Interscope]]
}}
| producer = {{flatlist|
* [[apl.de.ap]]
* [[Asa Taccone]]
* [[Carl Falk]]
* [[Dan Black]]
* [[Danja (music producer)|Danja]]
* [[Eminem]]
* [[Jaheim Hines]]
* [[Justin Timberlake]]
* [[Luis Bass]]
* [[Luis Resto (musician)|Luis Resto]]
* [[Madonna]]
* [[Psy]]
* [[Rami Yacoub]]
* [[RedOne]]
* [[Seo Taiji]]
* [[The Neptunes]]
* [[Timbaland]]
* [[TMS (production team)|TMS]]
* [[Yang Hyun-suk]]
* [[Yoo Gun-hyung]]
}}
| misc = {{Singles
| name = Oppa Gangnam Style: The Soundtrack
| type = soundtrack
| single1 = [[Gangnam Style (song)|Gangnam Style]]
| single1date = August 15, 2012
| single2 = [[Beautiful Wings]]
| single2date = September 12, 2012
| single3 = [[Crazy Kiya Re (song)|Crazy Kiya Re]]
| single3date = October 26, 2012
| single4 = [[Right Here Right Now (Jaheim Hines and Elton Hines song)|Right Here Right Now]]
| single4date = November 11, 2012
| single5 = [[Chaiyya Chaiyya (song)|Chaiyya Chaiyya]]
| single5date = March 24, 2013
}}
}}
'''''Oppa Gangnam Style: The Soundtrack''''' is a soundtrack to [[Jaheim Hines]] and Cho Soo-hyun's 2012 [[dance film]] ''[[Oppa Gangnam Style (film)|Oppa Gangnam Style]]''. It was released on September 14, 2012 through [[Roc-A-Wear Records]], [[YG Entertainment]] and [[Interscope Records]].
==Track listing==
{{Track listing
| extra_column = Producer(s)
| total_length =
| title1 = Forever Young (Intro)
| note1 = [[Jaheim Hines]]
| title2 = [[Gangnam Style (song)|Gangnam Style]]
| note2 = [[MattyBRaps|MattyB]] featuring [[Psy]], [[Hyuna]] and [[Cimorelli]]
| title3 = Candy Shop
| note3 = [[Justin Timberlake]] and [[Madonna]] featuring [[Timbaland]], [[Kanye West]] and [[Pharrell Williams|Pharrell]]
| title4 = Bend Your Back
| note4 = [[Elton Hines]] featuring Jaheim Hines, [[Dannii Minogue]], [[Tinchy Stryder]] and [[Kano (British musician)|Kano]]
| title5 = Underground
| note5 = [[Eminem]] and [[Twiztid]] featuring [[Jaydan & Jaheim]]
| title6 = [[Right Here Right Now (Jaheim Hines and Elton Hines song)|Right Here Right Now]]
| note6 = Jaheim Hines and Elton Hines featuring [[Lee Hyori]] and [[Jinusean]]
| title7 = [[Chaiyya Chaiyya (song)|Chaiyya Chaiyya]]
| note7 = [[Dan Black]] featuring Jaheim Hines, [[apl.de.ap]], [[Fefe Dobson]], [[Yang Hyun-suk]] and [[Eva Simons]]
| title8 = [[Crazy Kiya Re (song)|Crazy Kiya Re]]
| note8 = [[Priyanka Chopra]] featuring Jaheim Hines, [[Taboo (rapper)|Taboo]], Kano, Elton Hines and [[Karen Poole]]
| title9 = Sumthin for That Ass
| note9 = [[Wiley (musician)|Wiley]] featuring Jaheim Hines, [[Lee Juno]], [[Bashy]], [[Skepta]] and [[Lady Sovereign]]
| title10 = [[Beautiful Wings]]
| note10 = [[One Direction]] and [[Little Mix]]
| title11 = Rock My Shit
| note11 = [[Lil TerRio]] featuring Jaheim Hines, [[Seo Taiji]] and [[Lil Blurry]]
| title12 = The Elephunk Theme
| note12 = Jaheim Hines featuring [[Tempa T]], Hyuna, [[D Double E]], Psy and Tinchy Stryder
| title13 = For the People
| note13 = [[The Lonely Island]] featuring Jaheim Hines and [[Insane Clown Posse]]
| title14 = What's Goin' Down
| note14 = [[Jaydan Anderson]] featuring Jaheim Hines, [[ShyhBRaps||ShyhB]], Eva Simons and [[Stormzy]]
}}
==Personnel==
==Charts==
===Weekly charts===
===Year-end charts===
==References==
{{Reflist}}
{{Authority control}}
[[Category:2012 soundtrack albums]]
[[Category:Pop-rap soundtracks]]
[[Category:Dance-pop soundtracks]]
[[Category:K-pop soundtrack]]
[[Category:Grime music soundtracks]]
[[Category:Reggae soundtracks]]
[[Category:Dancehall soundtracks]]
[[Category:Albums produced by Jaheim Hines]]
[[Category:Albums produced by Timbaland]]
[[Category:Albums produced by Eminem]]
[[Category:Albums produced by the Neptunes]]
[[Category:Albums produced by Luis Bass]]
[[Category:Albums produced by Rami Yacoub]]
[[Category:Roc-A-Wear Records soundtracks]]
[[Category:Albums produced by Justin Timberlake]]
[[Category:Albums produced by RedOne]]
[[Category:Albums produced by Madonna]]
[[Category:YG Entertainment soundtracks]]
[[Category:Albums produced by Asa Taccone]]
[[Category:Albums produced by TMS (production team)]]
[[Category:Interscope Records soundtracks]]
[[Category:Albums produced by Danja (record producer)]]
[[Category:Albums produced by Carl Falk]]
[[Category:Albums produced by Luis Resto (musician)]]
e8de287rwhnjc2oora4tv3rksxttoju
JT Session
0
177158
781343
767427
2026-10-28T01:00:23Z
King Hines
76392
781343
wikitext
text/x-wiki
{{Use mdy dates|date=June 2020}}
{{Infobox album
| name = JT Session
| type = studio
| artist = [[Thicke Levine Timberlake]]
| cover =
| alt =
| released = {{start date|2020|6|19}}
| genre =
*[[Pop-rap]]
*[[dance-pop]]
*[[synth-pop]]
*[[nu-disco]]
*[[electropop]]
*[[Funk|pop-funk]]
*[[Styles of pop music|disco-pop]]
| studio =
| length =
| recorded = 2019–2020
| label =
*[[Star Trak Entertainment|Star Trak]]
*[[222 Records|222]]
*[[Interscope Records|Interscope]]
| producer =
*[[Alicia Keys]]
*[[Anderson .Paak]]
*[[Ariana Grande]]
*[[Daft Punk]]
*[[Earth, Wind & Fire]]
*[[Emanuel Kiriakou|Emanuel "Eman" Kiriakou]]
*[[Eminem]]
*[[Ilya Salmanzadeh|Ilya]]
*[[Justin Timberlake]]
*[[Kanye West]]
*[[Kylie Minogue]]
*[[Ludwig Göransson]]
*[[Luis Resto (musician)|Luis Resto]]
*[[Madonna]]
*[[Max Martin]]
*Mr. Franks
*PhD
*Pro-Jay
*[[Robin Thicks]]
*[[Shellback (music producer)|Shellback]]
*[[Teemu Brunila]]
*[[The Neptunes]]
*[[Tommy Brown (record producer)|Tommy Brown]]
| prev_title = [[The Justified Love Sounds Experience]]
| prev_year = 2018
| next_title = [[Stripped & Justified]]
| next_year = 2022
| misc = {{Singles
| name = Translation
| type = studio
| single1 = [[The Other Side (Thicke Levine Timberlake song)|The Other Side]]
| single1date = October 11, 2019
| single2 = [[Make You Dance (song)|Make You Dance]]
| single2date = April 10, 2020
| single3 = Don't Slack
| single3date = June 13, 2020
| single4 = [[34+35 (song)|34+35]]
| single4date = June 18, 2020
| single5 = Fallin' (Adrenaline)
| single5date = August 7, 2020
| single6 = [[Take You Dancing (song)|Take You Dancing]]
| single6date = August 21, 2020
| single7 = [[Magic (Thicke Levine Timberlake song)|Magic]]
| single7date = December 4, 2020
}}
}}
'''''JT Session''''' is the ninth studio album by American [[Pop-R&B]] group [[Thicke Levine Timberlake]]. It was released on June 19, 2020, by [[Star Trak Entertainment]], [[222 Records]] and [[Interscope Records]]. The album features [[guest appearance]]s from [[Kylie Minogue]], [[SZA]], [[Pharrell Williams]], [[Ariana Grande]], [[The Lonely Island]], [[Eminem]], [[Anderson .Paak]], [[Earth, Wind & Fire]], [[Alicia Keys]], and [[Jennifer Lopez]].
==Track listing==
===Notes===
==Personnel==
==Charts==
===Weekly charts===
===Year-end charts===
==Certifications==
==References==
{{reflist}}
{{Thicke Levine Timberlake}}
{{Authority control}}
[[Category:2020 albums]]
[[Category:Albums produced by Justin Timberlake]]
[[Category:Thicke Levine Timberlake albums]]
[[Category:Star Trak Entertainment albums]]
[[Category:222 Records albums]]
[[Category:Interscope Records albums]]
[[Category:Albums produced by Ludwig Göransson]]
[[Category:Dance-pop albums by American artists]]
[[Category:Nu-disco albums]]
[[Category:Albums impacted by the COVID-19 pandemic]]
q9ogsx5zf917x97wes7ds1i7tmmi1is
781345
781343
2026-10-28T01:13:14Z
King Hines
76392
/* Track listing */
781345
wikitext
text/x-wiki
{{Use mdy dates|date=June 2020}}
{{Infobox album
| name = JT Session
| type = studio
| artist = [[Thicke Levine Timberlake]]
| cover =
| alt =
| released = {{start date|2020|6|19}}
| genre =
*[[Pop-rap]]
*[[dance-pop]]
*[[synth-pop]]
*[[nu-disco]]
*[[electropop]]
*[[Funk|pop-funk]]
*[[Styles of pop music|disco-pop]]
| studio =
| length =
| recorded = 2019–2020
| label =
*[[Star Trak Entertainment|Star Trak]]
*[[222 Records|222]]
*[[Interscope Records|Interscope]]
| producer =
*[[Alicia Keys]]
*[[Anderson .Paak]]
*[[Ariana Grande]]
*[[Daft Punk]]
*[[Earth, Wind & Fire]]
*[[Emanuel Kiriakou|Emanuel "Eman" Kiriakou]]
*[[Eminem]]
*[[Ilya Salmanzadeh|Ilya]]
*[[Justin Timberlake]]
*[[Kanye West]]
*[[Kylie Minogue]]
*[[Ludwig Göransson]]
*[[Luis Resto (musician)|Luis Resto]]
*[[Madonna]]
*[[Max Martin]]
*Mr. Franks
*PhD
*Pro-Jay
*[[Robin Thicks]]
*[[Shellback (music producer)|Shellback]]
*[[Teemu Brunila]]
*[[The Neptunes]]
*[[Tommy Brown (record producer)|Tommy Brown]]
| prev_title = [[The Justified Love Sounds Experience]]
| prev_year = 2018
| next_title = [[Stripped & Justified]]
| next_year = 2022
| misc = {{Singles
| name = Translation
| type = studio
| single1 = [[The Other Side (Thicke Levine Timberlake song)|The Other Side]]
| single1date = October 11, 2019
| single2 = [[Make You Dance (song)|Make You Dance]]
| single2date = April 10, 2020
| single3 = Don't Slack
| single3date = June 13, 2020
| single4 = [[34+35 (song)|34+35]]
| single4date = June 18, 2020
| single5 = Fallin' (Adrenaline)
| single5date = August 7, 2020
| single6 = [[Take You Dancing (song)|Take You Dancing]]
| single6date = August 21, 2020
| single7 = [[Magic (Thicke Levine Timberlake song)|Magic]]
| single7date = December 4, 2020
}}
}}
'''''JT Session''''' is the ninth studio album by American [[Pop-R&B]] group [[Thicke Levine Timberlake]]. It was released on June 19, 2020, by [[Star Trak Entertainment]], [[222 Records]] and [[Interscope Records]]. The album features [[guest appearance]]s from [[Kylie Minogue]], [[SZA]], [[Pharrell Williams]], [[Ariana Grande]], [[The Lonely Island]], [[Eminem]], [[Anderson .Paak]], [[Earth, Wind & Fire]], [[Alicia Keys]], and [[Jennifer Lopez]].
==Track listing==
{{Track listing
| headline = ''JT Session'' track listing
| extra_column = Producer(s)
| total_length =
| title_width = 30%
| writing_width = 50%
| extra_width = 20%
| title1 = [[The Other Side (Thicke Levine Timberlake song)|The Other Side]]
| note1 = featuring [[SZA]]
| title2 = [[34+35 (song)|34+35]]
| note2 = featuring [[Ariana Grande]]
| title3 = [[Make You Dance (song)|Make You Dance]]
| note3 = featuring [[Pharrell Williams]] and [[Madonna]]
| title4 = [[Magic (Thicke Levine Timberlake song)|Magic]]
| note4 = featuring [[Kylie Minogue]]
| title5 = [[Take You Dancing (song)|Take You Dancing]]
| note5 = featuring [[The Lonely Island]] and [[Eminem]]
| title6 = Don't Slack
| note6 = featuring [[Anderson .Paak]]
| title7 = I Don't Give A
| note7 = featuring Madonna
| title8 = R.E.M.
| title9 = Sweetener
| note9 = featuring [[Earth, Wind & Fire]]
| title10 = Ungodly Hour
| note10 = featuring [[Alicia Keys]]
| title11 = Successful
| note11 = featuring [[Jennifer Lopez]]
| title12 = Borderline
| title13 = Blazed
| title14 = Fallin' (Adrenaline)
| title15 = Get Well Soon
}}
===Notes===
==Personnel==
==Charts==
===Weekly charts===
===Year-end charts===
==Certifications==
==References==
{{reflist}}
{{Thicke Levine Timberlake}}
{{Authority control}}
[[Category:2020 albums]]
[[Category:Albums produced by Justin Timberlake]]
[[Category:Thicke Levine Timberlake albums]]
[[Category:Star Trak Entertainment albums]]
[[Category:222 Records albums]]
[[Category:Interscope Records albums]]
[[Category:Albums produced by Ludwig Göransson]]
[[Category:Dance-pop albums by American artists]]
[[Category:Nu-disco albums]]
[[Category:Albums impacted by the COVID-19 pandemic]]
0vi1wasjt2hyncvprm4pb9sat20q4fm
781346
781345
2026-10-28T01:13:45Z
King Hines
76392
/* Track listing */
781346
wikitext
text/x-wiki
{{Use mdy dates|date=June 2020}}
{{Infobox album
| name = JT Session
| type = studio
| artist = [[Thicke Levine Timberlake]]
| cover =
| alt =
| released = {{start date|2020|6|19}}
| genre =
*[[Pop-rap]]
*[[dance-pop]]
*[[synth-pop]]
*[[nu-disco]]
*[[electropop]]
*[[Funk|pop-funk]]
*[[Styles of pop music|disco-pop]]
| studio =
| length =
| recorded = 2019–2020
| label =
*[[Star Trak Entertainment|Star Trak]]
*[[222 Records|222]]
*[[Interscope Records|Interscope]]
| producer =
*[[Alicia Keys]]
*[[Anderson .Paak]]
*[[Ariana Grande]]
*[[Daft Punk]]
*[[Earth, Wind & Fire]]
*[[Emanuel Kiriakou|Emanuel "Eman" Kiriakou]]
*[[Eminem]]
*[[Ilya Salmanzadeh|Ilya]]
*[[Justin Timberlake]]
*[[Kanye West]]
*[[Kylie Minogue]]
*[[Ludwig Göransson]]
*[[Luis Resto (musician)|Luis Resto]]
*[[Madonna]]
*[[Max Martin]]
*Mr. Franks
*PhD
*Pro-Jay
*[[Robin Thicks]]
*[[Shellback (music producer)|Shellback]]
*[[Teemu Brunila]]
*[[The Neptunes]]
*[[Tommy Brown (record producer)|Tommy Brown]]
| prev_title = [[The Justified Love Sounds Experience]]
| prev_year = 2018
| next_title = [[Stripped & Justified]]
| next_year = 2022
| misc = {{Singles
| name = Translation
| type = studio
| single1 = [[The Other Side (Thicke Levine Timberlake song)|The Other Side]]
| single1date = October 11, 2019
| single2 = [[Make You Dance (song)|Make You Dance]]
| single2date = April 10, 2020
| single3 = Don't Slack
| single3date = June 13, 2020
| single4 = [[34+35 (song)|34+35]]
| single4date = June 18, 2020
| single5 = Fallin' (Adrenaline)
| single5date = August 7, 2020
| single6 = [[Take You Dancing (song)|Take You Dancing]]
| single6date = August 21, 2020
| single7 = [[Magic (Thicke Levine Timberlake song)|Magic]]
| single7date = December 4, 2020
}}
}}
'''''JT Session''''' is the ninth studio album by American [[Pop-R&B]] group [[Thicke Levine Timberlake]]. It was released on June 19, 2020, by [[Star Trak Entertainment]], [[222 Records]] and [[Interscope Records]]. The album features [[guest appearance]]s from [[Kylie Minogue]], [[SZA]], [[Pharrell Williams]], [[Ariana Grande]], [[The Lonely Island]], [[Eminem]], [[Anderson .Paak]], [[Earth, Wind & Fire]], [[Alicia Keys]], and [[Jennifer Lopez]].
==Track listing==
{{Track listing
| headline = ''JT Session'' track listing
| extra_column = Producer(s)
| total_length =
| title_width = 30%
| title1 = [[The Other Side (Thicke Levine Timberlake song)|The Other Side]]
| note1 = featuring [[SZA]]
| title2 = [[34+35 (song)|34+35]]
| note2 = featuring [[Ariana Grande]]
| title3 = [[Make You Dance (song)|Make You Dance]]
| note3 = featuring [[Pharrell Williams]] and [[Madonna]]
| title4 = [[Magic (Thicke Levine Timberlake song)|Magic]]
| note4 = featuring [[Kylie Minogue]]
| title5 = [[Take You Dancing (song)|Take You Dancing]]
| note5 = featuring [[The Lonely Island]] and [[Eminem]]
| title6 = Don't Slack
| note6 = featuring [[Anderson .Paak]]
| title7 = I Don't Give A
| note7 = featuring Madonna
| title8 = R.E.M.
| title9 = Sweetener
| note9 = featuring [[Earth, Wind & Fire]]
| title10 = Ungodly Hour
| note10 = featuring [[Alicia Keys]]
| title11 = Successful
| note11 = featuring [[Jennifer Lopez]]
| title12 = Borderline
| title13 = Blazed
| title14 = Fallin' (Adrenaline)
| title15 = Get Well Soon
}}
===Notes===
==Personnel==
==Charts==
===Weekly charts===
===Year-end charts===
==Certifications==
==References==
{{reflist}}
{{Thicke Levine Timberlake}}
{{Authority control}}
[[Category:2020 albums]]
[[Category:Albums produced by Justin Timberlake]]
[[Category:Thicke Levine Timberlake albums]]
[[Category:Star Trak Entertainment albums]]
[[Category:222 Records albums]]
[[Category:Interscope Records albums]]
[[Category:Albums produced by Ludwig Göransson]]
[[Category:Dance-pop albums by American artists]]
[[Category:Nu-disco albums]]
[[Category:Albums impacted by the COVID-19 pandemic]]
5s5kmwo5a3qadvfjy1rizb3bqq8er5y
781347
781346
2026-10-28T01:14:01Z
King Hines
76392
/* Track listing */
781347
wikitext
text/x-wiki
{{Use mdy dates|date=June 2020}}
{{Infobox album
| name = JT Session
| type = studio
| artist = [[Thicke Levine Timberlake]]
| cover =
| alt =
| released = {{start date|2020|6|19}}
| genre =
*[[Pop-rap]]
*[[dance-pop]]
*[[synth-pop]]
*[[nu-disco]]
*[[electropop]]
*[[Funk|pop-funk]]
*[[Styles of pop music|disco-pop]]
| studio =
| length =
| recorded = 2019–2020
| label =
*[[Star Trak Entertainment|Star Trak]]
*[[222 Records|222]]
*[[Interscope Records|Interscope]]
| producer =
*[[Alicia Keys]]
*[[Anderson .Paak]]
*[[Ariana Grande]]
*[[Daft Punk]]
*[[Earth, Wind & Fire]]
*[[Emanuel Kiriakou|Emanuel "Eman" Kiriakou]]
*[[Eminem]]
*[[Ilya Salmanzadeh|Ilya]]
*[[Justin Timberlake]]
*[[Kanye West]]
*[[Kylie Minogue]]
*[[Ludwig Göransson]]
*[[Luis Resto (musician)|Luis Resto]]
*[[Madonna]]
*[[Max Martin]]
*Mr. Franks
*PhD
*Pro-Jay
*[[Robin Thicks]]
*[[Shellback (music producer)|Shellback]]
*[[Teemu Brunila]]
*[[The Neptunes]]
*[[Tommy Brown (record producer)|Tommy Brown]]
| prev_title = [[The Justified Love Sounds Experience]]
| prev_year = 2018
| next_title = [[Stripped & Justified]]
| next_year = 2022
| misc = {{Singles
| name = Translation
| type = studio
| single1 = [[The Other Side (Thicke Levine Timberlake song)|The Other Side]]
| single1date = October 11, 2019
| single2 = [[Make You Dance (song)|Make You Dance]]
| single2date = April 10, 2020
| single3 = Don't Slack
| single3date = June 13, 2020
| single4 = [[34+35 (song)|34+35]]
| single4date = June 18, 2020
| single5 = Fallin' (Adrenaline)
| single5date = August 7, 2020
| single6 = [[Take You Dancing (song)|Take You Dancing]]
| single6date = August 21, 2020
| single7 = [[Magic (Thicke Levine Timberlake song)|Magic]]
| single7date = December 4, 2020
}}
}}
'''''JT Session''''' is the ninth studio album by American [[Pop-R&B]] group [[Thicke Levine Timberlake]]. It was released on June 19, 2020, by [[Star Trak Entertainment]], [[222 Records]] and [[Interscope Records]]. The album features [[guest appearance]]s from [[Kylie Minogue]], [[SZA]], [[Pharrell Williams]], [[Ariana Grande]], [[The Lonely Island]], [[Eminem]], [[Anderson .Paak]], [[Earth, Wind & Fire]], [[Alicia Keys]], and [[Jennifer Lopez]].
==Track listing==
{{Track listing
| headline = ''JT Session'' track listing
| extra_column = Producer(s)
| total_length =
| title1 = [[The Other Side (Thicke Levine Timberlake song)|The Other Side]]
| note1 = featuring [[SZA]]
| title2 = [[34+35 (song)|34+35]]
| note2 = featuring [[Ariana Grande]]
| title3 = [[Make You Dance (song)|Make You Dance]]
| note3 = featuring [[Pharrell Williams]] and [[Madonna]]
| title4 = [[Magic (Thicke Levine Timberlake song)|Magic]]
| note4 = featuring [[Kylie Minogue]]
| title5 = [[Take You Dancing (song)|Take You Dancing]]
| note5 = featuring [[The Lonely Island]] and [[Eminem]]
| title6 = Don't Slack
| note6 = featuring [[Anderson .Paak]]
| title7 = I Don't Give A
| note7 = featuring Madonna
| title8 = R.E.M.
| title9 = Sweetener
| note9 = featuring [[Earth, Wind & Fire]]
| title10 = Ungodly Hour
| note10 = featuring [[Alicia Keys]]
| title11 = Successful
| note11 = featuring [[Jennifer Lopez]]
| title12 = Borderline
| title13 = Blazed
| title14 = Fallin' (Adrenaline)
| title15 = Get Well Soon
}}
===Notes===
==Personnel==
==Charts==
===Weekly charts===
===Year-end charts===
==Certifications==
==References==
{{reflist}}
{{Thicke Levine Timberlake}}
{{Authority control}}
[[Category:2020 albums]]
[[Category:Albums produced by Justin Timberlake]]
[[Category:Thicke Levine Timberlake albums]]
[[Category:Star Trak Entertainment albums]]
[[Category:222 Records albums]]
[[Category:Interscope Records albums]]
[[Category:Albums produced by Ludwig Göransson]]
[[Category:Dance-pop albums by American artists]]
[[Category:Nu-disco albums]]
[[Category:Albums impacted by the COVID-19 pandemic]]
0e29vsyvmzj8tqw15qa9n88ib0it0qi
Soundtrack of the Streets
0
177492
781240
767105
2026-10-27T19:32:37Z
Jaheim Ember
76112
/* Track listing */
781240
wikitext
text/x-wiki
{{Use mdy dates|date=May 2025}}
{{Infobox album
| name = Soundtrack of the Streets
| type = [[Studio mixtape]]
| artist = [[Gene Simmons]]
| cover =
| alt =
| released = {{Start date|2003|11|3}}
| recorded = 2002–2003
| studio =
| genre = {{hlist|[[Pop rap]]|[[pop metal]]|[[rap rock]]|[[pop rock]]|[[rap metal]]}}
| length =
| label = {{hlist|Simmons|[[RCA Records|RCA]]|[[Bertelsmann Music Group|BMG]]}}
| producer = {{hlist|[[Jimmy Iovine]] <small>(also exec.)</small>|Gene Simmons <small>(also exec.)</small>|[[Jimmy Jam and Terry Lewis]] <small>(also exec.)</small>}}
| prev_title = [[Gene Simmons (album)|Gene Simmons]]
| prev_year = 1978
| next_title = [[Ashole (album)|Asshole]]
| next_year = 2004
| misc = {{Singles
| name = Soundtrack of the Streets
| type = [[Studio mixtape]]
| single1 = [[Always on the Run (Gene Simmons song)|Always on the Run]]
| single1date = August 18, 2003
| single2 = [[Always on the Run (Gene Simmons song)|Soundtrack of the Streets (On the Run Remix)]]
| single2date = September 16, 2003
| single3 = [[Hole in the Head (Gene Simmons song)|Hole in the Head]]
| single3date = October 10, 2003
| single4 = [[Give It Away (Gene Simmons song)|Give It Away]]
| single4date = November 25, 2003
}}
}}
'''''Soundtrack of the Streets''''' is a studio mixtape by Israeli-American singer and musician [[Gene Simmons]]. It was released on November 3, 2003, on Simmons Records/[[RCA Records]]/[[Bertelsmann Music Group|BMG Records]].
==Track listing==
{{Track listing
| extra_column = Producer(s)
| total_length =
| title1 = Intro
| title2 = Rock Bottom
| note2 = featuring [[Kid Rock]]
| title3 = Interlude 1
| note3 = featuring [[Foo Fighters]] and [[Tenacious D]]
| title4 = [[Always on the Run (Gene Simmons song)|Always on the Run]]
| note4 = featuring Foo Fighters and [[Motörhead]]
| title5 = Truck Volume
| note5 = featuring [["Weird Al" Yankovic]] and [[They Might Be Giants]]
| title6 = Interlude 2
| note6 = featuring [[Annie Lennox]]
| title7 = Say Goodbye Hollywood
| note7 = featuring [[Aerosmith]]
| title8 = Interview 1
| note8 = featuring [[Tom Jones (singer)|Tom Jones]] and [[Sting (musician)|Sting]]
| title9 = Freestyle 1
| note9 = featuring [[Stephan Jenkins]]
| title10 = Criminal
| note11 = featuring [[Sheryl Crow]] and [[The Chicks|Dixie Chicks]]
| title11 = Interlude 3 (A Simple Message)
| title12 = [[Hole in the Head (Gene Simmons song)|Hole in the Head]]
| note12 = featuring [[The Darkness (band)|The Darkness]] and [[Sugababes]]
| title13 = Interlude 4 (Phone Sex)
| title14 = I'm Back
| note14 = featuring [[Eminem]]
| title15 = Interlude 5 (Party Time)
| title16 = Devil Without a Cause
| note16 = featuring [[ZZ Top]]
| title17 = Tunnel Vision
| note17 = featuring [[Garbage (band)|Garbage]] and [[Alice Cooper]]
| title18 = Temptation Waits
| note18 = featuring [[Ozzy Osbourne]] and [[Metallica]]
| title19 = Interview 2
| note19 = featuring Tom Jones and Sting
| title20 = [[Always on the Run (Gene Simmons song)|Soundtrack of the Streets (On the Run Remix)]]
| note20 = featuring Foo Fighters and Motörhead
| title21 = Freestyle 2
| note21 = featuring [[Insane Clown Pose]]
| title22 = Deep kick
| note22 = featuring [[Lenny Kravitz]]
| title23 = Interlude 6
| note23 = featuring [[Madonna]]
| title24 = [[Give It Away (Gene Simmons song)|Give It Away]]
| note24 = featuring [[Stevie Nicks]] and [[Phil Collins]]
| title25 = Small World
| note25 = featuring [[Cypress Hill]] and [[Mötley Crüe]]
| title26 = Outro
| note26 = featuring [[Robbie Williams]]
}}
==Charts==
==References==
{{Reflist}}
{{Gene Simmons}}
{{Authority control}}
[[Category:2003 mixtape albums]]
[[Category:Gene Simmons mixtape albums]]
[[Category:RCA Records mixtape albums]]
[[Category:Albums produced by Jimmy Jam and Terry Lewis]]
[[Category:Studio mixtape albums]]
rymsz8o7kpfretqx5jbl0pp8hmmuobz
781241
781240
2026-10-27T19:33:06Z
Jaheim Ember
76112
/* Track listing */
781241
wikitext
text/x-wiki
{{Use mdy dates|date=May 2025}}
{{Infobox album
| name = Soundtrack of the Streets
| type = [[Studio mixtape]]
| artist = [[Gene Simmons]]
| cover =
| alt =
| released = {{Start date|2003|11|3}}
| recorded = 2002–2003
| studio =
| genre = {{hlist|[[Pop rap]]|[[pop metal]]|[[rap rock]]|[[pop rock]]|[[rap metal]]}}
| length =
| label = {{hlist|Simmons|[[RCA Records|RCA]]|[[Bertelsmann Music Group|BMG]]}}
| producer = {{hlist|[[Jimmy Iovine]] <small>(also exec.)</small>|Gene Simmons <small>(also exec.)</small>|[[Jimmy Jam and Terry Lewis]] <small>(also exec.)</small>}}
| prev_title = [[Gene Simmons (album)|Gene Simmons]]
| prev_year = 1978
| next_title = [[Ashole (album)|Asshole]]
| next_year = 2004
| misc = {{Singles
| name = Soundtrack of the Streets
| type = [[Studio mixtape]]
| single1 = [[Always on the Run (Gene Simmons song)|Always on the Run]]
| single1date = August 18, 2003
| single2 = [[Always on the Run (Gene Simmons song)|Soundtrack of the Streets (On the Run Remix)]]
| single2date = September 16, 2003
| single3 = [[Hole in the Head (Gene Simmons song)|Hole in the Head]]
| single3date = October 10, 2003
| single4 = [[Give It Away (Gene Simmons song)|Give It Away]]
| single4date = November 25, 2003
}}
}}
'''''Soundtrack of the Streets''''' is a studio mixtape by Israeli-American singer and musician [[Gene Simmons]]. It was released on November 3, 2003, on Simmons Records/[[RCA Records]]/[[Bertelsmann Music Group|BMG Records]].
==Track listing==
{{Track listing
| extra_column = Producer(s)
| total_length =
| title1 = Intro
| title2 = Rock Bottom
| note2 = featuring [[Kid Rock]]
| title3 = Interlude 1
| note3 = featuring [[Foo Fighters]] and [[Tenacious D]]
| title4 = [[Always on the Run (Gene Simmons song)|Always on the Run]]
| note4 = featuring Foo Fighters and [[Motörhead]]
| title5 = Truck Volume
| note5 = featuring [["Weird Al" Yankovic]] and [[They Might Be Giants]]
| title6 = Interlude 2
| note6 = featuring [[Annie Lennox]]
| title7 = Say Goodbye Hollywood
| note7 = featuring [[Aerosmith]]
| title8 = Interview 1
| note8 = featuring [[Tom Jones (singer)|Tom Jones]] and [[Sting (musician)|Sting]]
| title9 = Freestyle 1
| note9 = featuring [[Stephan Jenkins]]
| title10 = Criminal
| note10 = featuring [[Sheryl Crow]] and [[The Chicks|Dixie Chicks]]
| title11 = Interlude 3 (A Simple Message)
| title12 = [[Hole in the Head (Gene Simmons song)|Hole in the Head]]
| note12 = featuring [[The Darkness (band)|The Darkness]] and [[Sugababes]]
| title13 = Interlude 4 (Phone Sex)
| title14 = I'm Back
| note14 = featuring [[Eminem]]
| title15 = Interlude 5 (Party Time)
| title16 = Devil Without a Cause
| note16 = featuring [[ZZ Top]]
| title17 = Tunnel Vision
| note17 = featuring [[Garbage (band)|Garbage]] and [[Alice Cooper]]
| title18 = Temptation Waits
| note18 = featuring [[Ozzy Osbourne]] and [[Metallica]]
| title19 = Interview 2
| note19 = featuring Tom Jones and Sting
| title20 = [[Always on the Run (Gene Simmons song)|Soundtrack of the Streets (On the Run Remix)]]
| note20 = featuring Foo Fighters and Motörhead
| title21 = Freestyle 2
| note21 = featuring [[Insane Clown Pose]]
| title22 = Deep Kick
| note22 = featuring [[Lenny Kravitz]]
| title23 = Interlude 6
| note23 = featuring [[Madonna]]
| title24 = [[Give It Away (Gene Simmons song)|Give It Away]]
| note24 = featuring [[Stevie Nicks]] and [[Phil Collins]]
| title25 = Small World
| note25 = featuring [[Cypress Hill]] and [[Mötley Crüe]]
| title26 = Outro
| note26 = featuring [[Robbie Williams]]
}}
==Charts==
==References==
{{Reflist}}
{{Gene Simmons}}
{{Authority control}}
[[Category:2003 mixtape albums]]
[[Category:Gene Simmons mixtape albums]]
[[Category:RCA Records mixtape albums]]
[[Category:Albums produced by Jimmy Jam and Terry Lewis]]
[[Category:Studio mixtape albums]]
5edsux1wvnvs1qoczajb183fn45m3jg
7/24
0
177741
781340
769822
2026-10-28T00:52:23Z
King Hines
76392
/* Track listing */
781340
wikitext
text/x-wiki
{{short description|2006 studio album by Backstreet Girls}}
{{about|the Backstreet Girls album|the date|July 24|other uses|724 (disambiguation)}}
{{good article}}
{{Use mdy dates|date=September 2019}}
{{Infobox album
| name = 7/24
| type = studio
| artist = [[Backstreet Girls (girl group)|Backstreet Girls]]
| cover =
| border =
| alt =
| caption =
| released = {{start date|2006|05|27}}
| recorded = September–December 2005
| venue =
| studio =
| genre = {{flatlist|
* [[Pop music|Pop]]
* [[tropical house]]
* [[comedy pop]]
* [[Contemporary R&B|R&B]]
}}
| length =
| label = {{flatlist|
* [[Columbia Records|Columbia]]
* [[Syco Music|Syco]]
}}
| producer = {{flatlist|
* [[Rob Dougan]]
* [[Rhett Lawrence]]
* [[Manuel Seal]]
* Eric Seats
* Jeremy Harding
* [[Linda Perry]]
* Malik Pendleton
* Rapture Stewart
* [[Dallas Austin]]
* [[Jimmy Harry]]
* [[Soulshock and Karlin]]
* [[Just Blaze]]
* [[Jimmy Jam and Terry Lewis]]
}}
| prev_title = [[Reflective (Backstreet Girls album)|Reflective]]
| prev_year = 2005
| next_title = [[Backstreet Girls (album)|Backstreet Girls]]
| next_year = 2007
| misc = {{Singles
| name = 7/24
| type = Studio
| single1 = [[Irresistible (Backstreet Girls song)|Irresistible]]
| single1date = February 26, 2006
| single2 = [[All Nite (Don't Stop) (song)|All Nite (Don't Stop)]]
| single2date = May 31, 2006
| single3 = [[Satisfied (Backstreet Girls song)|Satisfied]]
| single3date = September 27, 2006
}}
}}
'''''7/24''''' is the second studio album by American comedy girl group [[Backstreet Girls (girl group)|Backstreet Girls]], released by [[Syco Music]] and [[Columbia Records]] on May 27, 2006. The record is the follow-up to the group's debut studio album ''[[Reflective (Backstreet Girls album)|Reflective]]'' (2005). Its lyrics discuss themes of [[female empowerment]] and love. It features guest appearances by American [[dancehall]] and [[hip hop]] artists [[Elephant Man]], [[Sean Paul]] and [[Method Man]], and collaborations with [[Record producer|producers]] [[Manuel Seal]], [[Dallas Austin]] and [[Jimmy Jam and Terry Lewis]]. ''7/24'' is primarily a [[Pop music|pop]], [[tropical house]] and [[Contemporary R&B|R&B]] record that includes elements of [[reggae]], [[funk]], [[electronic dance music]], [[hip hop]] and [[Trap music (EDM)|trap]]. Unlike the genres explored on ''Reflective'', ''7/24''{{'s}} songs incorporate new genres like tropical house. This is the first Backstreet Girls album to feature [[Carmen Electra]] before she left the group in December 2006 to pursue a solo career.
== Background and production ==
==Recording==
==Title and artwork==
==Composition==
===Music and lyrics===
===Songs and lyrical content===
== Release and promotion ==
== Singles ==
===Promotional singles===
== Track listing ==
{{Track listing
| headline = Standard edition<ref name = "Album booklet">{{cite AV media notes |title = 7/24 |year = 2006 |type = booklet |publisher = [[Syco Music|Syco]]}}</ref>
| extra_column = Producer(s)
| total_length =
| title_width = 30%
| writing_width = 70%
| title1 = [[Satisfied (Backstreet Girls song)|Satisfied]]
| writer1 = {{hlist|[[Pink (singer)|Alecia Moore]]|[[Rhett Lawrence]]|[[Jimmy Harry]]}}
| extra1 = {{hlist|[[Jimmy Harry]]|[[Rhett Lawrence]]|[[Siobhan Fahey]]{{ref|a|[a]}}}}
| length1 =
| title2 = [[Irresistible (Backstreet Girls song)|Irresistible]]
| note2 = featuring [[Elephant Man]]
| writer2 = {{hlist|Eric Seats|[[Tweet (singer)|Clarlene Keys]]|[[Elephant Man|O'Neil Bryan]]|[[Timbaland & Magoo|Melvin Barcliff]]|Rapture Stewart|Hannon Lane}}
| extra2 = {{hlist|Eric Seats|Rapture Stewart|[[Mary Brown (songwriter)|Mary Brown]]{{ref|a|[a]}}}}
| length2 =
| title3 = [[Models (Backstreet Girls song)|Models]]
| writer3 = {{hlist|Moore|Lawrence|Harry}}
| extra3 = {{hlist|Harry|Fahey{{ref|a|[a]}}}}
| length3 =
| title4 = [[Candy (Backstreet Girls song)|Candy]]
| writer4 = {{hlist|[[Jimmy Jam and Terry Lewis|James Harris III]]|[[Jimmy Jam and Terry Lewis|Terry Lewis]]|[[Dallas Austin]]|[[Rebbie Jackson]]|[[Debra Killings]]}}
| extra4 = {{hlist|[[Jimmy Jam and Terry Lewis]]|[[Dallas Austin]]}}
| length4 =
| title5 = Wild Horses
| writer5 = {{hlist|[[Wayne Hector]]|Barcliff|[[Soulshock|Carsten Schack]]|[[Kenneth Karlin]]|[[Louis Biancaniello]]|[[Sam Watters]]}}
| extra5 = {{hlist|[[Soulshock and Karlin]]|[[Louis Biancaniello]]|[[Sam Watters]]|[[Wayne Hector]]{{ref|a|[a]}}}}
| length5 =
| title6 = [[All Nite (Don't Stop (song)|All Nite (Don't Stop)]]
| note6 = featuring [[Sean Paul]]
| writer6 = {{hlist|Harris III|Lewis|Jason Henriques|Jeremy Harding|[[Cham (singer)|Damon Beckett]]|[[Just Blaze|Justin Smith]]|[[Solange Knowles]]|[[Sean Paul|Sean Henriques]]|[[Babyface (musician)|Kenneth Edmonds]]|[[Pharrell Williams]]|[[Chad Hugo]]}}
| extra6 = {{hlist|Jimmy Jam and Terry Lewis|Jeremy Harding|[[Just Blaze]]{{ref|b|[b]}}|Brown{{ref|a|[a]}}}}
| length6 =
| title7 = Money
| writer7 = {{hlist|Harris III|Lewis|Austin|Jackson|Killings|Smith}}
| extra7 = {{hlist|Jimmy Jam and Terry Lewis|Austin}}
| length7 =
| title8 = Watch Me Go
| writer8 = {{hlist|[[Morris Day]]|[[Jesse Johnson (musician)|Jesse Johnson]]|[[Jellybean Johnson]]}}
| extra8 = Jimmy Jam and Terry Lewis
| length8 =
| title9 = Waiting
| writer9 = {{hlist|Harris III|Lewis|Moore|[[Linda Perry]]|[[Cathy Dennis]]}}
| extra9 = {{hlist|Jimmy Jam and Terry Lewis|[[Linda Perry]]|Brown{{ref|a|[a]}}}}
| length9 =
| title10 = Release Yo' Self
| note10 = featuring [[Method Man]]
| writer10 = {{hlist|[[Rob Dougan]]|[[Amerie|Amerie Rogers]]|[[Street Life (rapper)|Patrick Charles]]|[[Method Man|Clifford Smith]]}}
| extra10 = {{hlist|[[Rob Dougan]]|[[Jack Knight (songwriter)|Jack Knight]]{{ref|a|[a]}}}}
| length10 =
}}
{{track listing
| headline = Deluxe edition
| extra_column = Producer(s)
| total_length =
| title_width = 30%
| writing_width = 70%
| title11 = Swinging London Town
| writer11 = {{hlist|[[Manuel Seal]]|Knowles|[[Robert Waller (musician)|Robert Waller]]}}
| extra11 = {{hlist|[[Manuel Seal]]|[[Solange Knowles]]{{ref|a|[a]}}|[[Robert Waller (musician)|Robert Waller]]{{ref|a|[a]}}|[[Karen Poole]]{{ref|a|[a]}}}}
| length11 =
| title12 = It's Magic
| writer12 = [[Mary Brown (songwriter)|Mary Brown]]
| extra12 = {{hlist|Malik Pendleton|Brown{{ref|a|[a]}}}}
| length12 =
}}
==Notes==
* <sup>{{note|a|[a]}}</sup> signifies a vocal producer.
* <sup>{{note|b|[b]}}</sup> signifies an additional producer.
===Sample credits===
* "All Nite (Don't Stop)" contains a portion of the composition "[[There She Goes (Babyface song)|There She Goes]]", written by [[Babyface (musician)|Kenneth Edmonds]], [[Pharrell Williams]] and [[Chad Hugo]].
== Personnel ==
== Charts ==
=== Weekly charts ===
=== Year-end charts ===
== Certifications and sales ==
==Notes==
{{reflist|group=note}}
== References ==
{{Reflist}}
{{Backstreet Girls (girl group)|state=collapsed}}
{{Authority control}}
{{DEFAULTSORT:7 24}}
[[Category:2006 albums]]
[[Category:Backstreet Girls (girl group) albums]]
[[Category:Columbia Records albums]]
[[Category:Tropical house albums]]
[[Category:House music albums by American artists]]
[[Category:Contemporary R&B albums by American artists]]
pdb2l0dbyko4lzmolnc30y2os7flgh1
Achtung Zooropa
0
177834
781242
757503
2026-10-27T19:48:39Z
Jaheim Ember
76112
/* Track listing */
781242
wikitext
text/x-wiki
{{short description|2002 studio album by Goldplay}}
{{featured article}}
{{Use American English|date=September 2013}}
{{Use British English|date=September 2013}}
{{Use dmy dates|date=October 2020}}
{{Infobox album
| name = Achtung Zooropa
| type = studio
| artist = [[Goldplay]]
| cover =
| border =
| alt =
| released = {{Start date|2002|11|18|df=y}}
| recorded = October 2001 – September 2002
| studio =
| genre =
* [[Synth-pop]]
* [[art pop]]
* [[indie pop]]
* [[eletropop]]
| length =
| label =
* [[Island Def Jam Recordings|Island Def Jam]]
* [[EMI Records|EMI]]
| producer =
* [[Chris Martin]]
* [[Rik Simpson]]
* [[Bono]]
* [[The Edge]]
* [[Brian Eno]]
* [[Jon Hopkins]]
| prev_title = [[All That You Can Leave Behind]]
| prev_year = 2000
| next_title = [[Zoobaby]]
| next_year = 2005
| misc = {{Singles
| name = Achtung Zooropa
| type = studio
| single1 = [[Round Round (song)|Round Round]]
| single1date = 21 October 2002
| single2 = [[Where It's At (Goldplay song)|Where It's At]]
| single2date = 2 December 2002
| single3 = [[Angels (Goldplay song)|Angels]]
| single3date = 24 February 2003
| single4 = [[Champagne Supernova (song)|Champagne Supernova]]
| single4date = 8 June 2003
| single5 = [[Not Ready to Make Nice (song)|Not Ready to Make Nice]]
| single5date = 23 November 2003
}}
}}
'''''Achtung Zooropa''''' ({{IPAc-en|ˈ|a:|x|t|ʊ|ŋ}} {{respell|AHKH|toong}})<ref name="pons"/> is the second studio album by the American-British [[pop music|pop]] band [[Goldplay]]. It was released on 18 November 2002 by [[Island Def Jam Recordings]] and [[EMI Records]].
==Background==
==Recording and production==
==Composition==
==Release and promotion==
==Track listing==
{{Track listing
| extra_column = Producer(s)
| total_length =
| title1 = Hot Fudge
| note1 = featuring [[Robbie Williams]] and [[Sugababes]]
| title2 = [[Champagne Supernova (song)|Champagne Supernova]]
| note2 = featuring [[Liam Gallagher]] and [[Noel Gallagher]]
| title3 = [[Angels (Goldplay song)|Angels]]
| note3 = featuring Robbie Williams
| title4 = [[Here with Me (Goldplay song)|Here with Me]]
| note4 = featuring [[Dido (singer)|Dido]] and [[Enya]]
| title5 = [[Not Ready to Make Nice (song)|Not Ready to Make Nice]]
| note5 = featuring [[The Chicks|Dixie Chicks]]
| title6 = Loveboat
| note6 = featuring [[Kylie Minogue]] and Robbie Williams
| title7 = [[Round Round (song)|Round Round]]
| note7 = featuring Sugababes
| title8 = [[Where It's At (Goldplay song)|Where It's At]]
| title9 = Bittersweet Goodbye
| note9 = featuring [[Mick Jagger]] and [[Keith Richards]]
| title10 = Disco Down
| note10 = featuring [[Madonna]]
| title11 = Butterfly
| title12 = So Now Goodbye
| note12 = featuring [[Dannii Minogue]]
}}
== Personnel ==
==Charts==
==Certifications==
==References==
=== Notes ===
{{Reflist|group="nb"}}
=== Footnotes ===
{{reflist}}
==External links==
{{Goldplay}}
{{Goldplay songs}}
{{Authority control}}
[[Category:2002 albums]]
[[Category:Albums produced by Brian Eno]]
[[Category:Albums produced by Rik Simpson]]
[[Category:Albums produced by Jon Hopkins]]
[[Category:Island Def Jam Recordings albums]]
[[Category:EMI Records albums]]
[[Category:Goldplay albums]]
[[Category:Dance-pop albums]]
[[Category:Dance music albums by American artists]]
[[Category:Dance music albums by British artists]]
[[Category:2000s in American Music]]
[[Category:2000s in British music]]
tkv2s5whm0toi57jaq94g4emi7f7d8t
Template:AIC article list
10
178278
781405
766989
2026-10-28T05:34:05Z
DVRTed
55993
copying https://en.wikipedia.org/wiki/Template:AIC_article_list
781405
wikitext
text/x-wiki
{{#ifeq:{{NAMESPACE}}|Template||{{#if:{{{1|}}}|Each individual row below tracks an article's status using a template with '''three''' parameters: <span class="nowrap"><code><nowiki />{{[[TM:AIC article row|AIC article row]]{{!}}a '''page title''', {{!}}a '''status''', {{!}}and optional '''notes''' <nowiki />}}</code></span><br>
Available '''statuses''' are: <span class="nowrap">{{bang}}<code>to do</code>/<code>requested</code>,</span> <span class="nowrap">{{cloc}}<code>ongoing</code>,</span> <span class="nowrap">{{yeag}}<code>tagged</code>,</span> <span class="nowrap">{{nayc}}<code>unnecessary</code>,</span> and <span class="nowrap">{{yeac}}<code>completed</code>.</span><br>
Please update and add entries as needed.|Usage guide available at {{tl|AIC article list}}.}}}}
{| class="wikitable sortable"
! Article !! Status !! Notes
{{#if:{{{1|}}}|{{{1}}}}}
|}<noinclude>
{{documentation}}
</noinclude>
c52twnqpc1yon6ttswxg7kgffam0mc8
781412
781405
2026-10-28T07:45:23Z
DVRTed
55993
add optional "index" param
781412
wikitext
text/x-wiki
{{#ifeq:{{NAMESPACE}}|Template||{{#if:{{{1|}}}|Each individual row below tracks an article's status using a template with '''three''' parameters: <span class="nowrap"><code><nowiki />{{[[TM:AIC article row|AIC article row]]{{!}}a '''page title''', {{!}}a '''status''', {{!}}and optional '''notes''' <nowiki />}}</code></span><br>
Available '''statuses''' are: <span class="nowrap">{{bang}}<code>to do</code>/<code>requested</code>,</span> <span class="nowrap">{{cloc}}<code>ongoing</code>,</span> <span class="nowrap">{{yeag}}<code>tagged</code>,</span> <span class="nowrap">{{nayc}}<code>unnecessary</code>,</span> and <span class="nowrap">{{yeac}}<code>completed</code>.</span><br>
Please update and add entries as needed.|Usage guide available at {{tl|AIC article list}}.}}}}
{| class="wikitable sortable ainb-cleanup-table" {{#if:{{{index|}}}|data-table-index="{{{index}}}"}}
! Article !! Status !! Notes
{{#if:{{{1|}}}|{{{1}}}}}
|}<noinclude>
{{documentation}}
</noinclude>
ni91r61wyxmlqol0iqc2jarpmofsp0d
Wikipedia:AI noticeboard/DVRTed
4
178751
781161
780936
2026-10-27T15:06:10Z
DVRTed
55993
Adding a new discussion subsection (using [[User:DVRTed/AINB-helper|AINB-helper]])
781161
wikitext
text/x-wiki
{{AINB case banner |cleanup=completed |llmprod=undetermined |conduct=review |case_type=user}}
== Discussion ==
All around me are familiar faces... [[User:DVRTed|DVRTed]] ([[User talk:DVRTed|talk]]) 05:49, 23 October 2026 (UTC)
:test reply breaking the script [[User:DVRTed|DVRTed]] ([[User talk:DVRTed|talk]]) 05:49, 23 October 2026 (UTC)
::test again [[User:DVRTed|DVRTed]] ([[User talk:DVRTed|talk]]) 05:49, 23 October 2026 (UTC)
:::test3 [[User:DVRTed|DVRTed]] ([[User talk:DVRTed|talk]]) 05:50, 23 October 2026 (UTC)
=== 27 October 2026 ===
testing lah [[User:DVRTed|DVRTed]] ([[User talk:DVRTed|talk]]) 15:06, 27 October 2026 (UTC)
== Cleanup ==
{{InfoCard|content='''Tracker detail'''
* User: {{User-ainb|1=DVRTed}}
* Start date: 2022-12-01
* End date: 2026-10-25}}
=== Tracking list ===
{{AIC article list|
{{AIC article row|article=Test Page 817876|status=requested|notes=3 edits: [[Special:Diff/780857|(+314)]] [[Special:Diff/765241|(+83)]] [[Special:Diff/691118|(+14)]]}}
{{AIC article row|article=BS Nymph|status=requested|notes=24 edits: [[Special:Diff/768763|(+70)]] [[Special:Diff/768500|(+314)]] [[Special:Diff/765391|(+5)]] [[Special:Diff/762815|(+330)]] [[Special:Diff/762814|(-2679)]] [[Special:Diff/762811|(+88)]] [[Special:Diff/762776|(+331)]] [[Special:Diff/762774|(+90)]] [[Special:Diff/762772|(+283)]] [[Special:Diff/762716|(+88)]] [[Special:Diff/762713|(+88)]] [[Special:Diff/762710|(+88)]] [[Special:Diff/762707|(+318)]] [[Special:Diff/762691|(+318)]] [[Special:Diff/762688|(+88)]] [[Special:Diff/762686|(+318)]] [[Special:Diff/762684|(+88)]] [[Special:Diff/762682|(+88)]] [[Special:Diff/762681|(+88)]] [[Special:Diff/762678|(+318)]] [[Special:Diff/762677|(-88)]] [[Special:Diff/762676|(+88)]] [[Special:Diff/759773|(-85)]] [[Special:Diff/759772|(+85)]]}}
}}
eugj4dvqo1u5utz02a3e3b9668fl2hd
781243
781161
2026-10-27T19:55:57Z
DVRTed
55993
/* Cleanup */
781243
wikitext
text/x-wiki
{{AINB case banner |cleanup=completed |llmprod=undetermined |conduct=review |case_type=user}}
== Discussion ==
All around me are familiar faces... [[User:DVRTed|DVRTed]] ([[User talk:DVRTed|talk]]) 05:49, 23 October 2026 (UTC)
:test reply breaking the script [[User:DVRTed|DVRTed]] ([[User talk:DVRTed|talk]]) 05:49, 23 October 2026 (UTC)
::test again [[User:DVRTed|DVRTed]] ([[User talk:DVRTed|talk]]) 05:49, 23 October 2026 (UTC)
:::test3 [[User:DVRTed|DVRTed]] ([[User talk:DVRTed|talk]]) 05:50, 23 October 2026 (UTC)
=== 27 October 2026 ===
testing lah [[User:DVRTed|DVRTed]] ([[User talk:DVRTed|talk]]) 15:06, 27 October 2026 (UTC)
== Cleanup ==
{{InfoCard|content='''Tracker detail'''
* User: {{User-ainb|1=DVRTed}}
* User2: {{User-ainb|1=DVRTed}}
* User2: {{User-ainb|1=DVRTed bot}}
* User2: {{User-ainb|1=DVRTed_bot}}
* Start date: 2022-12-01
* End date: 2026-10-25}}
=== Tracking list ===
{{AIC article list|
{{AIC article row|article=Test Page 817876|status=requested|notes=3 edits: [[Special:Diff/780857|(+314)]] [[Special:Diff/765241|(+83)]] [[Special:Diff/691118|(+14)]]}}
{{AIC article row|article=BS Nymph|status=requested|notes=24 edits: [[Special:Diff/768763|(+70)]] [[Special:Diff/768500|(+314)]] [[Special:Diff/765391|(+5)]] [[Special:Diff/762815|(+330)]] [[Special:Diff/762814|(-2679)]] [[Special:Diff/762811|(+88)]] [[Special:Diff/762776|(+331)]] [[Special:Diff/762774|(+90)]] [[Special:Diff/762772|(+283)]] [[Special:Diff/762716|(+88)]] [[Special:Diff/762713|(+88)]] [[Special:Diff/762710|(+88)]] [[Special:Diff/762707|(+318)]] [[Special:Diff/762691|(+318)]] [[Special:Diff/762688|(+88)]] [[Special:Diff/762686|(+318)]] [[Special:Diff/762684|(+88)]] [[Special:Diff/762682|(+88)]] [[Special:Diff/762681|(+88)]] [[Special:Diff/762678|(+318)]] [[Special:Diff/762677|(-88)]] [[Special:Diff/762676|(+88)]] [[Special:Diff/759773|(-85)]] [[Special:Diff/759772|(+85)]]}}
}}
8zy10r255mu5jnlm3bf23rxme8fv021
781261
781243
2026-10-27T22:11:48Z
DVRTed
55993
Updated status for [[Test Page 817876]] to ongoing (using [[User:DVRTed/AINB-helper|AINB-helper]])
781261
wikitext
text/x-wiki
{{AINB case banner |cleanup=completed |llmprod=undetermined |conduct=review |case_type=user}}
== Discussion ==
All around me are familiar faces... [[User:DVRTed|DVRTed]] ([[User talk:DVRTed|talk]]) 05:49, 23 October 2026 (UTC)
:test reply breaking the script [[User:DVRTed|DVRTed]] ([[User talk:DVRTed|talk]]) 05:49, 23 October 2026 (UTC)
::test again [[User:DVRTed|DVRTed]] ([[User talk:DVRTed|talk]]) 05:49, 23 October 2026 (UTC)
:::test3 [[User:DVRTed|DVRTed]] ([[User talk:DVRTed|talk]]) 05:50, 23 October 2026 (UTC)
=== 27 October 2026 ===
testing lah [[User:DVRTed|DVRTed]] ([[User talk:DVRTed|talk]]) 15:06, 27 October 2026 (UTC)
== Cleanup ==
{{InfoCard|content='''Tracker detail'''
* User: {{User-ainb|1=DVRTed}}
* User2: {{User-ainb|1=DVRTed}}
* User2: {{User-ainb|1=DVRTed bot}}
* User2: {{User-ainb|1=DVRTed_bot}}
* Start date: 2022-12-01
* End date: 2026-10-25}}
=== Tracking list ===
{{AIC article list|
{{AIC article row|article=Test Page 817876|status=ongoing|notes=3 edits: [[Special:Diff/780857|(+314)]] [[Special:Diff/765241|(+83)]] [[Special:Diff/691118|(+14)]] [[special:diff/781260|prodded]]}}
{{AIC article row|article=BS Nymph|status=requested|notes=24 edits: [[Special:Diff/768763|(+70)]] [[Special:Diff/768500|(+314)]] [[Special:Diff/765391|(+5)]] [[Special:Diff/762815|(+330)]] [[Special:Diff/762814|(-2679)]] [[Special:Diff/762811|(+88)]] [[Special:Diff/762776|(+331)]] [[Special:Diff/762774|(+90)]] [[Special:Diff/762772|(+283)]] [[Special:Diff/762716|(+88)]] [[Special:Diff/762713|(+88)]] [[Special:Diff/762710|(+88)]] [[Special:Diff/762707|(+318)]] [[Special:Diff/762691|(+318)]] [[Special:Diff/762688|(+88)]] [[Special:Diff/762686|(+318)]] [[Special:Diff/762684|(+88)]] [[Special:Diff/762682|(+88)]] [[Special:Diff/762681|(+88)]] [[Special:Diff/762678|(+318)]] [[Special:Diff/762677|(-88)]] [[Special:Diff/762676|(+88)]] [[Special:Diff/759773|(-85)]] [[Special:Diff/759772|(+85)]]}}
}}
4gmevqne1r21qs17uy7it0dpzpnj4q3
Music Monck
0
179078
781401
781126
2026-10-28T02:54:58Z
Jaheim King
76391
/* Track listing */
781401
wikitext
text/x-wiki
{{Infobox album
| name = Music Monck
| type = studio
| artist = [[B-Seed]]
| cover =
| border =
| alt =
| released = {{Start date|2013|06|02}}<br>{{Start date|2014|08|23}}<br />(international)
| recorded = 2012–2013
| venue =
| studio =
| genre = {{hlist|[[Reggae]]|[[dancehall]]|[[ragga]]|[[dub music|dub]]|[[reggaeton]]|[[hip-hop music|hip-hop]]}}
| length =
| language = {{hlist|English|[[Jamaican Patois]]|Spanish}}
| label = {{hlist|[[Rhino Entertainment|Rhino]]|[[East West Records|EastWest]]}}
| producer =
| prev_title = [[Old Dubby Conquerors]]
| prev_year = 2011
| next_title = [[Nexus!]]
| next_year = 2015
| misc = {{Singles
| name = Music Monck
| type = studio
| single1 = [[Impacto Freestyle]]
| single1date = April 2, 2012
| single2 = [[Rompe Freestyle]]
| single2date = May 12, 2013
| single3 = [[Hotel Room Service Freestyle]]
| single3date = October 31, 2013
| single4 = [[Descontrol Freestyle]]
| single4date = November 3, 2013
| single5 = [[Get Busy Freestyle]]
| single5date = June 21, 2014
}}
}}
'''''Music Monck''''' is the second studio album by Canadian-American [[reggae fusion]] band [[B-Seed]]. It was released on June 2, 2013.
== Track listing ==
{{Track listing
| extra_column = Producer(s)
| total_length =
| title1 = [[Rompe Freestyle]]
| writer1 = {{hlist|[[Daddy Yankee|Ramón Ayala]]|[[Sean Paul|Sean Paul Henriques]]|[[Pitbull (rapper)|Armando Perez]]|[[Monserrate & DJ Urba|Urbani Mota]]|[[Monserrate & DJ Urba|Alex Monserrate]]|Luis Diaz|Hugo Diaz|Sam Fisher|[[Eddie Dee|Eddie Ávila]]}}
| extra1 = {{hlist|[[Monserrate & DJ Urba|DJ Urba & Monserrate]]|The Diaz Brothers}}
| length1 =
| title2 = [[Hotel Room Service Freestyle]]
| writer2 = {{hlist|R. Ayala|S. Henriques|[[Jim Jonsin|James Scheffer]]|[[DJ Buddha|Urales Vargas]]|[[Nile Rodgers]]|[[Bernard Edwards]]|[[Uncle Luke|Luther Campbell]]|[[Mr. Mixx|David Hobbs]]|[[Brother Marquis|Mark Ross]]|[[Fresh Kid Ice|Christopher Wong Won]]|[[Nightcrawlers (band)|Hugh Brankin]]|[[Ross Campbell (composer)|Ross Campbell]]|[[Nightcrawlers (band)|John Reid]]|[[Nightcrawlers (band)|Graham Wilson]]}}
| extra2 = {{hlist|[[Jim Jonsin]]|[[DJ Buddha]]}}
| length2 =
| title3 = Dale Caliente Freestyle
| writer3 = {{hlist|R. Ayala|S. Henriques|A. Perez|U. Mota|A. Monserrate|U. Vargas}}
| extra3 = {{hlist|DJ Urba & Monserrate|DJ Buddha{{ref|a|[a]}}}}
| length3 =
| title4 = [[Get Busy Freestyle]]
| writer4 = {{hlist|R. Ayala|S. Henriques|A. Perez|Jeremy Harding|[[Steven "Lenky" Marsden|Steven Marsden]]}}
| extra4 = {{hlist|Jeremy Harding|[[Steven "Lenky" Marsden]]}}
| length4 =
| title5 = Tu Príncipe Freestyle
| writer5 = {{hlist|R. Ayala|S. Henriques|[[Luny Tunes|Francisco Saldaña]]|[[Luny Tunes|Víctor Cabrera]]|L. Diaz|H. Diaz}}
| extra5 = {{hlist|[[Luny Tunes]]|The Diaz Brothers}}
| length5 =
| title6 = Can You Do the Work Freestyle
| note6 = featuring [[Ivy Queen]]
| writer6 = {{hlist|R. Ayala|[[Ivy Queen|Martha Pesante]]|J. Harding|U. Vargas}}
| extra6 = {{hlist|Jeremy Harding|DJ Buddha}}
| length6 =
| title7 = Concrete Freestyle
| writer7 = {{hlist|R. Ayala|J. Harding|Louis Malcolm|Richard Myrie}}
| extra7 = {{hlist|Jeremy Harding|Louis "Flabba" Malcolm}}
| length7 =
| title8 = Punkie Freestyle
| writer8 = {{hlist|A. Perez|J. Harding|Richard Martin}}
| extra8 = {{hlist|Jeremy Harding|Richard "Richie D" Martin}}
| length8 =
| title9 = Mensaje de Estado Freestyle
| writer9 = {{hlist|R. Ayala|S. Henriques|[[Mr. Collipark|Michael Crooms]]|José Torres}}
| extra9 = {{hlist|[[Mr. Collipark]]|José "Gocho" Torres{{ref|a|[a]}}}}
| length9 =
| title10 = [[Impacto Freestyle]]
| note10 = featuring [[Shaggy (musician)|Shaggy]]
| writer10 = {{hlist|R. Ayala|S. Henriques|A. Perez|[[Shaggy (musician)|Orville Burrell]]|[[Scott Storch]]|[[Tainy|Marco Masís]]|Robert Livingston|[[Shaun Pizzonia]]}}
| extra10 = {{hlist|[[Scott Storch]]|[[Tainy]]|Robert Livingston{{ref|a|[a]}}|[[Shaun Pizzonia|Shaun "Sting International" Pizzonia]]{{ref|a|[a]}}}}
| length10 =
| title11 = Head to Toe Freestyle
| writer11 = {{hlist|S. Henriques|R. Ayala|U. Vargas|Donovan Bennett|Paul Edmund|Andre Gray}}
| extra11 = {{hlist|DJ Buddha|Donovan "Don Corleon" Bennett}}
| length11 =
| title12 = Now That I've Got Your Love Freestyle
| writer12 = {{hlist|R. Ayala|S. Henriques|A. Perez|[[Di Genius|Stephen McGregor]]|J. Harding}}
| extra12 = {{hlist|[[Di Genius|Stephen "Di Genius" McGregor]]|Jeremy Harding{{ref|a|[a]}}}}
| length12 =
}}
'''Notes'''
* {{sup|{{note|a|[a]}}}} signifies an additional producer.
== Personnel ==
=== B-Seed ===
* [[Daddy Yankee]] – vocals
* [[Pitbull (rapper)|Pitbull]] – vocals
* [[Sean Paul]] – vocals
* [[DJ Buddha]] – turntables
* [[Eric Stefani]] – keyboards, background vocals
* Jeremy Harding – saxophone
* Fahrenheit – trombone, background vocals
* [[Tom Dumont]] – guitars
* [[Tony Kanal]] – bass
* Jigzagula – percussion, background vocals
* [[Adrian Young]] – drums
== Charts ==
===Weekly charts===
===Year-end charts===
== Certifications ==
== References ==
{{Reflist}}
== External links ==
{{B-Seed}}
{{Authority control}}
[[Category:2013 albums]]
[[Category:B-Seed albums]]
[[Category:Rhino Entertainment albums]]
[[Category:East West Records albums]]
k9nff87z3i82c9o9f22ifu40i9erc1m
Nexus!
0
179079
781402
767407
2026-10-28T03:13:43Z
Jaheim King
76391
/* Track listing */
781402
wikitext
text/x-wiki
{{Infobox album
| name = Nexus!
| type = studio
| artist = [[B-Seed]]
| cover =
| border =
| alt =
| released = {{Start date|2015|10|14}}<br>{{Start date|2016|03|12}}<br />(international)
| recorded = 2014–2015
| venue =
| studio =
| genre = {{hlist|[[Dancehall]]|[[reggaeton]]|[[dub music|dub]]|[[reggae]]|[[hip-hop music|hip-hop]]|[[reggae fusion]]}}
| length =
| language = {{hlist|English|[[Jamaican Patois]]|Spanish}}
| label = [[Rhino Entertainment|Rhino]]
| producer =
| prev_title = [[Music Monck]]
| prev_year = 2013
| next_title = [[B-Seed (album)|B-Seed]]
| next_year = 2018
| misc = {{Singles
| name = Nexus!
| type = studio
| single1 = [[Shut It Down Freestyle]]
| single1date = August 22, 2015
| single2 = [[Grito Mundial Freestyle]]
| single2date = November 25, 2015
| single3 = [[So Fine Freestyle]]
| single3date = February 1, 2016
| single4 = [[Pose Freestyle]]
| single4date = November 3, 2016
}}
}}
'''''Nexus''''' is the third studio album by Canadian-American [[reggae fusion]] band [[B-Seed]]. It was released on October 14, 2015.
== Track listing ==
{{Track listing
| extra_column = Producer(s)
| total_length =
| title1 = [[Grito Mundial Freestyle]]
| writer1 = {{hlist|[[Daddy Yankee|Ramón Ayala]]|[[Sean Paul|Sean Paul Henriques]]|[[Pitbull (rapper)|Armando Perez]]|Jeremy Harding|[[Musicólogo & Menes|Eliezer García]]|[[Eduardo López]]}}
| extra1 = {{hlist|Jeremy Harding|[[Musicólogo & Menes|Los de la Nazza]]}}
| length1 =
| title2 = Send It On Freestyle
| writer2 = {{hlist|R. Ayala|A. Perez|S. Henriques|[[DJ Buddha|Urales Vargas]]|[[Steven "Lenky" Marsden|Steven Marsden]]|J. Harding|Dansel Cole}}
| extra2 = {{hlist|[[DJ Buddha]]|[[Steven "Lenky" Marsden]]|Jeremy Harding{{ref|a|[a]}}}}
| length2 =
| title3 = [[Shut It Down Freestyle]]
| note3 = featuring [[Nelly Furtado]]
| writer3 = {{hlist|R. Ayala|S. Henriques|[[Nelly Furtado]]|[[RedOne|Nadir Khayat]]|[[DJ Snake|William Grigahcine]]}}
| extra3 = {{hlist|[[RedOne]]|[[DJ Snake]]}}
| length3 =
| title4 = El Truco Freestyle
| writer4 = {{hlist|R. Ayala|S. Henriques|A. Perez|[[Monserrate & DJ Urba|Urbani Mota]]|[[Monserrate & DJ Urba|Alex Monserrate]]|[[Clinton Sparks]]|Terrence Harold|Sam Fisher}}
| extra4 = {{hlist|[[Monserrate & DJ Urba|DJ Urba & Monserrate]]|[[Clinton Sparks]]}}
| length4 =
| title5 = Lace It Freestyle
| writer5 = {{hlist|R. Ayala|A. Perez|S. Henriques|[[Di Genius|Stephen McGregor]]|[[Jimmy Thörnfeldt]]}}
| extra5 = {{hlist|[[Di Genius|Stephen "Di Genius" McGregor]]|[[Jimmy Thörnfeldt|Jimmy Joker]]}}
| length5 =
| title6 = El Jefe Freestyle
| note6 = featuring [[Nayer]]
| writer6 = {{hlist|A. Perez|[[Nayer|Nayer Regalado]]|J. Harding|Luis Diaz|Hugh Diaz|Leo Vazquez}}
| extra6 = {{hlist|Jeremy Harding|The Diaz Brothers}}
| length6 =
| title7 = Tensión Freestyle
| writer7 = {{hlist|R. Ayala|A. Perez|S. Henriques|[[Luny Tunes|Francisco Saldaña]]|[[Luny Tunes|Víctor Cabrera]]|[[Nely el Arma Secreta|Josías de la Cruz]]|[[Héctor el Father|Héctor Delgado]]|José Torres}}
| extra7 = {{hlist|[[Luny Tunes]]|[[Nely el Arma Secreta|Nely]]}}
| length7 =
| title8 = El Empuje Freestyle
| writer8 = {{hlist|R. Ayala|S. Henriques|A. Perez|U. Mota|A. Monserrate|W. Grigahcine}}
| extra8 = {{hlist|DJ Urba & Monserrate|DJ Snake}}
| length8 =
| title9 = A lo Clásico Freestyle
| writer9 = {{hlist|S. Henriques|R. Ayala|[[Scott Storch]]|J. de la Cruz|[[Tainy|Marco Masís]]|J. Torres|U. Vargas|L. Vazquez}}
| extra9 = {{hlist|[[Scott Storch]]|Nely|[[Tainy]]|José "Gocho" Torres{{ref|a|[a]}}}}
| length9 =
| title10 = [[Pose Freestyle]]
| writer10 = {{hlist|A. Perez|R. Ayala|S. Henriques|N. Khayat|E. García|E. López|J. Thörnfeldt|T. Harold}}
| extra10 = {{hlist|RedOne|Los de la Nazza|Jimmy Joker}}
| length10 =
| title11 = [[So Fine Freestyle]]
| writer11 = {{hlist|R. Ayala|S. Henriques|A. Perez|N. Khayat|S. McGregor|J. Thörnfeldt|U. Vargas|Nigel Staff}}
| extra11 = {{hlist|RedOne|Stephen "Di Genius" McGregor|Jimmy Joker}}
| length11 =
| title12 = Shake That Thing Freestyle
| writer12 = {{hlist|R. Ayala|A. Perez|S. Henriques|J. Harding|Troy Rami}}
| extra12 = {{hlist|Jeremy Harding|Troyton Rami}}
| length12 =
| title13 = Plane To P.R. Freestyle
| writer13 = {{hlist|A. Perez|S. Henriques|[[will.i.am|William Adams]]|U. Vargas]]}}
| extra13 = {{hlist|[[wil.i.am]]|DJ Buddha}}
| length13 =
| title14 = Salgo pa' la Calle Freestyle
| note14 = featuring Sophia Del Carmen
| writer14 = {{hlist|S. Henriques|A. Perez|Sophia Del Carmen|F. Saldaña|V. Cadrera|J. de la Cruz|M. Masís|Jason Henriques}}
| extra14 = {{hlist|Luny Tunes|Nely|Tainy}}
| length14 =
}}
'''Notes'''
* {{sup|{{note|a|[a]}}}} signifies an additional producer.
== Personnel ==
== Charts ==
===Weekly charts===
===Year-end charts===
== Certifications ==
== References ==
{{Reflist}}
== External links ==
{{B-Seed}}
{{Authority control}}
[[Category:2015 albums]]
[[Category:B-Seed albums]]
[[Category:Rhino Entertainment albums]]
ify0yvn0tmk19bq4nz7osu24uau5bsa
781403
781402
2026-10-28T03:14:04Z
Jaheim King
76391
/* Track listing */
781403
wikitext
text/x-wiki
{{Infobox album
| name = Nexus!
| type = studio
| artist = [[B-Seed]]
| cover =
| border =
| alt =
| released = {{Start date|2015|10|14}}<br>{{Start date|2016|03|12}}<br />(international)
| recorded = 2014–2015
| venue =
| studio =
| genre = {{hlist|[[Dancehall]]|[[reggaeton]]|[[dub music|dub]]|[[reggae]]|[[hip-hop music|hip-hop]]|[[reggae fusion]]}}
| length =
| language = {{hlist|English|[[Jamaican Patois]]|Spanish}}
| label = [[Rhino Entertainment|Rhino]]
| producer =
| prev_title = [[Music Monck]]
| prev_year = 2013
| next_title = [[B-Seed (album)|B-Seed]]
| next_year = 2018
| misc = {{Singles
| name = Nexus!
| type = studio
| single1 = [[Shut It Down Freestyle]]
| single1date = August 22, 2015
| single2 = [[Grito Mundial Freestyle]]
| single2date = November 25, 2015
| single3 = [[So Fine Freestyle]]
| single3date = February 1, 2016
| single4 = [[Pose Freestyle]]
| single4date = November 3, 2016
}}
}}
'''''Nexus''''' is the third studio album by Canadian-American [[reggae fusion]] band [[B-Seed]]. It was released on October 14, 2015.
== Track listing ==
{{Track listing
| extra_column = Producer(s)
| total_length =
| title1 = [[Grito Mundial Freestyle]]
| writer1 = {{hlist|[[Daddy Yankee|Ramón Ayala]]|[[Sean Paul|Sean Paul Henriques]]|[[Pitbull (rapper)|Armando Perez]]|Jeremy Harding|[[Musicólogo & Menes|Eliezer García]]|[[Eduardo López]]}}
| extra1 = {{hlist|Jeremy Harding|[[Musicólogo & Menes|Los de la Nazza]]}}
| length1 =
| title2 = Send It On Freestyle
| writer2 = {{hlist|R. Ayala|A. Perez|S. Henriques|[[DJ Buddha|Urales Vargas]]|[[Steven "Lenky" Marsden|Steven Marsden]]|J. Harding|Dansel Cole}}
| extra2 = {{hlist|[[DJ Buddha]]|[[Steven "Lenky" Marsden]]|Jeremy Harding{{ref|a|[a]}}}}
| length2 =
| title3 = [[Shut It Down Freestyle]]
| note3 = featuring [[Nelly Furtado]]
| writer3 = {{hlist|R. Ayala|S. Henriques|[[Nelly Furtado]]|[[RedOne|Nadir Khayat]]|[[DJ Snake|William Grigahcine]]}}
| extra3 = {{hlist|[[RedOne]]|[[DJ Snake]]}}
| length3 =
| title4 = El Truco Freestyle
| writer4 = {{hlist|R. Ayala|S. Henriques|A. Perez|[[Monserrate & DJ Urba|Urbani Mota]]|[[Monserrate & DJ Urba|Alex Monserrate]]|[[Clinton Sparks]]|Terrence Harold|Sam Fisher}}
| extra4 = {{hlist|[[Monserrate & DJ Urba|DJ Urba & Monserrate]]|[[Clinton Sparks]]}}
| length4 =
| title5 = Lace It Freestyle
| writer5 = {{hlist|R. Ayala|A. Perez|S. Henriques|[[Di Genius|Stephen McGregor]]|[[Jimmy Thörnfeldt]]}}
| extra5 = {{hlist|[[Di Genius|Stephen "Di Genius" McGregor]]|[[Jimmy Thörnfeldt|Jimmy Joker]]}}
| length5 =
| title6 = El Jefe Freestyle
| note6 = featuring [[Nayer]]
| writer6 = {{hlist|A. Perez|[[Nayer|Nayer Regalado]]|J. Harding|Luis Diaz|Hugh Diaz|Leo Vazquez}}
| extra6 = {{hlist|Jeremy Harding|The Diaz Brothers}}
| length6 =
| title7 = Tensión Freestyle
| writer7 = {{hlist|R. Ayala|A. Perez|S. Henriques|[[Luny Tunes|Francisco Saldaña]]|[[Luny Tunes|Víctor Cabrera]]|[[Nely el Arma Secreta|Josías de la Cruz]]|[[Héctor el Father|Héctor Delgado]]|José Torres}}
| extra7 = {{hlist|[[Luny Tunes]]|[[Nely el Arma Secreta|Nely]]}}
| length7 =
| title8 = El Empuje Freestyle
| writer8 = {{hlist|R. Ayala|S. Henriques|A. Perez|U. Mota|A. Monserrate|W. Grigahcine}}
| extra8 = {{hlist|DJ Urba & Monserrate|DJ Snake}}
| length8 =
| title9 = A lo Clásico Freestyle
| writer9 = {{hlist|S. Henriques|R. Ayala|[[Scott Storch]]|J. de la Cruz|[[Tainy|Marco Masís]]|J. Torres|U. Vargas|L. Vazquez}}
| extra9 = {{hlist|[[Scott Storch]]|Nely|[[Tainy]]|José "Gocho" Torres{{ref|a|[a]}}}}
| length9 =
| title10 = [[Pose Freestyle]]
| writer10 = {{hlist|A. Perez|R. Ayala|S. Henriques|N. Khayat|E. García|E. López|J. Thörnfeldt|T. Harold}}
| extra10 = {{hlist|RedOne|Los de la Nazza|Jimmy Joker}}
| length10 =
| title11 = [[So Fine Freestyle]]
| writer11 = {{hlist|R. Ayala|S. Henriques|A. Perez|N. Khayat|S. McGregor|J. Thörnfeldt|U. Vargas|Nigel Staff}}
| extra11 = {{hlist|RedOne|Stephen "Di Genius" McGregor|Jimmy Joker}}
| length11 =
| title12 = Shake That Thing Freestyle
| writer12 = {{hlist|R. Ayala|A. Perez|S. Henriques|J. Harding|Troy Rami}}
| extra12 = {{hlist|Jeremy Harding|Troyton Rami}}
| length12 =
| title13 = Plane To P.R. Freestyle
| writer13 = {{hlist|A. Perez|S. Henriques|[[will.i.am|William Adams]]|U. Vargas}}
| extra13 = {{hlist|[[wil.i.am]]|DJ Buddha}}
| length13 =
| title14 = Salgo pa' la Calle Freestyle
| note14 = featuring Sophia Del Carmen
| writer14 = {{hlist|S. Henriques|A. Perez|Sophia Del Carmen|F. Saldaña|V. Cadrera|J. de la Cruz|M. Masís|Jason Henriques}}
| extra14 = {{hlist|Luny Tunes|Nely|Tainy}}
| length14 =
}}
'''Notes'''
* {{sup|{{note|a|[a]}}}} signifies an additional producer.
== Personnel ==
== Charts ==
===Weekly charts===
===Year-end charts===
== Certifications ==
== References ==
{{Reflist}}
== External links ==
{{B-Seed}}
{{Authority control}}
[[Category:2015 albums]]
[[Category:B-Seed albums]]
[[Category:Rhino Entertainment albums]]
sztst28na0ufaayaejrzyg1izl8ylsf
781404
781403
2026-10-28T03:15:51Z
Jaheim King
76391
/* Personnel */
781404
wikitext
text/x-wiki
{{Infobox album
| name = Nexus!
| type = studio
| artist = [[B-Seed]]
| cover =
| border =
| alt =
| released = {{Start date|2015|10|14}}<br>{{Start date|2016|03|12}}<br />(international)
| recorded = 2014–2015
| venue =
| studio =
| genre = {{hlist|[[Dancehall]]|[[reggaeton]]|[[dub music|dub]]|[[reggae]]|[[hip-hop music|hip-hop]]|[[reggae fusion]]}}
| length =
| language = {{hlist|English|[[Jamaican Patois]]|Spanish}}
| label = [[Rhino Entertainment|Rhino]]
| producer =
| prev_title = [[Music Monck]]
| prev_year = 2013
| next_title = [[B-Seed (album)|B-Seed]]
| next_year = 2018
| misc = {{Singles
| name = Nexus!
| type = studio
| single1 = [[Shut It Down Freestyle]]
| single1date = August 22, 2015
| single2 = [[Grito Mundial Freestyle]]
| single2date = November 25, 2015
| single3 = [[So Fine Freestyle]]
| single3date = February 1, 2016
| single4 = [[Pose Freestyle]]
| single4date = November 3, 2016
}}
}}
'''''Nexus''''' is the third studio album by Canadian-American [[reggae fusion]] band [[B-Seed]]. It was released on October 14, 2015.
== Track listing ==
{{Track listing
| extra_column = Producer(s)
| total_length =
| title1 = [[Grito Mundial Freestyle]]
| writer1 = {{hlist|[[Daddy Yankee|Ramón Ayala]]|[[Sean Paul|Sean Paul Henriques]]|[[Pitbull (rapper)|Armando Perez]]|Jeremy Harding|[[Musicólogo & Menes|Eliezer García]]|[[Eduardo López]]}}
| extra1 = {{hlist|Jeremy Harding|[[Musicólogo & Menes|Los de la Nazza]]}}
| length1 =
| title2 = Send It On Freestyle
| writer2 = {{hlist|R. Ayala|A. Perez|S. Henriques|[[DJ Buddha|Urales Vargas]]|[[Steven "Lenky" Marsden|Steven Marsden]]|J. Harding|Dansel Cole}}
| extra2 = {{hlist|[[DJ Buddha]]|[[Steven "Lenky" Marsden]]|Jeremy Harding{{ref|a|[a]}}}}
| length2 =
| title3 = [[Shut It Down Freestyle]]
| note3 = featuring [[Nelly Furtado]]
| writer3 = {{hlist|R. Ayala|S. Henriques|[[Nelly Furtado]]|[[RedOne|Nadir Khayat]]|[[DJ Snake|William Grigahcine]]}}
| extra3 = {{hlist|[[RedOne]]|[[DJ Snake]]}}
| length3 =
| title4 = El Truco Freestyle
| writer4 = {{hlist|R. Ayala|S. Henriques|A. Perez|[[Monserrate & DJ Urba|Urbani Mota]]|[[Monserrate & DJ Urba|Alex Monserrate]]|[[Clinton Sparks]]|Terrence Harold|Sam Fisher}}
| extra4 = {{hlist|[[Monserrate & DJ Urba|DJ Urba & Monserrate]]|[[Clinton Sparks]]}}
| length4 =
| title5 = Lace It Freestyle
| writer5 = {{hlist|R. Ayala|A. Perez|S. Henriques|[[Di Genius|Stephen McGregor]]|[[Jimmy Thörnfeldt]]}}
| extra5 = {{hlist|[[Di Genius|Stephen "Di Genius" McGregor]]|[[Jimmy Thörnfeldt|Jimmy Joker]]}}
| length5 =
| title6 = El Jefe Freestyle
| note6 = featuring [[Nayer]]
| writer6 = {{hlist|A. Perez|[[Nayer|Nayer Regalado]]|J. Harding|Luis Diaz|Hugh Diaz|Leo Vazquez}}
| extra6 = {{hlist|Jeremy Harding|The Diaz Brothers}}
| length6 =
| title7 = Tensión Freestyle
| writer7 = {{hlist|R. Ayala|A. Perez|S. Henriques|[[Luny Tunes|Francisco Saldaña]]|[[Luny Tunes|Víctor Cabrera]]|[[Nely el Arma Secreta|Josías de la Cruz]]|[[Héctor el Father|Héctor Delgado]]|José Torres}}
| extra7 = {{hlist|[[Luny Tunes]]|[[Nely el Arma Secreta|Nely]]}}
| length7 =
| title8 = El Empuje Freestyle
| writer8 = {{hlist|R. Ayala|S. Henriques|A. Perez|U. Mota|A. Monserrate|W. Grigahcine}}
| extra8 = {{hlist|DJ Urba & Monserrate|DJ Snake}}
| length8 =
| title9 = A lo Clásico Freestyle
| writer9 = {{hlist|S. Henriques|R. Ayala|[[Scott Storch]]|J. de la Cruz|[[Tainy|Marco Masís]]|J. Torres|U. Vargas|L. Vazquez}}
| extra9 = {{hlist|[[Scott Storch]]|Nely|[[Tainy]]|José "Gocho" Torres{{ref|a|[a]}}}}
| length9 =
| title10 = [[Pose Freestyle]]
| writer10 = {{hlist|A. Perez|R. Ayala|S. Henriques|N. Khayat|E. García|E. López|J. Thörnfeldt|T. Harold}}
| extra10 = {{hlist|RedOne|Los de la Nazza|Jimmy Joker}}
| length10 =
| title11 = [[So Fine Freestyle]]
| writer11 = {{hlist|R. Ayala|S. Henriques|A. Perez|N. Khayat|S. McGregor|J. Thörnfeldt|U. Vargas|Nigel Staff}}
| extra11 = {{hlist|RedOne|Stephen "Di Genius" McGregor|Jimmy Joker}}
| length11 =
| title12 = Shake That Thing Freestyle
| writer12 = {{hlist|R. Ayala|A. Perez|S. Henriques|J. Harding|Troy Rami}}
| extra12 = {{hlist|Jeremy Harding|Troyton Rami}}
| length12 =
| title13 = Plane To P.R. Freestyle
| writer13 = {{hlist|A. Perez|S. Henriques|[[will.i.am|William Adams]]|U. Vargas}}
| extra13 = {{hlist|[[wil.i.am]]|DJ Buddha}}
| length13 =
| title14 = Salgo pa' la Calle Freestyle
| note14 = featuring Sophia Del Carmen
| writer14 = {{hlist|S. Henriques|A. Perez|Sophia Del Carmen|F. Saldaña|V. Cadrera|J. de la Cruz|M. Masís|Jason Henriques}}
| extra14 = {{hlist|Luny Tunes|Nely|Tainy}}
| length14 =
}}
'''Notes'''
* {{sup|{{note|a|[a]}}}} signifies an additional producer.
== Personnel ==
=== B-Seed ===
* [[Daddy Yankee]] – vocals
* [[Pitbull (rapper)|Pitbull]] – vocals
* [[Sean Paul]] – vocals
* [[DJ Buddha]] – turntables
* [[Eric Stefani]] – keyboards, backing vocals
* Jeremy Harding – saxophone
* Fahrenheit – trombone, backing vocals
* [[Tom Dumont]] – guitars
* [[Tony Kanal]] – bass
* Jigzagula – percussion, backing vocals
* [[Adrian Young]] – drums
== Charts ==
===Weekly charts===
===Year-end charts===
== Certifications ==
== References ==
{{Reflist}}
== External links ==
{{B-Seed}}
{{Authority control}}
[[Category:2015 albums]]
[[Category:B-Seed albums]]
[[Category:Rhino Entertainment albums]]
skgnd0gbukgkegcf1713s7x95l9o4cg
Ibero-American MAB Network (IberoMAB)
0
179378
781414
769727
2026-10-28T09:26:39Z
OrangeLemonadee
76341
referance edit
781414
wikitext
text/x-wiki
== Ibero-American MAB Network ==
The '''Ibero-American MAB Network''' ('''IberoMAB''') is a regional network established in 1992 within the framework of the Man and the Biosphere (MAB) Programme of the United Nations Educational, Scientific and Cultural Organization (UNESCO). It is a link between countries in Latin America and the Caribbean and between Spain and Portugal to foster cooperation between biosphere reserves and sustainable development. The network's objectives are to support the strengthening of national MAB committees, foster cooperation among the member countries and encourage the establishment of new biosphere reserves.<ref name="UNESCO">{{cite web |title=Regional and Thematic Networks |url=https://www.unesco.org/en/mab/networks |website=UNESCO |access-date=21 October 2026}}</ref>
== History ==
The Ibero-American MAB Network was created in 1992 to strengthen cooperation among countries participating in UNESCO's Man and the Biosphere Programme. Its establishment provided a regional framework for sharing experiences and supporting biosphere reserves in Latin America, the Caribbean, Spain and Portugal.<ref name="UNESCO" />
In 2018, the network's Action Plan for 2018–2025 was endorsed at the XVIII IberoMAB Meeting in Santa Marta, Colombia. The plan provided a framework for cooperation and activities among member countries.<ref name="UNESCO" />
In October 2020, IberoMAB published a declaration addressing the COVID-19 pandemic and its effects on communities and the environment. The declaration emphasized sustainable recovery, community participation, scientific research and the role of biosphere reserves in protecting biodiversity and supporting human well-being.<ref name="Declaration">{{cite web |title=IberoMAB Biosphere Reserves pledge for a sustainable 'new normal' to overcome the COVID19 crisis |url=https://www.unesco.org/en/articles/iberomab-biosphere-reserves-pledge-sustainable-new-normal-overcome-covid19-crisis |website=UNESCO |date=15 October 2020 |access-date=21 October 2026}}</ref>
In 2023, the network held its second congress in the Trifinio-Fraternidad Transboundary Biosphere Reserve. Participants reviewed progress on regional cooperation and assessed the implementation of the Lima Action Plan 2016–2025 and the IberoMAB Action Plan 2018–2025.<ref name="UNESCO" />
== Geographic scope and membership ==
''This section has not yet been completed.''
== Objectives and activities ==
IberoMAB aims to strengthen the implementation of UNESCO's Man and the Biosphere Programme across its member countries. Its main objectives include:
* Strengthening national committees responsible for the MAB Programme.
* Improving cooperation and communication between member countries and biosphere reserves.
* Supporting the establishment and development of biosphere reserves.
* Promoting biodiversity conservation and sustainable development.
* Encouraging the exchange of scientific knowledge, experiences and good practices.<ref name="UNESCO" />
The network also supports initiatives addressing environmental and social challenges. In its 2020 declaration on the COVID-19 pandemic, IberoMAB encouraged community participation, the involvement of young people and vulnerable groups, scientific and technical research, and the integration of biodiversity and sustainable development into public policies.<ref name="Declaration" />
More recently, UNESCO has continued to support regional cooperation on biodiversity conservation, sustainable management, community participation and green economies through initiatives involving biosphere reserves in Latin America and the Caribbean and their international partners.<ref name="Project">{{cite web |title=MAB Secretariat support to strategic initiatives for sustainable development |url=https://core.unesco.org/en/project/3210211102 |website=UNESCO Core Data Portal |access-date=21 October 2026}}</ref>
== Organization and cooperation ==
IberoMAB operates within UNESCO's Man and the Biosphere Programme and brings together national MAB committees and representatives from participating countries. Its activities provide opportunities for members to exchange knowledge, discuss common challenges and coordinate regional priorities.<ref name="UNESCO" />
The network has a Bureau that supports its coordination. According to UNESCO's regional networks information, Honduras serves as president, Peru as vice-president and Spain as secretariat.<ref name="UNESCO" />
IberoMAB also cooperates with other regional and thematic networks within the World Network of Biosphere Reserves. This cooperation supports the sharing of expertise and the development of approaches to biodiversity conservation, sustainable development and environmental management.<ref name="Project" />
== See also ==
* [[Man and the Biosphere Programme]]
* [[World Network of Biosphere Reserves]]
* [[Biosphere reserve]]
* [[UNESCO]]
== References ==
{{reflist}}
<ref>{{Cite web |title=Regional and Thematic Networks |url=https://www.unesco.org/en/mab/networks |website=UNESCO |access-date=21 October 2026}}</ref>
<ref>{{Cite web |title=IberoMAB Biosphere Reserves pledge for a sustainable 'new normal' to overcome the COVID19 crisis |url=https://www.unesco.org/en/articles/iberomab-biosphere-reserves-pledge-sustainable-new-normal-overcome-covid19-crisis |website=UNESCO |date=15 October 2020 |access-date=21 October 2026}}</ref>
<ref>{{Cite web |title=MAB Secretariat support to strategic initiatives for sustainable development |url=https://core.unesco.org/en/project/3210211102 |website=UNESCO Core Data Portal |access-date=21 October 2026}}</ref>
== External links ==
* [https://www.unesco.org/en/mab/networks UNESCO – Regional and Thematic Networks]
* [https://www.unesco.org/en/mab/about UNESCO – About the Man and the Biosphere Programme]
* [https://www.unesco.org/en/mab/list UNESCO – List of Biosphere Reserves]
* [https://core.unesco.org/en/project/3210211102 UNESCO Core Data Portal – MAB strategic initiatives for sustainable development]
6qwi0rno5p94n6x7ifwljrmnn54cjhb
Southest Asia Biosphere Reserve Network
0
179379
781458
769687
2026-10-28T11:04:08Z
~2026-57502-85
76455
781458
wikitext
text/x-wiki
{{Short description|Regional network of UNESCO biosphere reserves in Southeast Asia}}
{{Infobox organization
| name = Southeast Asian Biosphere Reserve Network
| abbreviation = SeaBRnet
| formation = October 1998
| type = Regional [[Man and the Biosphere Programme|MAB]] Network
| headquarters = [[Jakarta]], Indonesia (UNESCO Regional Office)
| region_served = [[Southeast Asia]]
| membership = 51 biosphere reserves across 8 countries
| parent_organization = [[UNESCO]] [[Man and the Biosphere Programme]]
| website = {{URL|https://www.unesco.org/en/fieldoffice/jakarta/science-network/about-seabrnet}}
}}
The '''Southeast Asian Biosphere Reserve Network''' ('''SeaBRnet''') is a regional collaborative platform established under [[UNESCO]]'s [[Man and the Biosphere Programme]] (MAB). Founded in October 1998, the network promotes knowledge-sharing, research collaboration, and capacity-building among biosphere reserves across [[Southeast Asia]]. As of 2026, the network comprises 51 biosphere reserves across eight Southeast Asian nations.<ref name="AboutSeaBRnet">{{Cite web |last=UNESCO |date=4 August 2026 |title=Southeast Asian Biosphere Reserve Network (SeaBRnet) |url=https://www.unesco.org/en/fieldoffice/jakarta/science-network/about-seabrnet |website=UNESCO Jakarta Natural Sciences Network |access-date=22 October 2026}}</ref>
== History ==
SeaBRnet was initiated in October 1998 under UNESCO's Man and the Biosphere Programme to address the unique ecological, geographical, and socio-economic characteristics of Southeast Asia—a region recognized for its high biodiversity, extensive tropical rainforest and marine ecosystems, and dense human populations.<ref name="AboutSeaBRnet" />
* '''Subsequent Expansion:''' Over the following decades, cooperation through the network grew to involve 10 participating countries: [[Cambodia]], [[China]], [[Indonesia]], [[Japan]], [[Laos|Lao PDR]], [[Malaysia]], [[Myanmar]], the [[Philippines]], [[Thailand]], and [[Vietnam]], focusing on transboundary conservation and sustainable resource management. Through regular regional meetings, capacity-building workshops, and joint research initiatives, the network continues to strengthen the management effectiveness of biosphere reserves, promoting them as premier learning sites for sustainable development throughout Southeast Asia.<ref name="AboutSeaBRnet" />
* '''Recent Milestones:''' The network has continued its active regional collaboration through periodic meetings and virtual coordination (such as regional preparatory workshops leading up to the global MAB Congresses), reinforcing its role as a key platform for biodiversity protection and local livelihood resilience in Southeast Asia. Since its inception, SeaBRnet has served as a vital collaborative platform for countries across the region to share scientific knowledge, exchange best practices in site management, and tackle common environmental challenges such as habitat fragmentation, deforestation, climate change impacts, and balancing conservation with sustainable local livelihoods.<ref name="AboutSeaBRnet" />
== Objectives and activities ==
SeaBRnet assists biosphere reserves in balancing biodiversity conservation with community-driven, sustainable economic development through local empowerment and regional cooperation.<ref name="AboutSeaBRnet" /> Its primary activities are structured around three core areas:
* '''Annual SeaBRnet Meetings:''' Regional assemblies that bring reserve managers, scientists, and stakeholders together to evaluate progress against global MAB strategies, exchange local case studies, and set strategic conservation priorities.<ref name="AboutSeaBRnet" />
* '''Capacity-Building and Training Series:''' A practical learning program designed to upgrade reserve management capabilities across Southeast Asia. It includes hands-on technical workshops, peer case studies, and on-site field visits or virtual tours to observe reserve operations and community initiatives.<ref>{{Cite web |title=The 16th Southeast Asian Biosphere Reserve Network Meeting in Cambodia: Strengthening Regional Cooperation on Biosphere Reserves |url=https://www.publicnow.com/view/B92006316134009B64F0E93A61F5C92F77C7DB5E |access-date=22 October 2026}}</ref><ref>{{Cite web |title=Palawan BR: Model for Sustainable Development; PCSDS Shares Best Practices during SEABRNet Training in Indonesia |url=https://pcsd.gov.ph/palawan-br-model-for-sustainable-development-pcsds-shares-best-practices-during-seabrnet-training-in-indonesia/ |website=Palawan Council for Sustainable Development |access-date=22 October 2026}}</ref>
* '''Youth of SeaBRnet:''' A regional platform that supports youth participation in conservation and climate resilience through research grants (such as the MAB Young Scientists Awards), water resilience competitions, skill-building workshops, and project showcases.<ref>{{Cite web |date=26 June 2025 |title=Southeast Asian Biosphere Reserve Network (SeaBRnet) |url=https://www.unesco.org/en/fieldoffice/jakarta/science-network/youth-seabrnet |website=UNESCO Jakarta Natural Sciences Network |access-date=22 October 2026}}</ref>
== Organization and cooperation ==
The Secretariat of SeaBRnet is hosted by the UNESCO Regional Science Bureau for Asia and the Pacific, located in [[Jakarta]], Indonesia.<ref name="AboutSeaBRnet" /> The network operates in partnership with National MAB Committees, local governments, academic institutions, and community stakeholders across its member countries.
== References ==
{{Reflist}}
[[Category:Man and the Biosphere Programme]]
[[Category:International environmental organizations]]
[[Category:Organizations established in 1998]]
[[Category:Conservation in Southeast Asia]]
bu0ti1x9gp461pz3tghtr6c03a4c2j0
781459
781458
2026-10-28T11:14:29Z
Aeronomad4
76342
781459
wikitext
text/x-wiki
{{Short description|Regional network of UNESCO biosphere reserves in Southeast Asia}}
{{Infobox organization
| name = Southeast Asian Biosphere Reserve Network
| abbreviation = SeaBRnet
| formation = October 1998
| type = Regional [[Man and the Biosphere Programme|MAB]] Network
| headquarters = [[Jakarta]], Indonesia (UNESCO Regional Office)
| region_served = [[Southeast Asia]]
| membership = 51 biosphere reserves across 8 countries
| parent_organization = [[UNESCO]] [[Man and the Biosphere Programme]]
| website = {{URL|https://www.unesco.org/en/fieldoffice/jakarta/science-network/about-seabrnet}}
}}
The '''Southeast Asian Biosphere Reserve Network''' ('''SeaBRnet''') is a regional collaborative platform established under [[UNESCO]]'s [[Man and the Biosphere Programme]] (MAB). Founded in October 1998, the network promotes knowledge-sharing, research collaboration, and capacity-building among biosphere reserves across [[Southeast Asia]]. As of 2026, the network comprises 51 biosphere reserves across eight Southeast Asian nations.<ref name="AboutSeaBRnet">{{Cite web |last=UNESCO |date=4 August 2026 |title=Southeast Asian Biosphere Reserve Network (SeaBRnet) |url=https://www.unesco.org/en/fieldoffice/jakarta/science-network/about-seabrnet |website=UNESCO Jakarta Natural Sciences Network |access-date=22 October 2026}}</ref>
== History ==
SeaBRnet was initiated in October 1998 under UNESCO's Man and the Biosphere Programme to address the unique ecological, geographical, and socio-economic characteristics of Southeast Asia—a region recognized for its high biodiversity, extensive tropical rainforest and marine ecosystems, and dense human populations.<ref name="AboutSeaBRnet" />
* '''Subsequent Expansion:''' Over the following decades, cooperation through the network grew to involve 10 participating countries: [[Cambodia]], [[China]], [[Indonesia]], [[Japan]], [[Laos|Lao PDR]], [[Malaysia]], [[Myanmar]], the [[Philippines]], [[Thailand]], and [[Vietnam]], focusing on transboundary conservation and sustainable resource management. Through regular regional meetings, capacity-building workshops, and joint research initiatives, the network continues to strengthen the management effectiveness of biosphere reserves, promoting them as premier learning sites for sustainable development throughout Southeast Asia.<ref name="AboutSeaBRnet" />
* '''Recent Milestones:''' The network has continued its active regional collaboration through periodic meetings and virtual coordination (such as regional preparatory workshops leading up to the global MAB Congresses), reinforcing its role as a key platform for biodiversity protection and local livelihood resilience in Southeast Asia. Since its inception, SeaBRnet has served as a vital collaborative platform for countries across the region to share scientific knowledge, exchange best practices in site management, and tackle common environmental challenges such as habitat fragmentation, deforestation, climate change impacts, and balancing conservation with sustainable local livelihoods.<ref name="AboutSeaBRnet" />
== Objectives and activities ==
SeaBRnet assists biosphere reserves in balancing biodiversity conservation with community-driven, sustainable economic development through local empowerment and regional cooperation.<ref name="AboutSeaBRnet" /> Its primary activities are structured around three core areas:
* '''Annual SeaBRnet Meetings:''' Regional assemblies that bring reserve managers, scientists, and stakeholders together to evaluate progress against global MAB strategies, exchange local case studies, and set strategic conservation priorities.<ref name="AboutSeaBRnet" />
* '''Capacity-Building and Training Series:''' A practical learning program designed to upgrade reserve management capabilities across Southeast Asia. It includes hands-on technical workshops, peer case studies, and on-site field visits or virtual tours to observe reserve operations and community initiatives.<ref>{{Cite web |title=The 16th Southeast Asian Biosphere Reserve Network Meeting in Cambodia: Strengthening Regional Cooperation on Biosphere Reserves |url=https://www.publicnow.com/view/B92006316134009B64F0E93A61F5C92F77C7DB5E |access-date=22 October 2026}}</ref><ref>{{Cite web |title=Palawan BR: Model for Sustainable Development; PCSDS Shares Best Practices during SEABRNet Training in Indonesia |url=https://pcsd.gov.ph/palawan-br-model-for-sustainable-development-pcsds-shares-best-practices-during-seabrnet-training-in-indonesia/ |website=Palawan Council for Sustainable Development |access-date=22 October 2026}}</ref>
* '''Youth of SeaBRnet:''' A regional platform that supports youth participation in conservation and climate resilience through research grants (such as the MAB Young Scientists Awards), water resilience competitions, skill-building workshops, and project showcases.<ref name="AboutSeaBRnet">{{Cite web |last=UNESCO |date=4 August 2026 |title=Southeast Asian Biosphere Reserve Network (SeaBRnet) |url=https://www.unesco.org/en/fieldoffice/jakarta/science-network/about-seabrnet |website=UNESCO Jakarta Natural Sciences Network |access-date=22 October 2026}}</ref>
== Organization and cooperation ==
The Secretariat of SeaBRnet is hosted by the UNESCO Regional Science Bureau for Asia and the Pacific, located in [[Jakarta]], Indonesia.<ref name="AboutSeaBRnet" /> The network operates in partnership with National MAB Committees, local governments, academic institutions, and community stakeholders across its member countries.
== References ==
{{Reflist}}
[[Category:Man and the Biosphere Programme]]
[[Category:International environmental organizations]]
[[Category:Organizations established in 1998]]
[[Category:Conservation in Southeast Asia]]
jko51k1tk4qgyuq4wl6v2isphsv6oo7
781460
781459
2026-10-28T11:17:14Z
Aeronomad4
76342
781460
wikitext
text/x-wiki
{{Short description|Regional network of UNESCO biosphere reserves in Southeast Asia}}
{{Infobox organization
| name = Southeast Asian Biosphere Reserve Network
| abbreviation = SeaBRnet
| formation = October 1998
| type = Regional [[Man and the Biosphere Programme|MAB]] Network
| headquarters = [[Jakarta]], Indonesia (UNESCO Regional Office)
| region_served = [[Southeast Asia]]
| membership = 51 biosphere reserves across 8 countries
| parent_organization = [[UNESCO]] [[Man and the Biosphere Programme]]
| website = {{URL|https://www.unesco.org/en/fieldoffice/jakarta/science-network/about-seabrnet}}
}}
The '''Southeast Asian Biosphere Reserve Network''' ('''SeaBRnet''') is a regional collaborative platform established under [[UNESCO]]'s [[Man and the Biosphere Programme]] (MAB). Founded in October 1998, the network promotes knowledge-sharing, research collaboration, and capacity-building among biosphere reserves across [[Southeast Asia]]. As of 2026, the network comprises 51 biosphere reserves across eight Southeast Asian nations.<ref name="AboutSeaBRnet">{{Cite web |last=UNESCO |date=4 August 2026 |title=Southeast Asian Biosphere Reserve Network (SeaBRnet) |url=https://www.unesco.org/en/fieldoffice/jakarta/science-network/about-seabrnet |website=UNESCO Jakarta Natural Sciences Network |access-date=28 October 2026}}</ref>
== History ==
SeaBRnet was initiated in October 1998 under UNESCO's Man and the Biosphere Programme to address the unique ecological, geographical, and socio-economic characteristics of Southeast Asia—a region recognized for its high biodiversity, extensive tropical rainforest and marine ecosystems, and dense human populations.<ref name="AboutSeaBRnet" />
* '''Subsequent Expansion:''' Over the following decades, cooperation through the network grew to involve 10 participating countries: [[Cambodia]], [[China]], [[Indonesia]], [[Japan]], [[Laos|Lao PDR]], [[Malaysia]], [[Myanmar]], the [[Philippines]], [[Thailand]], and [[Vietnam]], focusing on transboundary conservation and sustainable resource management. Through regular regional meetings, capacity-building workshops, and joint research initiatives, the network continues to strengthen the management effectiveness of biosphere reserves, promoting them as premier learning sites for sustainable development throughout Southeast Asia.<ref name="AboutSeaBRnet" />
* '''Recent Milestones:''' The network has continued its active regional collaboration through periodic meetings and virtual coordination (such as regional preparatory workshops leading up to the global MAB Congresses), reinforcing its role as a key platform for biodiversity protection and local livelihood resilience in Southeast Asia. Since its inception, SeaBRnet has served as a vital collaborative platform for countries across the region to share scientific knowledge, exchange best practices in site management, and tackle common environmental challenges such as habitat fragmentation, deforestation, climate change impacts, and balancing conservation with sustainable local livelihoods.<ref name="AboutSeaBRnet" />
== Objectives and activities ==
SeaBRnet assists biosphere reserves in balancing biodiversity conservation with community-driven, sustainable economic development through local empowerment and regional cooperation.<ref name="AboutSeaBRnet" /> Its primary activities are structured around three core areas:
* '''Annual SeaBRnet Meetings:''' Regional assemblies that bring reserve managers, scientists, and stakeholders together to evaluate progress against global MAB strategies, exchange local case studies, and set strategic conservation priorities.<ref name="AboutSeaBRnet" />
* '''Capacity-Building and Training Series:''' A practical learning program designed to upgrade reserve management capabilities across Southeast Asia. It includes hands-on technical workshops, peer case studies, and on-site field visits or virtual tours to observe reserve operations and community initiatives.<ref>{{Cite web |title=The 16th Southeast Asian Biosphere Reserve Network Meeting in Cambodia: Strengthening Regional Cooperation on Biosphere Reserves |url=https://www.publicnow.com/view/B92006316134009B64F0E93A61F5C92F77C7DB5E |access-date=28 October 2026}}</ref><ref>{{Cite web |title=Palawan BR: Model for Sustainable Development; PCSDS Shares Best Practices during SEABRNet Training in Indonesia |url=https://pcsd.gov.ph/palawan-br-model-for-sustainable-development-pcsds-shares-best-practices-during-seabrnet-training-in-indonesia/ |website=Palawan Council for Sustainable Development |access-date=28 October 2026}}</ref>
* '''Youth of SeaBRnet:''' A regional platform that supports youth participation in conservation and climate resilience through research grants (such as the MAB Young Scientists Awards), water resilience competitions, skill-building workshops, and project showcases.<ref name="AboutSeaBRnet">{{Cite web |last=UNESCO |date=4 August 2026 |title=Southeast Asian Biosphere Reserve Network (SeaBRnet) |url=https://www.unesco.org/en/fieldoffice/jakarta/science-network/about-seabrnet |website=UNESCO Jakarta Natural Sciences Network |access-date=28 October 2026}}</ref>
== Organization and cooperation ==
The Secretariat of SeaBRnet is hosted by the UNESCO Regional Science Bureau for Asia and the Pacific, located in [[Jakarta]], Indonesia.<ref name="AboutSeaBRnet" /> The network operates in partnership with National MAB Committees, local governments, academic institutions, and community stakeholders across its member countries.
== References ==
{{Reflist}}
[[Category:Man and the Biosphere Programme]]
[[Category:International environmental organizations]]
[[Category:Organizations established in 1998]]
[[Category:Conservation in Southeast Asia]]
owqr59sikc0jhndlzbqcyojf73q00tb
MediaWiki:Hiruwiki/modules/angle-guess.js
8
179476
781415
770203
2026-10-28T10:38:31Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781415
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/* MediaWiki:Hiruwiki/modules/angle-guess.js */
( function () {
'use strict';
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"nl": {
"_name": "Hoek schatten",
"difference": "Verschil:",
"finalScore": "Eindscore",
"hint": "Verplaats de blauwe stip om de hoek in te stellen",
"nextAngle": "Volgende hoek ›",
"points": "Punten:",
"pts": "ptn",
"round": "Ronde:",
"roundOver": "Spel voorbij",
"target": "Doel:",
"tryAgain": "Opnieuw proberen",
"yourGuess": "Uw schatting:"
},
"fr": {
"_name": "Deviner l’angle",
"difference": "Différence :",
"finalScore": "Score final",
"hint": "Déplacez le point bleu pour régler l'angle",
"nextAngle": "Angle suivant ›",
"points": "Points :",
"pts": "pts",
"round": "Manche :",
"roundOver": "Jeu terminé",
"target": "Cible :",
"tryAgain": "Réessayer",
"yourGuess": "Votre estimation :"
},
"tr": {
"_name": "Açı Tahmini",
"difference": "Fark:",
"finalScore": "Sonuç",
"hint": "Açıyı ayarlamak için mavi noktayı hareket ettirin",
"nextAngle": "Sonraki açı ›",
"points": "Puan:",
"pts": "puan",
"round": "Tur:",
"roundOver": "Oyun bitti",
"target": "Hedef:",
"tryAgain": "Tekrar dene",
"yourGuess": "Tahminin:"
},
"eu": {
"_name": "Angelua asmatu",
"difference": "Aldea:",
"finalScore": "Azken emaitza",
"hint": "Puntu urdina mugitu angelua zehazteko",
"nextAngle": "Hurrengo angelua ›",
"points": "Puntuak:",
"pts": "pt",
"round": "Txanda:",
"roundOver": "Jokoa amaitu da",
"target": "Helburua:",
"tryAgain": "Berriz saiatu",
"yourGuess": "Zure aukera:"
},
"sk": {
"_name": "Hádanie uhla",
"difference": "Rozdiel:",
"finalScore": "Konečné skóre",
"hint": "Posuňte modrý bod na nastavenie uhla",
"nextAngle": "Ďalší uhol ›",
"points": "Body:",
"pts": "b",
"round": "Kolo:",
"roundOver": "Koniec hry",
"target": "Cieľ:",
"tryAgain": "Skúsiť znova",
"yourGuess": "Váš odhad:"
},
"skr-arab": {
"_name": "زاویہ دا اندازہ",
"difference": "فرق:",
"finalScore": "آخری نمبر",
"hint": "زاویہ ٹھہراؤݨ کیتے نیلے نقطے کوں سرکاؤ",
"nextAngle": "اگلا زاویہ ‹",
"points": "نمبر:",
"pts": "نمبر",
"round": "چکر:",
"roundOver": "کھیل مک ڳیا",
"target": "نشانہ:",
"tryAgain": "ولا کوشش کرو",
"yourGuess": "تہاݙا اندازہ:"
},
"lb": {
"points": "Punkten:",
"round": "Ronn:",
"target": "Zil:",
"tryAgain": "Nach eng Kéier probéieren"
},
"ko": {
"_name": "각도 추측",
"difference": "차이:",
"finalScore": "최종 점수",
"hint": "파란색 점을 움직여 각도를 설정하세요",
"nextAngle": "다음 각 ›",
"points": "점수:",
"pts": "점",
"round": "라운드:",
"roundOver": "게임 종료",
"target": "목표:",
"tryAgain": "다시 시도",
"yourGuess": "당신의 예상:"
},
"en": {
"_name": "Angle Guess",
"difference": "Difference:",
"finalScore": "Final score",
"hint": "Move the blue dot to set the angle",
"nextAngle": "Next angle ›",
"points": "Points:",
"pts": "pts",
"round": "Round:",
"roundOver": "Game over",
"target": "Target:",
"tryAgain": "Try again",
"yourGuess": "Your guess:"
},
"it": {
"_name": "Indovina l'angolo",
"difference": "Differenza:",
"finalScore": "Punteggio finale",
"hint": "Sposta il punto blu per impostare l'angolo",
"nextAngle": "Prossimo angolo ›",
"points": "Punti:",
"round": "Turno:",
"roundOver": "Gioco terminato",
"target": "Obiettivo:",
"tryAgain": "Riprova",
"yourGuess": "La tua ipotesi:"
},
"ca": {
"_name": "Endevina l'angle",
"difference": "Diferència",
"finalScore": "Puntuació final",
"hint": "Mou el punt blau per definir l'angle",
"nextAngle": "Angle següent ›",
"points": "Punts:",
"pts": "pts",
"round": "Ronda:",
"roundOver": "Final de la partida",
"target": "Objectiu:",
"tryAgain": "Torneu-ho a provar",
"yourGuess": "La vostra estimació"
},
"qqq": {
"_name": "Name of the Angle Guess module",
"difference": "Label for the difference between the target angle and the player's guess. Followed by a value in degrees.",
"finalScore": "Heading shown when all rounds are complete, above the total score",
"hint": "Instruction telling the user how to set their angle guess",
"nextAngle": "Button label to proceed to the next angle. Uses › as a right-pointing arrow.",
"points": "Label for the points earned. Followed by a numeric value.",
"pts": "Abbreviation for \"points displayed after a numeric score",
"round": "Label for the current round number. Followed by \"X / Y\".",
"roundOver": "Heading shown in the overlay when all rounds are finished",
"target": "Label for the target angle the player must guess. Followed by a value in degrees.",
"tryAgain": "Button label to start a new game after all rounds are complete",
"yourGuess": "Label for the angle the player guessed. Followed by a value in degrees."
},
"ga": {
"_name": "Buille faoi thuairim uillinn",
"difference": "Difríocht:",
"finalScore": "Scór deiridh",
"hint": "Bog an ponc gorm chun an uillinn a shocrú",
"nextAngle": "An chéad uillinn eile ’",
"points": "Pointí:",
"pts": "pointí",
"round": "Babhta:",
"roundOver": "Cluiche thart",
"target": "Sprioc:",
"tryAgain": "Déan iarracht arís",
"yourGuess": "Do bhuille faoi thuairim:"
},
"es": {
"_name": "Adivinar ángulo",
"difference": "Diferencia:",
"finalScore": "Puntuación final",
"hint": "Mueve el punto azul para establecer el ángulo",
"nextAngle": "Siguiente ángulo ›",
"points": "Puntos:",
"pts": "pts",
"round": "Ronda:",
"roundOver": "Juego terminado",
"target": "Objetivo:",
"tryAgain": "Intentar de nuevo",
"yourGuess": "Tu elección:"
},
"mk": {
"_name": "Погодување на агол",
"difference": "Разлика:",
"finalScore": "Конечен исход",
"hint": "Поместете ја сината точка за да го зададете аголот",
"nextAngle": "Следен агол ›",
"points": "Бодови:",
"pts": "бодови",
"round": "Круг:",
"roundOver": "Играта заврши",
"target": "Цел:",
"tryAgain": "Обиди се пак",
"yourGuess": "Вашиот одговор:"
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
// ── Find mount point ──────────────────────────────────────────
var root = document.querySelector( '.hiruwiki[data-module="angle-guess"]' );
if ( !root ) return;
// ── Constants ─────────────────────────────────────────────────
var MAX_ROUNDS = 5;
var RADIUS = 155;
var BAND_WIDTH = 26;
var HANDLE_R = 9;
var ARROW_EXTRA = 18;
var ARROW_SIZE = 8;
var DRAG_HIT_R2 = 22 * 22;
var CENTER = { x: 200, y: 210 };
// ── Inject HTML ───────────────────────────────────────────────
root.innerHTML =
'<div class="angle-guess-card">' +
'<canvas class="angle-guess-canvas" width="440" height="260"></canvas>' +
'<div class="angle-guess-infobar">' +
'<span>' + t('target') + ' <span class="ag-val" id="ag-iTarget">?°</span></span>' +
'<span>' + t('yourGuess') + ' <span class="ag-val" id="ag-iGuess">—</span></span>' +
'<span>' + t('difference') + ' <span class="ag-val" id="ag-iDiff">—</span></span>' +
'<span>' + t('points') + ' <span class="ag-val" id="ag-iPoints">—</span></span>' +
'</div>' +
'<button class="angle-guess-btn" id="ag-nextBtn" disabled>' + t('nextAngle') + '</button>' +
'</div>' +
'<div class="angle-guess-overlay" id="ag-overlay">' +
'<div class="angle-guess-popup">' +
'<h2>' + t('roundOver') + '</h2>' +
'<p>' + t('finalScore') + '</p>' +
'<span class="ag-big" id="ag-finalScore">0</span>' +
'<button class="angle-guess-btn" id="ag-newRoundBtn">' + t('tryAgain') + '</button>' +
'</div>' +
'</div>';
// ── DOM refs (scoped to root) ─────────────────────────────────
var canvas = root.querySelector( '.angle-guess-canvas' );
var ctx = canvas.getContext( '2d' );
var overlay = root.querySelector( '#ag-overlay' );
var nextBtn = root.querySelector( '#ag-nextBtn' );
var newRoundBtn = root.querySelector( '#ag-newRoundBtn' );
var iTarget = root.querySelector( '#ag-iTarget' );
var iGuess = root.querySelector( '#ag-iGuess' );
var iDiff = root.querySelector( '#ag-iDiff' );
var iPoints = root.querySelector( '#ag-iPoints' );
var finalScore = root.querySelector( '#ag-finalScore' );
// ── State ─────────────────────────────────────────────────────
var dragging = false;
var submitted = false;
var revealing = false;
var revealAngle = 0;
var targetRayAng = 0;
var targetAngle = 0;
var handleAngle = 0;
var round = 0;
var totalScore = 0;
// ── Helpers ───────────────────────────────────────────────────
function degToRad( d ) { return d * Math.PI / 180; }
function posToAngle( mx, my ) {
var dx = mx - CENTER.x;
var dy = -( my - CENTER.y );
var a = Math.atan2( dy, dx ) * 180 / Math.PI;
return Math.max( 0, Math.min( 180, a ) );
}
function angleToPos( deg ) {
var r = degToRad( deg );
return { x: CENTER.x + Math.cos( r ) * RADIUS,
y: CENTER.y - Math.sin( r ) * RADIUS };
}
function calcPoints( diff ) { return diff > 10 ? 0 : Math.max( 0, 10 - diff ); }
// ── Drawing ───────────────────────────────────────────────────
function drawGrid() {
ctx.save();
ctx.strokeStyle = hiruwiki.getThemeColor('border-color-base', 'rgba(0,0,0,0.06)');
ctx.lineWidth = 1;
for ( var x = 20; x < canvas.width; x += 20 ) {
ctx.beginPath(); ctx.moveTo( x, 0 ); ctx.lineTo( x, canvas.height ); ctx.stroke();
}
for ( var y = 20; y < canvas.height; y += 20 ) {
ctx.beginPath(); ctx.moveTo( 0, y ); ctx.lineTo( canvas.width, y ); ctx.stroke();
}
ctx.restore();
}
function drawProtractorBase() {
var outerR = RADIUS, innerR = RADIUS - BAND_WIDTH;
ctx.beginPath();
ctx.arc( CENTER.x, CENTER.y, outerR, Math.PI, 2 * Math.PI );
ctx.arc( CENTER.x, CENTER.y, innerR, 2 * Math.PI, Math.PI, true );
ctx.closePath();
ctx.fillStyle = hiruwiki.getThemeColor('background-color-neutral-subtle', '#d8dde3'); ctx.fill();
ctx.strokeStyle = hiruwiki.getThemeColor('border-color-base', '#b0bbc5'); ctx.lineWidth = 1; ctx.stroke();
}
function drawColoredProtractor( maxDeg ) {
var outerR = RADIUS, innerR = RADIUS - BAND_WIDTH;
for ( var d = 0; d < maxDeg; d++ ) {
var diff = Math.abs( d - targetAngle );
var color = diff <= 3 ? hiruwiki.getThemeColor('color-success', '#50dc78') : diff <= 8 ? hiruwiki.getThemeColor('color-warning', '#e6b432') : hiruwiki.getThemeColor('color-destructive', '#dc5050');
ctx.beginPath();
ctx.arc( CENTER.x, CENTER.y, outerR, -degToRad( d ), -degToRad( d + 1 ), true );
ctx.arc( CENTER.x, CENTER.y, innerR, -degToRad( d + 1 ), -degToRad( d ), false );
ctx.closePath();
ctx.fillStyle = color; ctx.fill();
}
}
function drawBaselineArrow() {
ctx.save();
var ex = CENTER.x + RADIUS + ARROW_EXTRA;
ctx.beginPath(); ctx.moveTo( CENTER.x - 10, CENTER.y ); ctx.lineTo( ex, CENTER.y );
ctx.strokeStyle = hiruwiki.getThemeColor('color-subtle', '#444'); ctx.lineWidth = 2; ctx.stroke();
ctx.beginPath(); ctx.moveTo( ex, CENTER.y );
ctx.lineTo( ex - ARROW_SIZE, CENTER.y - ARROW_SIZE / 2 );
ctx.lineTo( ex - ARROW_SIZE, CENTER.y + ARROW_SIZE / 2 );
ctx.closePath(); ctx.fillStyle = hiruwiki.getThemeColor('color-subtle', '#444'); ctx.fill();
ctx.restore();
}
function drawBlueRay() {
var hp = angleToPos( handleAngle );
var rad = degToRad( handleAngle );
ctx.save();
ctx.beginPath(); ctx.moveTo( CENTER.x, CENTER.y );
ctx.arc( CENTER.x, CENTER.y, 44, 0, -rad, true ); ctx.closePath();
ctx.fillStyle = 'rgba(40,100,180,0.12)'; ctx.fill();
ctx.beginPath(); ctx.moveTo( CENTER.x, CENTER.y ); ctx.lineTo( hp.x, hp.y );
ctx.strokeStyle = hiruwiki.getThemeColor('color-progressive', '#2866c0'); ctx.lineWidth = 2.5; ctx.stroke();
ctx.beginPath(); ctx.arc( hp.x, hp.y, HANDLE_R, 0, 2 * Math.PI );
ctx.fillStyle = hiruwiki.getThemeColor('color-progressive', '#2866c0'); ctx.fill();
ctx.strokeStyle = hiruwiki.getThemeColor('background-color-base', '#fff'); ctx.lineWidth = 2; ctx.stroke();
ctx.restore();
}
function drawTargetRay() {
var rad = degToRad( targetRayAng );
ctx.save();
ctx.beginPath(); ctx.moveTo( CENTER.x, CENTER.y );
ctx.lineTo( CENTER.x + Math.cos( rad ) * RADIUS, CENTER.y - Math.sin( rad ) * RADIUS );
ctx.strokeStyle = hiruwiki.getThemeColor('color-success', '#50dc78'); ctx.lineWidth = 2.5;
ctx.setLineDash( [ 6, 4 ] ); ctx.stroke(); ctx.setLineDash( [] );
ctx.restore();
}
function drawHUD() {
ctx.save();
ctx.fillStyle = hiruwiki.getThemeColor('color-progressive', '#3a6a80'); ctx.font = '13px Montserrat, sans-serif';
ctx.textAlign = 'left'; ctx.fillText( t('round') + ' ' + round + ' / ' + MAX_ROUNDS, 12, 20 );
ctx.textAlign = 'right'; ctx.fillText( t('points') + ' ' + totalScore, canvas.width - 12, 20 );
ctx.restore();
}
function draw() {
ctx.clearRect( 0, 0, canvas.width, canvas.height );
ctx.fillStyle = hiruwiki.getThemeColor('background-color-neutral-subtle', '#f9f9f7'); ctx.fillRect( 0, 0, canvas.width, canvas.height );
drawGrid();
if ( revealing ) drawColoredProtractor( revealAngle ); else drawProtractorBase();
drawBaselineArrow();
drawBlueRay();
if ( revealing || submitted ) drawTargetRay();
drawHUD();
}
// ── Reveal animation ──────────────────────────────────────────
function animateReveal() {
revealing = true;
revealAngle = 0;
targetRayAng = 0;
function step() {
revealAngle = Math.min( 180, revealAngle + 2 );
targetRayAng = Math.min( targetAngle, revealAngle );
draw();
if ( revealAngle < 180 ) requestAnimationFrame( step );
}
requestAnimationFrame( step );
}
// ── Submit guess ──────────────────────────────────────────────
function submitGuess() {
if ( submitted || revealing ) return;
submitted = true;
dragging = false;
nextBtn.disabled = false;
var playerGuess = Math.round( handleAngle );
var diff = Math.abs( targetAngle - playerGuess );
var points = calcPoints( diff );
totalScore += points;
iGuess .textContent = playerGuess + '°';
iDiff .textContent = diff + '°';
iPoints.textContent = points + ' ' + t('pts');
canvas.classList.remove( 'ag-glow-good', 'ag-glow-ok', 'ag-glow-bad' );
if ( diff <= 3 ) canvas.classList.add( 'ag-glow-good' );
else if ( diff <= 8 ) canvas.classList.add( 'ag-glow-ok' );
else canvas.classList.add( 'ag-glow-bad' );
animateReveal();
}
// ── Game flow ─────────────────────────────────────────────────
function newQuestion() {
targetAngle = Math.floor( Math.random() * 179 ) + 1;
handleAngle = 0;
submitted = false;
revealing = false;
revealAngle = 0;
targetRayAng = 0;
canvas.classList.remove( 'ag-glow-good', 'ag-glow-ok', 'ag-glow-bad' );
nextBtn.disabled = true;
iTarget.textContent = targetAngle + '°';
iGuess .textContent = '—';
iDiff .textContent = '—';
iPoints.textContent = '—';
draw();
}
function nextRound() {
if ( round >= MAX_ROUNDS ) {
finalScore.textContent = totalScore;
overlay.classList.add( 'ag-show' );
return;
}
round++;
newQuestion();
}
function startGame() {
round = 0;
totalScore = 0;
overlay.classList.remove( 'ag-show' );
nextRound();
}
// ── Input helpers ─────────────────────────────────────────────
function getCanvasPos( e ) {
var r = canvas.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
// ── Mouse ─────────────────────────────────────────────────────
canvas.addEventListener( 'mousedown', function ( e ) {
if ( submitted || revealing ) return;
var pos = getCanvasPos( e );
var hp = angleToPos( handleAngle );
var dx = pos.x - hp.x, dy = pos.y - hp.y;
if ( dx * dx + dy * dy < DRAG_HIT_R2 ) dragging = true;
} );
canvas.addEventListener( 'mousemove', function ( e ) {
if ( !dragging || submitted || revealing ) return;
var pos = getCanvasPos( e );
handleAngle = posToAngle( pos.x, pos.y );
draw();
} );
canvas.addEventListener( 'mouseleave', function () {
if ( dragging ) { dragging = false; if ( handleAngle > 0 ) submitGuess(); }
} );
canvas.addEventListener( 'mouseup', function () {
if ( !dragging ) return;
dragging = false;
submitGuess();
} );
// ── Touch ─────────────────────────────────────────────────────
canvas.addEventListener( 'touchstart', function ( e ) {
e.preventDefault();
if ( submitted || revealing ) return;
var pos = getCanvasPos( { clientX: e.touches[ 0 ].clientX, clientY: e.touches[ 0 ].clientY } );
handleAngle = posToAngle( pos.x, pos.y );
dragging = true;
draw();
}, { passive: false } );
canvas.addEventListener( 'touchmove', function ( e ) {
e.preventDefault();
if ( !dragging || submitted || revealing ) return;
var pos = getCanvasPos( { clientX: e.touches[ 0 ].clientX, clientY: e.touches[ 0 ].clientY } );
handleAngle = posToAngle( pos.x, pos.y );
draw();
}, { passive: false } );
canvas.addEventListener( 'touchend', function () {
if ( !dragging ) return;
dragging = false;
submitGuess();
} );
// ── Buttons ───────────────────────────────────────────────────
nextBtn .addEventListener( 'click', nextRound );
newRoundBtn.addEventListener( 'click', startGame );
// ── Live theme updates (MediaWiki / OS preference) ───────────
var themeObserver = new MutationObserver( draw );
themeObserver.observe( document.documentElement, { attributes: true, attributeFilter: [ 'class' ] } );
if ( document.body ) {
themeObserver.observe( document.body, { attributes: true, attributeFilter: [ 'class' ] } );
}
if ( window.matchMedia ) {
window.matchMedia( '(prefers-color-scheme: dark)' ).addEventListener( 'change', draw );
}
// ── Boot ──────────────────────────────────────────────────────
startGame();
// Footer branding
var footer = document.createElement("div");
footer.className = "hw-footer";
var fLogo = document.createElement("a");
fLogo.className = "hw-footer-icon";
fLogo.href = mw.util.getUrl('Wikipedia:Hiruwiki');
fLogo.title = 'Hiruwiki';
fLogo.innerHTML = hiruwiki.getLogoSvg(22);
var fText = document.createElement("span");
fText.innerHTML = t('hint');
footer.appendChild(fLogo);
footer.appendChild(fText);
root.appendChild(footer);
}() );
7oodxlfxkphgmgsrj1vg6mc83i17c66
MediaWiki:Hiruwiki/modules/angles.js
8
179478
781416
770205
2026-10-28T10:38:34Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781416
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
(function () {
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"nl": {
"_name": "Hoeken",
"bisector": "Bissectrice",
"bisectorResult": "Bissectrice: $1°",
"complement": "Complementair",
"explement": "Explementair",
"notDefined": "Niet gedefinieerd voor deze hoek",
"resultAngle": "Hoek $1: $2°",
"supplement": "Supplementair",
"hint": "Sleep de witte stip om de hoek te wijzigen"
},
"fr": {
"_name": "Angles",
"bisector": "Bissectrice",
"bisectorResult": "Bissectrice : $1°",
"complement": "Complémentaire",
"explement": "Explémentaire",
"notDefined": "Non défini pour cet angle",
"resultAngle": "Angle $1 : $2°",
"supplement": "Supplémentaire",
"hint": "Tirez la poignée pour changer l'angle"
},
"tr": {
"_name": "Açılar",
"bisector": "Açıortay",
"bisectorResult": "Açıortay: $1°",
"complement": "Tümler",
"explement": "Eşlenik",
"notDefined": "Bu açı için tanımlanmamış",
"resultAngle": "$1 açı: $2°",
"supplement": "Bütünler",
"hint": "Açıyı değiştirmek için tutup sürükleyin"
},
"eu": {
"_name": "Angeluak",
"bisector": "Erdikaria",
"bisectorResult": "Erdikaria: $1°",
"complement": "Osagarria",
"explement": "Konjubatua",
"notDefined": "Angelu honentzat definitu gabe",
"resultAngle": "Angelu $1: $2°",
"supplement": "Betegarria",
"hint": "Mugitu heldulekua angelua aldatzeko"
},
"sk": {
"_name": "Uhly",
"bisector": "Os uhla",
"bisectorResult": "Os uhla: $1°",
"complement": "Doplnkový",
"explement": "Explementárny",
"notDefined": "Nie je definovaný pre tento uhol",
"resultAngle": "$1 uhol: $2°",
"supplement": "Vedľajší",
"hint": "Potiahnutím rukoväte zmeníte uhol"
},
"skr-arab": {
"_name": "زاویے",
"bisector": "ونڈݨ آلا",
"bisectorResult": "ونڈݨ آلا: $1°",
"complement": "تکمیلی",
"explement": "معکوس",
"notDefined": "ایں زاویے کیتے مقرر نئیں",
"resultAngle": "$1 زاویہ: $2°",
"supplement": "متمم",
"hint": "زاویہ بدلݨ کیتے ہینڈل کوں گھسیٹو"
},
"lb": {
"resultAngle": "$1 Wénkel: $2°"
},
"ko": {
"_name": "각도",
"bisector": "이등분",
"bisectorResult": "이등분: $1°",
"complement": "여각",
"explement": "공액각",
"notDefined": "이 각도에 대해서는 정의되지 않았습니다",
"resultAngle": "$1: $2°",
"supplement": "보각",
"hint": "손잡이를 드래그하여 각도를 변경하세요"
},
"en": {
"_name": "Angles",
"bisector": "Bisector",
"bisectorResult": "Bisector: $1°",
"complement": "Complementary",
"explement": "Explementary",
"notDefined": "Not defined for this angle",
"resultAngle": "$1 angle: $2°",
"supplement": "Supplementary",
"hint": "Drag the handle to change the angle"
},
"it": {
"_name": "Angoli",
"bisector": "Bisettrice",
"bisectorResult": "Bisettrice: $1°",
"complement": "Complementare",
"explement": "Esplementare",
"notDefined": "Non definito per questo angolo",
"resultAngle": "Angolo $1: $2°",
"supplement": "Supplementare"
},
"ca": {
"_name": "Angles",
"bisector": "Bisectriu",
"bisectorResult": "Bisectriu: $1°",
"complement": "Complementari",
"explement": "Explementari",
"notDefined": "No definit per a aquest angle",
"supplement": "Suplementari"
},
"qqq": {
"_name": "Name of the Angles module",
"bisector": "Button label for the bisector mode",
"bisectorResult": "Result text showing the bisector angle. Parameters: $1 = bisector angle in degrees.",
"complement": "Button label for the complementary angle mode",
"explement": "Button label for the explementary angle mode",
"notDefined": "Message shown when the selected angle operation is not defined for the current angle (e.g. complement of an angle ≥ 90°)",
"resultAngle": "Result text showing a computed angle. Parameters: $1 = angle type name (e.g. \"Complementary\"), $2 = angle in degrees.",
"supplement": "Button label for the supplementary angle mode",
"hint": "Instruction text explaining how to interact with the angle applet"
},
"ga": {
"_name": "Uillinneacha",
"bisector": "Déroinnteoir",
"bisectorResult": "Déroinnteoir: $1°",
"complement": "Comhlántach",
"explement": "Turgnamhach",
"notDefined": "Gan sainmhíniú don uillinn seo",
"resultAngle": "Uillinn $1: $2°",
"supplement": "Forlíontach",
"hint": "Tarraing an láimhseáil chun an uillinn a athrú"
},
"es": {
"_name": "Ángulos",
"bisector": "Bisectriz",
"bisectorResult": "Bisectriz: $1°",
"complement": "Complementario",
"explement": "Explementario",
"notDefined": "No definido para este ángulo",
"resultAngle": "Ángulo $1: $2°",
"supplement": "Suplementario",
"hint": "Arrastra para cambiar el ángulo."
},
"mk": {
"_name": "Агли",
"bisector": "Симетрала",
"bisectorResult": "Симетрала: $1°",
"complement": "Комплементен",
"explement": "Експлементен",
"notDefined": "Не е дефинирано за овој агол",
"resultAngle": "$1 агол: $2°",
"supplement": "Суплементен",
"hint": "Повлечете ја рачката за да го измените аголот"
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
/* -------------------------
APPLET INITIALIZATION
------------------------- */
function initAngleApplet(container){
container.classList.add("hiruwiki-angles");
const canvas=document.createElement("canvas");
canvas.width=460;
canvas.height=460;
const panel=document.createElement("div");
panel.className="hiruwiki-angles-panel";
const info=document.createElement("div");
info.className="hiruwiki-angles-info";
const modes=[
["bisector",t("bisector")],
["complement",t("complement")],
["supplement",t("supplement")],
["explement",t("explement")]
];
modes.forEach(function(m){
const btn=document.createElement("button");
btn.textContent=m[1];
btn.dataset.mode=m[0];
panel.appendChild(btn);
});
panel.appendChild(info);
container.appendChild(canvas);
container.appendChild(panel);
const ctx=canvas.getContext("2d");
const cx=230;
const cy=230;
const R=170;
let angle=40;
let dragging=false;
let mode=null;
function rad(d){ return d*Math.PI/180; }
function point(deg,r){
let a=rad(deg);
return{
x:cx+r*Math.cos(a),
y:cy-r*Math.sin(a)
};
}
function drawGrid(){
// Fine grid lines — use border-color-subtle (very faint, Codex #c8ccd1 light / #2e3136 dark)
ctx.strokeStyle = hiruwiki.getThemeColor('border-color-subtle', '#c8ccd1');
ctx.lineWidth = 0.5;
for(let x=0;x<canvas.width;x+=25){
ctx.beginPath();
ctx.moveTo(x,0);
ctx.lineTo(x,canvas.height);
ctx.stroke();
}
for(let y=0;y<canvas.height;y+=25){
ctx.beginPath();
ctx.moveTo(0,y);
ctx.lineTo(canvas.width,y);
ctx.stroke();
}
// Concentric reference circles — slightly more visible than grid lines,
// use border-color-base (Codex #a2a9b1 light / #3d424d dark)
ctx.strokeStyle = hiruwiki.getThemeColor('border-color-base', '#a2a9b1');
ctx.lineWidth = 0.75;
for(let r=50;r<=R;r+=50){
ctx.beginPath();
ctx.arc(cx,cy,r,0,Math.PI*2);
ctx.stroke();
}
}
function drawRay(deg,color,width){
let p=point(deg,R);
ctx.strokeStyle=color;
ctx.lineWidth=width;
ctx.beginPath();
ctx.moveTo(cx,cy);
ctx.lineTo(p.x,p.y);
ctx.stroke();
}
function drawSector(a,b,color){
ctx.fillStyle=color;
ctx.beginPath();
ctx.moveTo(cx,cy);
ctx.arc(cx,cy,70,-rad(a),-rad(b),true);
ctx.closePath();
ctx.fill();
}
function drawLabel(x,y,text,color){
// Label background: background-color-base so it contrasts with sector fills
ctx.fillStyle = hiruwiki.getThemeColor('background-color-base', '#ffffff');
ctx.beginPath();
ctx.arc(x,y,16,0,Math.PI*2);
ctx.fill();
ctx.fillStyle=color;
ctx.font="bold 14px sans-serif";
ctx.textAlign="center";
ctx.textBaseline="middle";
ctx.fillText(text,x,y);
}
function computeTarget(){
let target=null;
let valid=true;
if(mode==="bisector") target=angle/2;
if(mode==="complement"){
if(angle>=90) valid=false;
target=90;
}
if(mode==="supplement"){
if(angle>=180) valid=false;
target=180;
}
if(mode==="explement") target=360;
return {target,valid};
}
function draw(){
// Canvas background — background-color-base: #fff light / #101418 dark
ctx.fillStyle = hiruwiki.getThemeColor('background-color-base', '#ffffff');
ctx.fillRect(0, 0, canvas.width, canvas.height);
drawGrid();
// Main rays — color-base: #202122 light / ~#f8f9fa dark
var baseColor = hiruwiki.getThemeColor('color-base', '#202122');
drawRay(0, baseColor, 3);
drawRay(angle, baseColor, 3);
// Primary sector (the angle being shown) — color-progressive at low opacity
// color-progressive: #36c light / #6e85cf dark
// We paint this manually with rgba so opacity works on the canvas.
// getThemeColor returns a hex string; we use it as the sector fill via
// a helper that converts it to rgba.
var progressiveHex = hiruwiki.getThemeColor('color-progressive', '#3366cc');
drawSector(0, angle, hexToRgba(progressiveHex, 0.20));
// Drag handle — color-progressive (solid, for good contrast on both themes)
ctx.fillStyle = progressiveHex;
const handle=point(angle,R);
ctx.beginPath();
ctx.arc(handle.x,handle.y,6,0,Math.PI*2);
ctx.fill();
// Angle label for the current angle — use color-progressive to match the handle
const blueLabel=point(angle,R-35);
drawLabel(blueLabel.x,blueLabel.y,angle+"°", progressiveHex);
if(!mode) return;
const res=computeTarget();
if(!res.valid){
info.innerHTML='<span class="invalid">'+t("notDefined")+'</span>';
return;
}
// Secondary ray and label — color-destructive: #d73333 light / #ff6e6e dark
var destructiveColor = hiruwiki.getThemeColor('color-destructive', '#d73333');
if(mode==="bisector"){
drawRay(res.target, destructiveColor, 2);
const end=point(res.target,R+25);
drawLabel(end.x,end.y,res.target.toFixed(1)+"°", destructiveColor);
info.textContent=t("bisectorResult",[res.target.toFixed(1)]);
return;
}
drawRay(res.target, destructiveColor, 2);
// Secondary sector — color-destructive at low opacity
drawSector(angle, res.target, hexToRgba(destructiveColor, 0.20));
const value=Math.abs(res.target-angle);
const mid=(angle+res.target)/2;
const label=point(mid,55);
drawLabel(label.x,label.y,value+"°", destructiveColor);
info.textContent=t("resultAngle",[t(mode),value]);
}
draw();
canvas.addEventListener("mousedown",function(e){
const rect=canvas.getBoundingClientRect();
const x=e.clientX-rect.left;
const y=e.clientY-rect.top;
const p=point(angle,R);
if(Math.hypot(x-p.x,y-p.y)<10){
dragging=true;
}
});
canvas.addEventListener("mouseup",function(){
dragging=false;
});
canvas.addEventListener("mousemove",function(e){
if(!dragging) return;
const rect=canvas.getBoundingClientRect();
const x=e.clientX-rect.left;
const y=e.clientY-rect.top;
const dx=x-cx;
const dy=cy-y;
let a=Math.atan2(dy,dx)*180/Math.PI;
if(a<0) a+=360;
angle=Math.round(a);
draw();
});
panel.querySelectorAll("button").forEach(function(btn){
btn.addEventListener("click",function(){
mode=btn.dataset.mode;
draw();
});
});
// Footer branding
var footer = document.createElement("div");
footer.className = "hw-footer";
var fLogo = document.createElement("a");
fLogo.className = "hw-footer-icon";
fLogo.href = mw.util.getUrl('Wikipedia:Hiruwiki');
fLogo.title = 'Hiruwiki';
fLogo.innerHTML = hiruwiki.getLogoSvg(22);
var fText = document.createElement("span");
fText.innerHTML = t('hint');
footer.appendChild(fLogo);
footer.appendChild(fText);
container.appendChild(footer);
}
/* -------------------------
HELPERS
------------------------- */
/**
* Convert a hex color string (#rgb or #rrggbb) returned by getThemeColor
* into an rgba() string with the given opacity (0–1).
* Falls back to the original string if parsing fails.
*/
function hexToRgba(hex, alpha) {
if (!hex || hex.charAt(0) !== '#') { return hex; }
var h = hex.slice(1);
if (h.length === 3) {
h = h[0]+h[0] + h[1]+h[1] + h[2]+h[2];
}
var r = parseInt(h.slice(0,2), 16);
var g = parseInt(h.slice(2,4), 16);
var b = parseInt(h.slice(4,6), 16);
if (isNaN(r) || isNaN(g) || isNaN(b)) { return hex; }
return 'rgba('+r+','+g+','+b+','+alpha+')';
}
/* -------------------------
FIND APPLETS
------------------------- */
function init(){
document.querySelectorAll('.hiruwiki[data-module="angles"]').forEach(function(el){
if(el.dataset.hiruwikiLoaded) return;
el.dataset.hiruwikiLoaded="1";
initAngleApplet(el);
});
}
if(document.readyState==="loading"){
document.addEventListener("DOMContentLoaded",init);
}else{
init();
}
})();
71jhuxiub514lhrmhy5cozdt3gzrgc3
MediaWiki:Hiruwiki/modules/circle-proof.js
8
179480
781417
770207
2026-10-28T10:38:38Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781417
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
( function () {
'use strict';
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"nl": {
"_name": "Cirkelbewijs",
"animStopped": "Animatie gestopt.",
"area": "Oppervlakte",
"areaAnimStatus": "De sectoren vormen een rechthoek: breedte = πr, hoogte = r.",
"areaReviewStatus": "✓ Breedte = πr, hoogte = r → A = πr².",
"circAnimStatus": "Elke halve cirkel (πr) rolt uit tot een lijn, wat in totaal 2πr maakt.",
"circReviewStatus": "✓ Bovenste πr + Onderste πr = C = 2πr.",
"circumference": "Omtrek",
"initStatus": "Kies een bewijs en klik op 'Bewijs het'.",
"proveIt": "Bewijs het ▶",
"radius": "Straal",
"readyStatus": "Klik op 'Bewijs het' om de animatie te zien.",
"review": "Beoordelen ▶",
"stop": "Stoppen ■",
"tabArea": "Oppervlakte",
"tabCirc": "Omtrek",
"title": "Cirkelberekeningsmachine",
"visualProof": "Visueel bewijs",
"hint": "Sleep de straalschuifregelaar om de berekening te zien"
},
"fr": {
"_name": "Preuve du cercle",
"animStopped": "Animation arrêtée.",
"area": "Aire",
"areaAnimStatus": "Les secteurs se réassemblent en un rectangle : largeur = πr, hauteur = r.",
"areaReviewStatus": "✓ Largeur = πr, hauteur = r → A = πr².",
"circAnimStatus": "Chaque demi-cercle (πr) se déroule en une ligne, totalisant 2πr.",
"circReviewStatus": "✓ Haut πr + Bas πr = C = 2πr.",
"circumference": "Circonférence",
"initStatus": "Choisissez une preuve et cliquez sur 'Prouver'.",
"proveIt": "Prouver ▶",
"radius": "Rayon",
"readyStatus": "Cliquez sur 'Prouver' pour voir l'animation.",
"review": "Réviser ▶",
"stop": "Arrêter ■",
"tabArea": "Aire",
"tabCirc": "Circonférence",
"title": "Calculateur de Cercle",
"visualProof": "Preuve visuelle",
"hint": "Tirez le curseur du rayon pour voir le calcul"
},
"tr": {
"_name": "Daire Kanıtı",
"animStopped": "Animasyon durduruldu.",
"area": "Alan",
"areaAnimStatus": "Kenarlar bir dikdörtgen oluşturacak şekilde bir araya gelir: genişlik = πr, yükseklik = r.",
"areaReviewStatus": "✓ Genişlik = πr, yükseklik = r → A = πr².",
"circAnimStatus": "Her yarım daire (πr) bir düz çizgiye dönüşür ve toplamda 2πr uzunluğunda olur.",
"circReviewStatus": "✓ Üst πr + Alt πr = C = 2πr.",
"circumference": "Çevre",
"initStatus": "Bir ispat seçin ve 'İspatla' düğmesine tıklayın.",
"proveIt": "İspatla ▶",
"radius": "Yarıçap",
"readyStatus": "Animasyonu izlemek için 'İspatla' düğmesine tıklayın.",
"review": "İncele ▶",
"stop": "Dur ■",
"tabArea": "Alan",
"tabCirc": "Çevre",
"title": "Daire Hesaplayıcı",
"visualProof": "Görsel ispat",
"hint": "Hesaplamayı görmek için yarıçap kaydırma çubuğunu sürükleyin"
},
"eu": {
"_name": "Zirkuluaren froga",
"animStopped": "Animazioa geldituta.",
"area": "Azalera",
"areaAnimStatus": "Sektoreak errektangelu batean elkartzen dira: zabalera = πr, altuera = r.",
"areaReviewStatus": "✓ Zabalera = πr, altuera = r → A = πr².",
"circAnimStatus": "Zirkuluerdi bakoitza (πr) lerro batean zabaltzen da, guztira 2πr eginez.",
"circReviewStatus": "✓ Goiko πr + Beheko πr = C = 2πr.",
"circumference": "Zirkunferentzia",
"initStatus": "Aukeratu froga bat eta sakatu 'Froga ezazu'.",
"proveIt": "Froga ezazu ▶",
"radius": "Erradioa",
"readyStatus": "Sakatu 'Froga ezazu' animazioa ikusteko.",
"review": "Berrikusi ▶",
"stop": "Gelditu ■",
"tabArea": "Azalera",
"tabCirc": "Zirkunferentzia",
"title": "Zirkuluaren kalkulagailua",
"visualProof": "Froga bisuala",
"hint": "Erabili erradioaren barra kalkulua ikusteko"
},
"sk": {
"_name": "Dôkaz pre kruh",
"animStopped": "Animácia zastavená.",
"area": "Obsah",
"areaAnimStatus": "Sektory sa preskladajú do obdĺžnika: šírka = πr, výška = r.",
"areaReviewStatus": "✓ Šírka = πr, výška = r → A = πr².",
"circAnimStatus": "Každý polkruh (πr) sa rozvinie do priamky, spolu tvoria 2πr.",
"circReviewStatus": "✓ Horný πr + dolný πr = C = 2πr.",
"circumference": "Obvod",
"initStatus": "Vyberte dôkaz a kliknite na „Dokázať“.",
"proveIt": "Dokázať ▶",
"radius": "Polomer",
"readyStatus": "Kliknutím na „Dokázať“ spustíte animáciu.",
"review": "Zopakovať ▶",
"stop": "Zastaviť ■",
"tabArea": "Obsah",
"tabCirc": "Obvod",
"title": "Kalkulačka kruhu",
"visualProof": "Vizuálny dôkaz",
"hint": "Potiahnutím posuvníka polomeru zobrazíte výpočet"
},
"skr-arab": {
"_name": "دائرے دا ثبوت",
"animStopped": "اینیمیشن رک ڳئی۔",
"area": "رقبہ",
"areaAnimStatus": "ٹکڑے جڑ کے مستطیل بݨ ویندن: چوڑائی = πr، اُچائی = r۔",
"areaReviewStatus": "✓ چوڑائی = πr، اُچائی = r → A = πr²۔",
"circAnimStatus": "ہر ادھ دائرہ (πr) کھل کے لکیر بݨ ویندے، کل ملا کے 2πr۔",
"circReviewStatus": "✓ اُتے πr + تھلے πr = C = 2πr۔",
"circumference": "دائرے دا گھیر",
"initStatus": "کوئی ثبوت چُݨو تے 'ثابت کرو' تے کلک کرو۔",
"proveIt": "ثابت کرو ◀",
"radius": "رداس",
"readyStatus": "اینیمیشن ݙیکھݨ کیتے 'ثابت کرو' تے کلک کرو۔",
"review": "ولا ݙیکھو ◀",
"stop": "روکو ■",
"tabArea": "رقبہ",
"tabCirc": "دائرے دا گھیر",
"title": "دائرے دا حساب کتاب",
"visualProof": "بصری ثبوت",
"hint": "حساب کتاب ݙیکھݨ کیتے رداس دے سلائیڈر کوں گھسیٹو"
},
"lb": {
"area": "Fläch",
"circumference": "Ëmfang",
"proveIt": "Beweisen ▶",
"radius": "Radius",
"readyStatus": "Klickt op „Beweisen“, fir d'Animatioun ze gesinn.",
"tabArea": "Fläch",
"visualProof": "Visuelle Beweis"
},
"ko": {
"_name": "원 증명",
"animStopped": "애니메이션이 멈췄습니다.",
"area": "면적",
"areaAnimStatus": "부채꼴들이 다시 모여 직사각형을 이룹니다: 너비 = πr, 높이 = r.",
"areaReviewStatus": "✓ 너비 = πr, 높이 = r → A = πr².",
"circAnimStatus": "각 반원(πr)은 펼쳐져 직선을 이루며, 총 2πr이 됩니다.",
"circReviewStatus": "✓ 위쪽 πr + 아래쪽 πr = C = 2πr.",
"circumference": "원둘레",
"initStatus": "증명을 선택하고 '증명하기'를 클릭하세요.",
"proveIt": "증명하기 ▶",
"radius": "반지름",
"readyStatus": "'증명하기'를 클릭하여 애니메이션을 보세요.",
"review": "다시 보기 ▶",
"stop": "정지 ■",
"tabArea": "면적",
"tabCirc": "원둘레",
"title": "원 계산기",
"visualProof": "시각적 증명",
"hint": "반지름 슬라이더를 드래그하여 계산 결과를 확인하세요."
},
"en": {
"_name": "Circle Proof",
"animStopped": "Animation stopped.",
"area": "Area",
"areaAnimStatus": "Sectors reassemble into a rectangle: width = πr, height = r.",
"areaReviewStatus": "✓ Width = πr, height = r → A = πr².",
"circAnimStatus": "Each semi-circle (πr) unrolls into a line, making 2πr in total.",
"circReviewStatus": "✓ Top πr + Bottom πr = C = 2πr.",
"circumference": "Circumference",
"initStatus": "Choose a proof and click 'Prove it'.",
"proveIt": "Prove it ▶",
"radius": "Radius",
"readyStatus": "Click 'Prove it' to watch the animation.",
"review": "Review ▶",
"stop": "Stop ■",
"tabArea": "Area",
"tabCirc": "Circumference",
"title": "Circle Calculator",
"visualProof": "Visual proof",
"hint": "Drag the radius slider to see the calculation"
},
"it": {
"animStopped": "Animazione interrotta.",
"area": "Area",
"areaAnimStatus": "I settori si riassemblano in un rettangolo: larghezza = πr, altezza = r.",
"areaReviewStatus": "✓ Larghezza = πr, altezza = r → A = πr².",
"circAnimStatus": "Ogni semicerchio (πr) si srotola in una linea, per un totale di 2πr.",
"circumference": "Circonferenza",
"initStatus": "Scegli una prova e fai clic su \"Dimostrala\".",
"proveIt": "Dimostralo ▶",
"radius": "Raggio",
"readyStatus": "Clicca su 'Dimostralo' per guardare l'animazione.",
"review": "Rivedi ▶",
"stop": "Stop ■",
"tabArea": "Area",
"tabCirc": "Circonferenza",
"visualProof": "Prova visiva"
},
"ca": {
"animStopped": "S'ha aturat l'animació.",
"area": "Àrea",
"areaReviewStatus": "✓ Amplada = πr, alçada = r → A = πr².",
"circumference": "Circumferència",
"initStatus": "Trieu una prova i feu clic a \"Demostra-ho\".",
"proveIt": "Demostra-ho ▶",
"radius": "Radi",
"stop": "Atura ■",
"tabArea": "Àrea",
"tabCirc": "Circumferència",
"title": "Calculadora de cercles",
"visualProof": "Prova visual"
},
"qqq": {
"_name": "Name of the Circle Proof module",
"animStopped": "Status message shown when the user stops the animation",
"area": "Label for the area metric",
"areaAnimStatus": "Status message shown during the area proof animation, explaining that sectors reassemble into a rectangle",
"areaReviewStatus": "Status message shown after the area proof animation completes",
"circAnimStatus": "Status message shown during the circumference proof animation, explaining that semi-circles unroll into lines",
"circReviewStatus": "Status message shown after the circumference proof animation completes",
"circumference": "Label for the circumference metric",
"initStatus": "Initial status message prompting the user to choose a proof",
"proveIt": "Button label to start the proof animation. Includes ▶ play symbol.",
"radius": "Label for the radius slider",
"readyStatus": "Status message shown after selecting a proof tab, before starting the animation",
"review": "Button label shown after animation completes, to replay. Includes ▶ play symbol.",
"stop": "Button label to stop a running animation. Includes ■ stop symbol.",
"tabArea": "Tab label for the area proof",
"tabCirc": "Tab label for the circumference proof",
"title": "Title of the circle calculator sidebar",
"visualProof": "Section heading for the visual proof controls",
"hint": "Instruction text explaining that the radius slider is interactive"
},
"ga": {
"_name": "Cruthúnas Ciorcail",
"animStopped": "Stopadh an beochan.",
"area": "Limistéar",
"areaAnimStatus": "Athcheanglaíonn earnálacha i ndronuilleog: leithead = πr, airde = r.",
"areaReviewStatus": "✓ Leithead = πr, airde = r → A = πr².",
"circAnimStatus": "Rollaíonn gach leathchiorcal (πr) isteach i líne, rud a fhágann 2πr san iomlán.",
"circReviewStatus": "✓ Barr πr + Bun πr = C = 2πr.",
"circumference": "Imlíne",
"initStatus": "Roghnaigh cruthúnas agus cliceáil 'Cruthaigh é'.",
"proveIt": "Cruthaigh é ▶",
"radius": "Ga",
"readyStatus": "Cliceáil 'Cruthaigh é' chun an beochan a fheiceáil.",
"review": "Athbhreithniú ▶",
"stop": "Stop ■",
"tabArea": "Limistéar",
"tabCirc": "Imlíne",
"title": "Áireamhán Ciorcail",
"visualProof": "Cruthúnas amhairc",
"hint": "Tarraing an sleamhnán gha chun an ríomh a fheiceáil"
},
"es": {
"_name": "Prueba del círculo",
"animStopped": "Animación detenida.",
"area": "Área",
"areaAnimStatus": "Los sectores se vuelven a ensamblar en un rectángulo: ancho = πr, alto = r.",
"areaReviewStatus": "✓ Ancho = πr, alto = r → A = πr².",
"circAnimStatus": "Cada semicírculo (πr) se desenrolla en una línea, haciendo 2πr en total.",
"circReviewStatus": "✓ Superior πr + Inferior πr = C = 2πr.",
"circumference": "Circunferencia",
"initStatus": "Elige una prueba y haz clic en 'Pruébalo'.",
"proveIt": "Pruébalo ▶",
"radius": "Radio",
"readyStatus": "Haz clic en 'Pruébalo' para ver la animación.",
"review": "Revisar ▶",
"stop": "Detener ■",
"tabArea": "Área",
"tabCirc": "Circunferencia",
"title": "Calculadora de Círculos",
"visualProof": "Prueba visual",
"hint": "Arrastra el control del radio para ver el cálculo."
},
"mk": {
"_name": "Доказ за кружница",
"animStopped": "Анимацијата запре.",
"area": "Плоштина",
"areaAnimStatus": "Исечоците се составуваат во правоаголник: ширина = πr, висина = r.",
"areaReviewStatus": "✓ Ширина = πr, висина = r → A = πr².",
"circAnimStatus": "Секоја полукружница се (πr) се одвиткува во права, што прави вкупно 2πr.",
"circReviewStatus": "✓ Горе πr + Долу πr = C = 2πr.",
"circumference": "Обиколка",
"initStatus": "Изберете доказ и стиснете на „Докажи“.",
"proveIt": "Докажи ▶",
"radius": "Полупречник",
"readyStatus": "Стиснете на „Докажи“ за да ја погледате анимацијата.",
"review": "Прегледај ▶",
"stop": "Запри ■",
"tabArea": "Плоштина",
"tabCirc": "Обиколка",
"title": "Пресметувач на кругови",
"visualProof": "Нагледен доказ",
"hint": "Повлечете го лизгачот за полупречник за да ја видите пресметката"
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
var PX_PER_CM = 30;
function init( container ) {
// ── Build HTML structure ──────────────────────────────────────
container.innerHTML =
'<div class="hiruwiki-circle-proof">' +
'<div class="hiruwiki-cp-canvas-panel" id="hcp-canvas-wrap">' +
'<canvas id="hcp-canvas"></canvas>' +
'</div>' +
'<div class="hiruwiki-cp-sidebar">' +
'<div class="hiruwiki-cp-section-title">' + t('title') + '</div>' +
'<div>' +
'<div class="hiruwiki-cp-slider-label">' +
'<span>' + t('radius') + '</span>' +
'<span class="hiruwiki-cp-slider-val"><span id="hcp-rdisp">2.0</span> cm</span>' +
'</div>' +
'<input class="hiruwiki-cp-slider" type="range" id="hcp-slider" min="10" max="105" value="60" step="1">' +
'</div>' +
'<hr class="hiruwiki-cp-divider">' +
'<div class="hiruwiki-cp-metric">' +
'<div class="hiruwiki-cp-metric-label">' + t('circumference') + '</div>' +
'<div class="hiruwiki-cp-metric-formula">C = 2πr</div>' +
'<div class="hiruwiki-cp-metric-value" id="hcp-cval">—</div>' +
'</div>' +
'<div class="hiruwiki-cp-metric">' +
'<div class="hiruwiki-cp-metric-label">' + t('area') + '</div>' +
'<div class="hiruwiki-cp-metric-formula">A = πr²</div>' +
'<div class="hiruwiki-cp-metric-value" id="hcp-aval">—</div>' +
'</div>' +
'<hr class="hiruwiki-cp-divider">' +
'<div class="hiruwiki-cp-section-title">' + t('visualProof') + '</div>' +
'<div class="hiruwiki-cp-tab-row">' +
'<button class="hiruwiki-cp-tab active" id="hcp-tabC">' + t('tabCirc') + '</button>' +
'<button class="hiruwiki-cp-tab" id="hcp-tabA">' + t('tabArea') + '</button>' +
'</div>' +
'<button class="hiruwiki-cp-prove-btn" id="hcp-provebtn">' + t('proveIt') + '</button>' +
'<div class="hiruwiki-cp-status" id="hcp-status">' + t('initStatus') + '</div>' +
'</div>' +
'</div>';
// ── References ───────────────────────────────────────────────
var canvas = document.getElementById( 'hcp-canvas' );
var ctx = canvas.getContext( '2d' );
var wrap = document.getElementById( 'hcp-canvas-wrap' );
var slider = document.getElementById( 'hcp-slider' );
var rDisp = document.getElementById( 'hcp-rdisp' );
var cVal = document.getElementById( 'hcp-cval' );
var aVal = document.getElementById( 'hcp-aval' );
var tabC = document.getElementById( 'hcp-tabC' );
var tabA = document.getElementById( 'hcp-tabA' );
var proveBtn = document.getElementById( 'hcp-provebtn' );
var status = document.getElementById( 'hcp-status' );
// ── State ────────────────────────────────────────────────────
var rPx = 60, rCm = 2.0;
var proofMode = 'C';
var animating = false, animT = 0, rafId = null;
// ── Helpers ──────────────────────────────────────────────────
function ease( t ) {
return t < 0.5 ? 2 * t * t : -1 + ( 4 - 2 * t ) * t;
}
// ── Proof selection ──────────────────────────────────────────
function selectProof( m ) {
proofMode = m;
tabC.classList.toggle( 'active', m === 'C' );
tabA.classList.toggle( 'active', m === 'A' );
stopAnim();
proveBtn.textContent = '' + t('proveIt') + '';
proveBtn.classList.remove( 'running' );
status.textContent = t('readyStatus');
redraw();
}
tabC.addEventListener( 'click', function () { selectProof( 'C' ); } );
tabA.addEventListener( 'click', function () { selectProof( 'A' ); } );
// ── Animation ────────────────────────────────────────────────
function stopAnim() {
if ( rafId ) { cancelAnimationFrame( rafId ); }
animating = false;
animT = 0;
}
function tick() {
animT += 0.007;
if ( animT >= 1 ) {
animT = 1;
animating = false;
proveBtn.classList.remove( 'running' );
proveBtn.textContent = '' + t('review') + '';
status.textContent = proofMode === 'C'
? t('circReviewStatus')
: t('areaReviewStatus');
}
redraw();
if ( animating ) { rafId = requestAnimationFrame( tick ); }
}
proveBtn.addEventListener( 'click', function () {
if ( animating ) {
stopAnim();
proveBtn.textContent = '' + t('proveIt') + '';
proveBtn.classList.remove( 'running' );
status.textContent = t('animStopped');
redraw();
} else {
animT = 0;
animating = true;
proveBtn.classList.add( 'running' );
proveBtn.textContent = t('stop');
status.textContent = proofMode === 'C'
? t('circAnimStatus')
: t('areaAnimStatus');
tick();
}
} );
// ── Metrics ──────────────────────────────────────────────────
function updateMetrics() {
rPx = parseInt( slider.value );
rCm = rPx / PX_PER_CM;
rDisp.textContent = rCm.toFixed( 1 );
cVal.textContent = ( 2 * Math.PI * rCm ).toFixed( 2 ) + ' cm';
aVal.textContent = ( Math.PI * rCm * rCm ).toFixed( 2 ) + ' cm²';
stopAnim();
proveBtn.textContent = '' + t('proveIt') + '';
proveBtn.classList.remove( 'running' );
status.textContent = t('readyStatus');
redraw();
}
slider.addEventListener( 'input', updateMetrics );
// ── Canvas resize ────────────────────────────────────────────
function resizeCanvas() {
canvas.width = wrap.clientWidth || 420;
canvas.height = wrap.clientHeight || 560;
}
window.addEventListener( 'resize', function () {
resizeCanvas();
redraw();
} );
// ── Drawing ──────────────────────────────────────────────────
var BLUE = '#378ADD';
var AMBER = '#EF9F27';
var GREEN = hiruwiki.getThemeColor('color-success', '#1d9e75');
function redraw() {
ctx.clearRect( 0, 0, canvas.width, canvas.height );
var t = ease( animT );
var cx = canvas.width / 2;
var cy = canvas.height * ( t > 0 ? 0.37 : 0.5 );
drawCircle( ctx, cx, cy, t );
if ( animT > 0 ) {
if ( proofMode === 'C' ) { drawCircProof( ctx, cx, cy, t ); }
else { drawAreaProof( ctx, cx, cy, t ); }
}
}
function drawCircle( ctx, cx, cy, t ) {
ctx.save();
ctx.globalAlpha = 1 - t * 0.45;
ctx.beginPath();
ctx.arc( cx, cy, rPx, 0, Math.PI * 2 );
ctx.fillStyle = 'rgba(29,158,117,0.10)';
ctx.fill();
ctx.strokeStyle = GREEN;
ctx.lineWidth = 2.5;
ctx.stroke();
// radius dashed line
ctx.beginPath();
ctx.moveTo( cx, cy );
ctx.lineTo( cx + rPx, cy );
ctx.strokeStyle = hiruwiki.getThemeColor('color-placeholder', '#72777d');
ctx.lineWidth = 1;
ctx.setLineDash( [ 4, 3 ] );
ctx.stroke();
ctx.setLineDash( [] );
// centre dot
ctx.beginPath();
ctx.arc( cx, cy, 3, 0, Math.PI * 2 );
ctx.fillStyle = hiruwiki.getThemeColor('color-placeholder', '#72777d');
ctx.fill();
// r label
ctx.font = '14px sans-serif';
ctx.fillStyle = hiruwiki.getThemeColor('color-placeholder', '#72777d');
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText( 'r = ' + rCm.toFixed( 1 ) + ' cm', cx + rPx / 2, cy - 6 );
ctx.globalAlpha = 1;
ctx.restore();
}
function drawCircProof( ctx, cx, cy, t ) {
ctx.save();
var lineLen = Math.PI * rPx;
var line1Y = cy + rPx + 52;
var line2Y = line1Y + 50;
var t1 = Math.min( t * 2, 1 );
var t2 = Math.max( ( t - 0.5 ) * 2, 0 );
// remaining top arc (blue)
if ( t1 < 1 ) {
ctx.beginPath();
ctx.arc( cx, cy, rPx, -Math.PI + Math.PI * t1, 0 );
ctx.strokeStyle = BLUE;
ctx.lineWidth = 3;
ctx.stroke();
}
// remaining bottom arc (amber)
if ( t2 < 1 ) {
ctx.beginPath();
ctx.arc( cx, cy, rPx, Math.PI * t2, Math.PI );
ctx.strokeStyle = AMBER;
ctx.lineWidth = 3;
ctx.stroke();
}
// line 1 — blue
if ( t1 > 0 ) {
var d1 = lineLen * t1;
var x0 = cx - d1 / 2, x1 = cx + d1 / 2;
ctx.beginPath(); ctx.moveTo( x0, line1Y ); ctx.lineTo( x1, line1Y );
ctx.strokeStyle = BLUE; ctx.lineWidth = 3.5; ctx.stroke();
ctx.strokeStyle = hiruwiki.getThemeColor('color-base', '#202122'); ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo( x0, line1Y - 8 ); ctx.lineTo( x0, line1Y + 8 ); ctx.stroke();
ctx.beginPath(); ctx.moveTo( x1, line1Y - 8 ); ctx.lineTo( x1, line1Y + 8 ); ctx.stroke();
if ( t1 > 0.94 ) {
ctx.font = 'bold 14px sans-serif';
ctx.fillStyle = BLUE;
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText( 'πr = ' + ( Math.PI * rCm ).toFixed( 2 ) + ' cm', cx, line1Y + 11 );
}
}
// line 2 — amber
if ( t2 > 0 ) {
var d2 = lineLen * t2;
var xa = cx - d2 / 2, xb = cx + d2 / 2;
ctx.beginPath(); ctx.moveTo( xa, line2Y ); ctx.lineTo( xb, line2Y );
ctx.strokeStyle = AMBER; ctx.lineWidth = 3.5; ctx.stroke();
ctx.strokeStyle = hiruwiki.getThemeColor('color-base', '#202122'); ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo( xa, line2Y - 8 ); ctx.lineTo( xa, line2Y + 8 ); ctx.stroke();
ctx.beginPath(); ctx.moveTo( xb, line2Y - 8 ); ctx.lineTo( xb, line2Y + 8 ); ctx.stroke();
if ( t2 > 0.94 ) {
ctx.font = 'bold 14px sans-serif';
ctx.fillStyle = AMBER;
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText( 'πr = ' + ( Math.PI * rCm ).toFixed( 2 ) + ' cm', cx, line2Y + 11 );
}
}
// final summary
if ( t >= 0.99 ) {
ctx.font = 'bold 15px sans-serif';
ctx.fillStyle = hiruwiki.getThemeColor('color-base', '#202122');
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText( 'C = 2πr = ' + ( 2 * Math.PI * rCm ).toFixed( 2 ) + ' cm', cx, line1Y - 14 );
}
ctx.restore();
}
function drawAreaProof( ctx, cx, cy, t ) {
ctx.save();
var N = 16;
var dA = ( 2 * Math.PI ) / N;
var rectW = Math.PI * rPx;
var rectH = rPx;
var rectCY = cy + rPx + 32 + rectH / 2;
var rectX = cx - rectW / 2;
for ( var i = 0; i < N; i++ ) {
var isUp = ( i % 2 === 0 );
var colIdx = Math.floor( i / 2 );
var sliceW = rectW / ( N / 2 );
var midAngle = i * dA - Math.PI / 2;
var tgtX = isUp
? rectX + colIdx * sliceW + sliceW / 2
: rectX + colIdx * sliceW + sliceW;
var tgtY = isUp ? rectCY + rectH / 2 : rectCY - rectH / 2;
var tgtRot = isUp ? -Math.PI / 2 : Math.PI / 2;
var lx = cx + ( tgtX - cx ) * t;
var ly = cy + ( tgtY - cy ) * t;
var lrot = midAngle + ( tgtRot - midAngle ) * t;
ctx.save();
ctx.translate( lx, ly );
ctx.rotate( lrot );
ctx.beginPath();
ctx.moveTo( 0, 0 );
ctx.arc( 0, 0, rPx, -dA / 2, dA / 2 );
ctx.closePath();
ctx.fillStyle = ( i % 2 === 0 )
? 'rgba(29,158,117,0.48)'
: 'rgba(55,138,221,0.43)';
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.10)';
ctx.lineWidth = 0.5;
ctx.stroke();
ctx.restore();
}
if ( t > 0.88 ) {
var alpha = Math.min( ( t - 0.88 ) / 0.12, 1 );
ctx.globalAlpha = alpha;
ctx.strokeStyle = hiruwiki.getThemeColor('color-base', '#202122'); ctx.lineWidth = 1.5;
ctx.setLineDash( [ 5, 3 ] );
ctx.strokeRect( rectX, rectCY - rectH / 2, rectW, rectH );
ctx.setLineDash( [] );
ctx.font = 'bold 14px sans-serif';
ctx.fillStyle = BLUE;
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText( 'πr = ' + ( Math.PI * rCm ).toFixed( 2 ) + ' cm', cx, rectCY + rectH / 2 + 9 );
ctx.fillStyle = AMBER;
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText( 'r = ' + rCm.toFixed( 1 ) + ' cm', rectX + rectW + 10, rectCY );
if ( t > 0.97 ) {
ctx.font = 'bold 15px sans-serif';
ctx.fillStyle = hiruwiki.getThemeColor('color-base', '#202122');
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText(
'A = πr × r = πr² = ' + ( Math.PI * rCm * rCm ).toFixed( 2 ) + ' cm²',
cx, rectCY - rectH / 2 - 12
);
}
ctx.globalAlpha = 1;
}
ctx.restore();
}
// ── Bootstrap ────────────────────────────────────────────────
resizeCanvas();
updateMetrics();
// Footer branding
var footer = document.createElement("div");
footer.className = "hw-footer";
var fLogo = document.createElement("a");
fLogo.className = "hw-footer-icon";
fLogo.href = mw.util.getUrl('Wikipedia:Hiruwiki');
fLogo.title = 'Hiruwiki';
fLogo.innerHTML = hiruwiki.getLogoSvg(22);
var fText = document.createElement("span");
fText.innerHTML = t('hint');
footer.appendChild(fLogo);
footer.appendChild(fText);
container.appendChild(footer);
}
// ── Scan for containers and initialise ───────────────────────────
function scanAndInit() {
document.querySelectorAll( '.hiruwiki[data-module="circle-proof"]' ).forEach( function ( el ) {
if ( !el.dataset.cpInit ) {
el.dataset.cpInit = '1';
init( el );
}
} );
}
if ( document.readyState === 'loading' ) {
document.addEventListener( 'DOMContentLoaded', scanAndInit );
} else {
scanAndInit();
}
}() );
6e2l9pjy9j977xohdnao9jybmoh7vn6
MediaWiki:Hiruwiki/modules/circumcentre.js
8
179482
781418
770209
2026-10-28T10:38:41Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781418
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/* =========================================================
* Hiruwiki module: circumcentre
* Visualisation of the circumcentre of a triangle
* =========================================================
* i18n: add a new key to the `messages` object below and
* a matching entry for every language code you need.
* Fall back to 'en' when the wiki language is absent.
* ========================================================= */
( function () {
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"nl": {
"_name": "Middelpunt omgeschreven cirkel",
"hint": "Sleep een hoekpunt om het middelpunt van de omgeschreven cirkel te verkennen"
},
"fr": {
"_name": "Circoncentre",
"hint": "Tirez n'importe quel sommet pour explorer le circoncentre"
},
"tr": {
"_name": "Çevre merkezi",
"hint": "Herhangi bir köşeyi sürükleyin · Ölçek: 1 cm = 40 px"
},
"eu": {
"_name": "Zirkunzentroa",
"hint": "Erpinak mugitu ditzakezu zirkunzentroa ikusteko"
},
"sk": {
"_name": "Stred opísanej kružnice",
"hint": "Potiahnutím ľubovoľného vrcholu preskúmate stred opísanej kružnice"
},
"skr-arab": {
"_name": "گرد مرکز",
"hint": "گرد مرکز کوں کھوجݨ کیتے کہیں وی سرے کوں گھسیٹو"
},
"ko": {
"_name": "외심",
"hint": "꼭짓점을 드래그해서 외심을 탐구하세요"
},
"en": {
"_name": "Circumcentre",
"hint": "Drag any vertex to explore the circumcentre"
},
"it": {
"_name": "Circumcentro",
"hint": "Trascina un vertice qualsiasi per esplorare il circocentro"
},
"qqq": {
"_name": "Name of the circumcentre module",
"hint": "Instruction text shown in the footer."
},
"ga": {
"_name": "Lár-imlíne",
"hint": "Tarraing aon bhuaicphointe chun an t-imlár a iniúchadh"
},
"es": {
"_name": "Circuncentro",
"hint": "Arrastra un vértice para explorar el circuncentro"
},
"mk": {
"_name": "Центар на опишана кружница",
"hint": "Повлечете некое теме за да го истражите центарот на опишаната кружница"
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
/* ── Constants ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var SCALE = 40; /* pixels per centimetre */
var RULER_H = 0; /* ruler removed */
var DPR = window.devicePixelRatio || 1;
/* ── Geometry helpers ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
function dist( a, b ) {
return Math.hypot( b.x - a.x, b.y - a.y );
}
function midpoint( A, B ) {
return { x: ( A.x + B.x ) / 2, y: ( A.y + B.y ) / 2 };
}
function circumcentre( A, B, C ) {
var ax = A.x, ay = A.y, bx = B.x, by = B.y, cx = C.x, cy = C.y;
var D = 2 * ( ax * ( by - cy ) + bx * ( cy - ay ) + cx * ( ay - by ) );
if ( Math.abs( D ) < 1e-9 ) {
return { x: ( ax + bx + cx ) / 3, y: ( ay + by + cy ) / 3 };
}
var a2 = ax * ax + ay * ay;
var b2 = bx * bx + by * by;
var c2 = cx * cx + cy * cy;
return {
x: ( a2 * ( by - cy ) + b2 * ( cy - ay ) + c2 * ( ay - by ) ) / D,
y: ( a2 * ( cx - bx ) + b2 * ( ax - cx ) + c2 * ( bx - ax ) ) / D
};
}
function angleDeg( V, A, B ) {
var ax = A.x - V.x, ay = A.y - V.y;
var bx = B.x - V.x, by = B.y - V.y;
var cos = ( ax * bx + ay * by ) /
( Math.hypot( ax, ay ) * Math.hypot( bx, by ) );
return Math.acos( Math.max( -1, Math.min( 1, cos ) ) ) * 180 / Math.PI;
}
function labelOffset( V, others, mag ) {
var dx = 0, dy = 0;
others.forEach( function ( O ) { dx += V.x - O.x; dy += V.y - O.y; } );
var l = Math.hypot( dx, dy ) || 1;
return { x: V.x + dx / l * mag, y: V.y + dy / l * mag };
}
function extendBisector( M, perp, W, H ) {
var dx = perp.x, dy = perp.y;
var ts = [];
if ( Math.abs( dx ) > 1e-9 ) { ts.push( -M.x / dx ); ts.push( ( W - M.x ) / dx ); }
if ( Math.abs( dy ) > 1e-9 ) { ts.push( -M.y / dy ); ts.push( ( H - M.y ) / dy ); }
ts = ts.filter( function ( t ) {
var px = M.x + t * dx, py = M.y + t * dy;
return px >= -5 && px <= W + 5 && py >= -5 && py <= H + 5;
} );
if ( ts.length < 2 ) { return null; }
ts.sort( function ( a, b ) { return a - b; } );
var t0 = ts[ 0 ], t1 = ts[ ts.length - 1 ];
return { x0: M.x + t0 * dx, y0: M.y + t0 * dy, x1: M.x + t1 * dx, y1: M.y + t1 * dy };
}
function toCm( px ) { return px / SCALE; }
function fmt( n ) { return n.toFixed( 2 ); }
/* ── Build widget ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
function buildWidget( container ) {
var lang = ( window.mw && mw.config.get( 'wgUserLanguage' ) ) || 'en';
lang = lang.split( '-' )[ 0 ];
if ( !messages[ lang ] ) { lang = 'en'; }
/* -- HTML skeleton -- */
container.innerHTML =
'<div class="hw-circumcentre">' +
'<canvas class="hw-canvas"></canvas>' +
'</div>';
/* -- Footer -- */
var footer = document.createElement( 'div' );
footer.className = 'hw-footer';
var fLogo = document.createElement( 'a' );
fLogo.className = 'hw-footer-icon';
fLogo.href = ( window.mw && mw.util.getUrl( 'Wikipedia:Hiruwiki' ) ) || '#';
fLogo.title = 'Hiruwiki';
if ( window.hiruwiki && window.hiruwiki.getLogoSvg ) {
fLogo.insertAdjacentHTML( 'beforeend', hiruwiki.getLogoSvg( 22 ) );
}
var fText = document.createElement( 'span' );
fText.className = 'hw-footer__text';
fText.textContent = messages[ lang ].hint;
footer.appendChild( fLogo );
footer.appendChild( fText );
container.appendChild( footer );
var canvas = container.querySelector( '.hw-canvas' );
var ctx = canvas.getContext( '2d' );
var W, H, verts, dragging = -1;
/* -- vertex initialisation -- */
function initVerts() {
var ox = W / 2 - 5.5 * SCALE;
var oy = 20;
verts = [
{ x: ox + 5.5 * SCALE, y: oy + 1.2 * SCALE },
{ x: ox + 1.2 * SCALE, y: oy + 8.0 * SCALE },
{ x: ox + 9.8 * SCALE, y: oy + 8.0 * SCALE }
];
}
/* -- colour palette -- */
function isDark() {
var root = document.documentElement;
if ( root.classList.contains( 'skin-theme-clientpref-night' ) ||
root.classList.contains( 'client-dark-mode' ) ||
document.body.classList.contains( 'mw-dark-mode' ) ) {
return true;
}
return !!( window.matchMedia && window.matchMedia( '(prefers-color-scheme: dark)' ).matches );
}
function palette() {
var dark = isDark();
return {
dark: dark,
tri: hiruwiki.getThemeColor( 'hw-color-primary', dark ? '#AFA9EC' : '#534AB7' ),
circ: hiruwiki.getThemeColor( 'hw-color-success', dark ? '#5DCAA5' : '#0F6E56' ),
bis: hiruwiki.getThemeColor( 'hw-color-warning', dark ? '#FAC775' : '#BA7517' ),
grid: hiruwiki.getThemeColor( 'hw-border', dark ? '#444441' : '#D3D1C7' ),
dotBg: hiruwiki.getThemeColor( 'hw-bg-base', dark ? '#1e1e1c' : '#ffffff' ),
circFill: dark ? 'rgba(93,202,165,.06)' : 'rgba(15,110,86,.05)',
circStroke: dark ? 'rgba(93,202,165,.35)' : 'rgba(15,110,86,.30)'
};
}
/* -- grid (major cm lines only, very faint) -- */
function drawGrid( p ) {
ctx.lineWidth = 0.5;
ctx.strokeStyle = p.grid;
ctx.globalAlpha = 0.18;
for ( var cm = 0; cm * SCALE < W + SCALE; cm++ ) {
ctx.beginPath();
ctx.moveTo( cm * SCALE, 0 );
ctx.lineTo( cm * SCALE, H );
ctx.stroke();
}
for ( var row = 0; row * SCALE < H + SCALE; row++ ) {
ctx.beginPath();
ctx.moveTo( 0, row * SCALE );
ctx.lineTo( W, row * SCALE );
ctx.stroke();
}
ctx.globalAlpha = 1.0;
}
/* -- right-angle mark at bisector midpoint -- */
function drawRightAngleMark( M, perp, sideDir ) {
var su = sideDir, ru = perp;
var SZ = 12;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo( M.x + su.x * SZ, M.y + su.y * SZ );
ctx.lineTo( M.x + su.x * SZ + ru.x * SZ, M.y + su.y * SZ + ru.y * SZ );
ctx.lineTo( M.x + ru.x * SZ, M.y + ru.y * SZ );
ctx.stroke();
}
function sideDir( P1, P2 ) {
var d = dist( P1, P2 ) || 1;
return { x: ( P2.x - P1.x ) / d, y: ( P2.y - P1.y ) / d };
}
/* -- main draw -- */
function draw() {
if ( !verts ) { return; }
ctx.clearRect( 0, 0, W, H );
var p = palette();
drawGrid( p );
var A = verts[ 0 ], B = verts[ 1 ], C = verts[ 2 ];
var O = circumcentre( A, B, C );
var R = dist( O, A );
var Ma = midpoint( B, C ), Mb = midpoint( C, A ), Mc = midpoint( A, B );
/* Perpendicular bisector directions (unit vectors) */
function bisDir( P1, P2 ) {
var nx = -( P2.y - P1.y ), ny = P2.x - P1.x;
var l = Math.hypot( nx, ny ) || 1;
return { x: nx / l, y: ny / l };
}
var perpA = bisDir( B, C );
var perpB = bisDir( C, A );
var perpC = bisDir( A, B );
var lineA = extendBisector( Ma, perpA, W, H );
var lineB = extendBisector( Mb, perpB, W, H );
var lineC = extendBisector( Mc, perpC, W, H );
/* 1. dashed perpendicular bisector lines */
ctx.save();
ctx.strokeStyle = p.bis; ctx.lineWidth = 1.4; ctx.setLineDash( [ 6, 5 ] );
[ lineA, lineB, lineC ].forEach( function ( ln ) {
if ( !ln ) { return; }
ctx.beginPath(); ctx.moveTo( ln.x0, ln.y0 ); ctx.lineTo( ln.x1, ln.y1 ); ctx.stroke();
} );
ctx.setLineDash( [] ); ctx.restore();
/* 2. circumcircle */
ctx.save();
ctx.beginPath(); ctx.arc( O.x, O.y, R, 0, Math.PI * 2 );
ctx.strokeStyle = p.circStroke; ctx.lineWidth = 1.5; ctx.stroke();
ctx.fillStyle = p.circFill; ctx.fill();
ctx.restore();
/* 3. triangle fill + stroke */
ctx.save();
ctx.beginPath();
ctx.moveTo( A.x, A.y ); ctx.lineTo( B.x, B.y ); ctx.lineTo( C.x, C.y ); ctx.closePath();
ctx.fillStyle = p.dark ? 'rgba(175,169,236,.07)' : 'rgba(83,74,183,.06)';
ctx.fill();
ctx.strokeStyle = p.tri; ctx.lineWidth = 2; ctx.stroke();
ctx.restore();
/* 4. right-angle marks at midpoints */
ctx.save();
ctx.strokeStyle = p.bis;
drawRightAngleMark( Ma, perpA, sideDir( B, C ) );
drawRightAngleMark( Mb, perpB, sideDir( C, A ) );
drawRightAngleMark( Mc, perpC, sideDir( A, B ) );
ctx.restore();
/* 5. midpoint dots */
[ Ma, Mb, Mc ].forEach( function ( M ) {
ctx.save();
ctx.beginPath(); ctx.arc( M.x, M.y, 5, 0, Math.PI * 2 );
ctx.fillStyle = p.bis; ctx.fill();
ctx.restore();
} );
/* 6. circumcentre dot */
ctx.save();
ctx.beginPath(); ctx.arc( O.x, O.y, 8, 0, Math.PI * 2 );
ctx.fillStyle = p.circ; ctx.fill();
ctx.strokeStyle = p.dotBg; ctx.lineWidth = 2; ctx.stroke();
ctx.restore();
/* 7. vertex dots */
verts.forEach( function ( V, i ) {
ctx.save();
ctx.beginPath(); ctx.arc( V.x, V.y, dragging === i ? 8 : 6.5, 0, Math.PI * 2 );
ctx.fillStyle = p.tri; ctx.fill();
ctx.strokeStyle = p.dotBg; ctx.lineWidth = 2; ctx.stroke();
ctx.restore();
} );
/* 8. text labels */
ctx.save();
ctx.textBaseline = 'middle';
/* vertex names */
ctx.font = "italic 700 18px 'Latin Modern Math', 'STIX Two Math', 'Computer Modern', 'Times New Roman', serif";
[ A, B, C ].forEach( function ( V, i ) {
var lbl = labelOffset( V, verts.filter( function ( _, j ) { return j !== i; } ), 26 );
ctx.fillStyle = p.tri; ctx.textAlign = 'center';
ctx.fillText( [ 'A', 'B', 'C' ][ i ], lbl.x, lbl.y );
} );
/* O label */
ctx.fillStyle = p.circ;
ctx.font = "italic 700 18px 'Latin Modern Math', 'STIX Two Math', 'Computer Modern', 'Times New Roman', serif";
ctx.textAlign = 'center';
ctx.fillText( 'O', O.x + 16, O.y - 16 );
ctx.restore();
}
/* -- resize -- */
function resize() {
W = canvas.offsetWidth;
H = 440;
canvas.width = W * DPR;
canvas.height = H * DPR;
ctx.setTransform( DPR, 0, 0, DPR, 0, 0 );
initVerts();
draw();
}
/* -- pointer helpers -- */
function ptFromEvent( e ) {
var rect = canvas.getBoundingClientRect();
var src = e.touches ? e.touches[ 0 ] : e;
return {
x: ( src.clientX - rect.left ) * ( W / rect.width ),
y: ( src.clientY - rect.top ) * ( H / rect.height )
};
}
function hitTest( pt ) {
return verts.findIndex( function ( v ) {
return Math.hypot( v.x - pt.x, v.y - pt.y ) < 16;
} );
}
function clamp( pt ) {
return {
x: Math.max( 10, Math.min( W - 10, pt.x ) ),
y: Math.max( 4, Math.min( H - 10, pt.y ) )
};
}
canvas.addEventListener( 'mousedown', function ( e ) { dragging = hitTest( ptFromEvent( e ) ); if ( dragging >= 0 ) { canvas.style.cursor = 'grabbing'; } } );
canvas.addEventListener( 'mousemove', function ( e ) {
if ( dragging < 0 ) {
canvas.style.cursor = hitTest( ptFromEvent( e ) ) >= 0 ? 'grab' : 'crosshair';
return;
}
verts[ dragging ] = clamp( ptFromEvent( e ) );
draw();
} );
canvas.addEventListener( 'mouseup', function () { dragging = -1; canvas.style.cursor = 'crosshair'; } );
canvas.addEventListener( 'mouseleave', function () { dragging = -1; } );
canvas.addEventListener( 'touchstart', function ( e ) {
e.preventDefault(); dragging = hitTest( ptFromEvent( e ) );
}, { passive: false } );
canvas.addEventListener( 'touchmove', function ( e ) {
e.preventDefault();
if ( dragging < 0 ) { return; }
verts[ dragging ] = clamp( ptFromEvent( e ) );
draw();
}, { passive: false } );
canvas.addEventListener( 'touchend', function () { dragging = -1; } );
/* dark-mode live update (MediaWiki) */
var observer = new MutationObserver( draw );
observer.observe( document.documentElement, { attributes: true, attributeFilter: [ 'class' ] } );
observer.observe( document.body, { attributes: true, attributeFilter: [ 'class' ] } );
/* kick off */
var ro = new ResizeObserver( resize );
ro.observe( canvas );
}
/* ----------------------------------------------------------
* Initialise all matching containers on the page
* ---------------------------------------------------------- */
document.querySelectorAll( '.hiruwiki[data-module="circumcentre"]' ).forEach( buildWidget );
}() );
cb66t9s8k80lonhdo2bzxpoi2nwdqhp
MediaWiki:Hiruwiki/modules/dividend.js
8
179484
781419
770211
2026-10-28T10:38:44Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781419
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/**
* Hiruwiki module: dividend
* Visualises integer division with remainders as a segmented bar,
* a live equation, and a repeated-addition breakdown.
*
* Usage: <div class="hiruwiki" data-module="dividend"></div>
*
* ─── I18N ────────────────────────────────────────────────────────────────────
* To add a language, add an entry to DIVIDEND_I18N below using the MediaWiki
* language code as the key (e.g. 'eu' for Basque, 'es' for Spanish).
* Every key present in 'en' must also appear in every other entry.
*
* Interpolation tokens:
* {N} – dividend
* {D} – divisor
* {Q} – quotient
* {R} – remainder
* {QD} – quotient × divisor (the largest multiple that fits)
* {s} – plural suffix for "chunk/s" — empty when Q=1, 's' otherwise
* (some languages may ignore this and handle plurals differently)
* ─────────────────────────────────────────────────────────────────────────────
*/
( function () {
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"nl": {
"_name": "Deeltal en deler",
"eq_lbl_dividend": "deeltal",
"eq_lbl_divisor": "deler",
"eq_lbl_quotient": "quotiënt",
"eq_lbl_remainder": "rest",
"insight_exact": "<strong>$1 ÷ $2 = $3</strong> precies — geen rest. De balk past perfect in <strong>$3</strong> gelijke stukken$6. Dit betekent dat <strong>$2</strong> een deler is van <strong>$1</strong>.",
"insight_less": "<strong>$1</strong> is kleiner dan de deler <strong>$2</strong>, dus het past in <strong>0</strong> hele stukken. De hele waarde is de rest. De rest is altijd kleiner dan de deler.",
"insight_normal": "<strong>$1 ÷ $2 = $3</strong> rest <strong>$4</strong>. Merk op dat het rode stukje (<strong>$4</strong>) altijd korter is dan één blauw blok (<strong>$2</strong>). De rest is altijd strikt kleiner dan de deler. Controle: <strong>$3 × $2 + $4 = $5 + $4 = $1</strong> ✓",
"label_bar_description": "Balkweergave — elk blauw blokje = {{PLURAL:$1|$1 eenheid|$1 eenheden}}",
"label_choose": "Kies deeltal en deler",
"label_dividend": "Deeltal (het getal dat gedeeld wordt)",
"label_divisor": "Deler (de grootte van het stuk)",
"label_equation": "Deelvergelijking",
"label_repadd": "Herhaalde optelling",
"repadd_exact": "— is precies deelbaar!",
"repadd_leftover": "blijft over",
"repadd_less": "$1 is minder dan $2, dus het quotiënt is 0 en de hele waarde is de rest.",
"hint": "Pas het deeltal en de deler aan om de verdeling te zien"
},
"fr": {
"_name": "Dividende et diviseur",
"eq_lbl_dividend": "dividende",
"eq_lbl_divisor": "diviseur",
"eq_lbl_quotient": "quotient",
"eq_lbl_remainder": "reste",
"insight_exact": "<strong>$1 ÷ $2 = $3</strong> exactement — pas de reste. La barre se remplit parfaitement en <strong>$3</strong> morceaux égaux. Cela signifie que <strong>$2</strong> est un diviseur de <strong>$1</strong>.",
"insight_less": "<strong>$1</strong> est plus petit que le diviseur <strong>$2</strong>, donc il contient <strong>0</strong> morceau entier. Toute la valeur est le reste. Le reste est toujours inférieur au diviseur.",
"insight_normal": "<strong>$1 ÷ $2 = $3</strong> reste <strong>$4</strong>. Notez que la pièce rouge (<strong>$4</strong>) est toujours plus courte qu'un morceau bleu (<strong>$2</strong>). Le reste est toujours strictement inférieur au diviseur. Vérification : <strong>$3 × $2 + $4 = $5 + $4 = $1</strong> ✓",
"label_bar_description": "Bare de visualisation pour chaque bouton bleue = {{PLURAL:$1|$1 unit|$1 units}}",
"label_choose": "Choisissez le dividende et le diviseur",
"label_dividend": "Dividende (le nombre à diviser)",
"label_divisor": "Diviseur (la taille du morceau)",
"label_equation": "Équation de division",
"label_repadd": "Addition répétée",
"repadd_exact": "— divise exactement !",
"repadd_leftover": "reste",
"repadd_less": "$1 est inférieur à $2, donc le quotient est 0 et toute la valeur est le reste.",
"hint": "Ajustez le dividende et le diviseur pour afficher le détail de la division."
},
"tr": {
"_name": "Bölünen ve Bölen",
"eq_lbl_dividend": "bölünen",
"eq_lbl_divisor": "bölen",
"eq_lbl_quotient": "bölüm",
"eq_lbl_remainder": "kalan",
"insight_exact": "<strong>$1 ÷ $2 = $3</strong> kalansız. Çubuk, <strong>$3</strong> eşit parçaya mükemmel bir şekilde bölünüyor. Bu, <strong>$2</strong> sayısının <strong>$1</strong> sayısına bölündüğünü gösteriyor..",
"label_bar_description": "Çubuk grafiği - her mavi parça = {{PLURAL:$1|$1 birim}}",
"label_choose": "Bölüneni ve böleni seçin.",
"label_dividend": "Bölünen (bölünen sayı)",
"label_divisor": "Bölen (parça boyutu)",
"label_equation": "Bölme denklemi",
"label_repadd": "Tekrarlı toplama",
"repadd_exact": "— tam olarak bölünüyor!",
"repadd_leftover": "kalanlı",
"repadd_less": "$1, $2 değerinden küçük olduğu için bölüm 0'dır ve tam sayı kısmı kalan değerdir.",
"hint": "Bölmenin ayrıntılarını görmek için bölünen ve böleni ayarlayın"
},
"eu": {
"_name": "Zatikizuna eta zatitzailea",
"eq_lbl_dividend": "zatikizuna",
"eq_lbl_divisor": "zatitzailea",
"eq_lbl_quotient": "zatidura",
"eq_lbl_remainder": "hondarra",
"insight_exact": "<strong>$1 ÷ $2 = $3</strong> zenbaki osoa — hondarrik gabe. Barra <strong>$3</strong> zati berdinetan betetzen da. Horrek esan nahi du <strong>$2</strong> <strong>$1</strong>-ren zatitzailea dela.<!--$6-->",
"insight_less": "<strong>$1</strong> zatitzailea baino txikiagoa da (<strong>$2</strong>), beraz ez da zati oso bat sartzen. Balio osoa hondarra da. Hondarra beti zatitzailea baino txikiagoa da.",
"insight_normal": "<strong>$1 ÷ $2 = $3</strong> hondarra <strong>$4</strong>. Kontuan hartu pieza gorria (<strong>$4</strong>) beti zati urdin bat (<strong>$2</strong>) baino motzagoa dela. Hondarra beti zatitzailea baino txikiagoa da. Egiaztatu: <strong>$3 × $2 + $4 = $5 + $4 = $1</strong> ✓",
"label_bar_description": "Barra-ikuspegia — zati urdin bakoitza = {{PLURAL:$1| unitate $1|$1 unitate}}",
"label_choose": "Aukeratu zatikizuna eta zatitzailea",
"label_dividend": "Zatikizuna (zatitu beharreko zenbakia)",
"label_divisor": "Zatitzailea (zatiaren tamaina)",
"label_equation": "Zatiketa ekuazioa",
"label_repadd": "Batuketa errepikatua",
"repadd_exact": "— zehaztasunez zatitzen da!",
"repadd_leftover": "hondarra",
"repadd_less": "$1 $2 baino txikiagoa da, beraz zatidura 0 da eta balio osoa hondarra da.",
"hint": "Doitu zatikizuna eta zatitzailea zatiketaren banaketa ikusteko"
},
"sk": {
"_name": "Delenec a deliteľ",
"eq_lbl_dividend": "delenec",
"eq_lbl_divisor": "deliteľ",
"eq_lbl_quotient": "podiel",
"eq_lbl_remainder": "zvyšok",
"insight_exact": "<strong>$1 ÷ $2 = $3</strong> presne — bez zvyšku. Pruh sa dokonale zaplní na <strong>$3</strong> rovnakých častí. To znamená, že <strong>$2</strong> je deliteľom čísla <strong>$1</strong>.",
"insight_less": "<strong>$1</strong> je menšie ako deliteľ <strong>$2</strong>, takže sa doň zmestí <strong>0</strong> celých častí. Celá hodnota je zvyškom. Zvyšok je vždy menší ako deliteľ.",
"insight_normal": "<strong>$1 ÷ $2 = $3</strong> zvyšok <strong>$4</strong>. Všimnite si, že červený kúsok (<strong>$4</strong>) je vždy kratší ako jedna modrá časť (<strong>$2</strong>). Zvyšok je vždy ostro menší ako deliteľ. Kontrola: <strong>$3 × $2 + $4 = $5 + $4 = $1</strong> ✓",
"label_bar_description": "Pruhové zobrazenie — každá modrá časť = {{PLURAL:$1|$1 jednotka|$1 jednotky|$1 jednotiek}}",
"label_choose": "Vyberte delenec a deliteľ",
"label_dividend": "Delenec (číslo, ktoré sa delí)",
"label_divisor": "Deliteľ (veľkosť časti)",
"label_equation": "Rovnica delenia",
"label_repadd": "Opakované sčítanie",
"repadd_exact": "— delí sa presne!",
"repadd_leftover": "zostáva",
"repadd_less": "$1 je menšie ako $2, takže podiel je 0 a celá hodnota je zvyškom.",
"hint": "Upravte delenec a deliteľ a zobrazí sa rozklad delenia"
},
"skr-arab": {
"_name": "مقسوم تے مقسوم علیہ",
"eq_lbl_dividend": "مقسوم",
"eq_lbl_divisor": "مقسوم علیہ",
"eq_lbl_quotient": "حاصل تقسیم",
"eq_lbl_remainder": "باقی",
"insight_exact": "<strong>$1 ÷ $2 = $3</strong> بالکل — کوئی باقی نئیں۔ پٹی <strong>$3</strong> برابر حصیاں وچ پوری بھر ویندی اے۔ ایں دا مطلب اے جو <strong>$2</strong> <strong>$1</strong> دا مقسوم علیہ اے۔",
"insight_less": "<strong>$1</strong> مقسوم علیہ <strong>$2</strong> توں چھوٹا اے، ایں کیتے <strong>0</strong> پورے حصے بݨدن۔ ساری قیمت باقی اے۔ باقی ہمیشہ مقسوم علیہ توں گھٹ ہوندی اے۔",
"insight_normal": "<strong>$1 ÷ $2 = $3</strong> باقی <strong>$4</strong>۔ غور کرو سرخ ٹکڑا (<strong>$4</strong>) ہمیشہ ہک نیلے ٹکڑے (<strong>$2</strong>) توں چھوٹا ہوندے۔ باقی ہمیشہ مقسوم علیہ توں سختی نال گھٹ ہوندے۔ جانچ: <strong>$3 × $2 + $4 = $5 + $4 = $1</strong> ✓",
"label_bar_description": "پٹی ویکھو — ہر نیلا ٹکڑا = {{PLURAL:$1|$1 اکائی|$1 اکائیاں}}",
"label_choose": "مقسوم تے مقسوم علیہ چݨو",
"label_dividend": "مقسوم (جیندا حصہ کیتا ویندے)",
"label_divisor": "مقسوم علیہ (حصے دا ناپ)",
"label_equation": "تقسیم دا فارمولا",
"label_repadd": "مسلسل جمع",
"repadd_exact": "— پوری طرح وںݙیندی اے!",
"repadd_leftover": "باقی بچیا ہویا",
"repadd_less": "$1 $2 توں گھٹ اے، ایں کیتے حاصل تقسیم 0 اے تے ساری قیمت باقی اے۔",
"hint": "تقسیم دا تفصیل ݙیکھݨ کیتے مقسوم تے مقسوم علیہ کوں ٹھہراؤ"
},
"lb": {
"eq_lbl_dividend": "Dividend",
"eq_lbl_quotient": "Quotient"
},
"ko": {
"_name": "피제수과 제수",
"eq_lbl_dividend": "피제수",
"eq_lbl_divisor": "제수",
"eq_lbl_quotient": "몫",
"eq_lbl_remainder": "나머지",
"insight_exact": "<strong>$1 ÷ $2 = $3</strong> - 정확히 나누어 떨어집니다. 막대는 <strong>$3</strong>씩 정확히 채워집니다. 따라서 <strong>$2</strong>은(는) <strong>$1</strong>의 약수입니다.",
"insight_less": "<strong>$1</strong>은(는) 제수인 <strong>$2</strong> 보다 작으므로, $1은(는) <strong>0</strong>개의 덩어리로 나눌 수 있습니다. 따라서 전체 값이 나머지가 됩니다. 나머지는 항상 제수보다 작습니다.",
"insight_normal": "<strong>$1 ÷ $2 = $3</strong> 나머지 <strong>$4</strong>입니다. 빨간색 조각(<strong>$4</strong>)은 항상 파란색 조각(<strong>$2</strong>)보다 작습니다. 나머지는 항상 제수보다 작습니다. 확인: <strong>$3 × $2 + $4 = $5 + $4 = $1</strong> ✓",
"label_bar_description": "막대형 보기 — 각 파란색 덩어리 = {{PLURAL:$1|$1 단위}}",
"label_choose": "피제수와 제수를 선택",
"label_dividend": "피제수 (나누어지는 수)",
"label_divisor": "제수 (나누는 수)",
"label_equation": "나누기 방정식",
"label_repadd": "반복 덧셈",
"repadd_exact": "— 정확히 나누어집니다!",
"repadd_leftover": "나머지",
"repadd_less": "$1은(는) $2보다 작으므로 몫은 0이고 나머지는 전체 값이 됩니다.",
"hint": "제수와 피제수를 조정하여 나눗셈 결과를 확인하세요"
},
"en": {
"_name": "Dividend and Divisor",
"eq_lbl_dividend": "dividend",
"eq_lbl_divisor": "divisor",
"eq_lbl_quotient": "quotient",
"eq_lbl_remainder": "remainder",
"insight_exact": "<strong>$1 ÷ $2 = $3</strong> exactly — no remainder. The bar fills perfectly into <strong>$3</strong> equal chunk$6. This means <strong>$2</strong> is a divisor of <strong>$1</strong>.",
"insight_less": "<strong>$1</strong> is smaller than the divisor <strong>$2</strong>, so it fits <strong>0</strong> whole chunks. The entire value is the remainder. The remainder is always less than the divisor.",
"insight_normal": "<strong>$1 ÷ $2 = $3</strong> remainder <strong>$4</strong>. Notice the red piece (<strong>$4</strong>) is always shorter than one blue chunk (<strong>$2</strong>). The remainder is always strictly less than the divisor. Check: <strong>$3 × $2 + $4 = $5 + $4 = $1</strong> ✓",
"label_bar_description": "Bar view — each blue chunk = {{PLURAL:$1|$1 unit|$1 units}}",
"label_choose": "Choose dividend and divisor",
"label_dividend": "Dividend (the number being divided)",
"label_divisor": "Divisor (the chunk size)",
"label_equation": "Division equation",
"label_repadd": "Repeated addition",
"repadd_exact": "— divides exactly!",
"repadd_leftover": "left over",
"repadd_less": "$1 is less than $2, so the quotient is 0 and the whole value is the remainder.",
"hint": "Adjust dividend and divisor to see the division breakdown"
},
"it": {
"_name": "Dividendo e divisore",
"eq_lbl_dividend": "dividendo",
"eq_lbl_divisor": "divisore",
"eq_lbl_quotient": "quoziente",
"eq_lbl_remainder": "resto",
"repadd_exact": "— divide esattamente!"
},
"ca": {
"_name": "Dividend i Divisor",
"eq_lbl_dividend": "dividend",
"eq_lbl_divisor": "divisor",
"eq_lbl_quotient": "quocient",
"eq_lbl_remainder": "residu",
"label_choose": "Trieu el dividend i el divisor"
},
"qqq": {
"_name": "Name of the Dividend and Divisor module",
"eq_lbl_dividend": "Label for the dividend in the division equation display",
"eq_lbl_divisor": "Label for the divisor in the division equation display",
"eq_lbl_quotient": "Label for the quotient in the division equation display",
"eq_lbl_remainder": "Label for the remainder in the division equation display",
"insight_exact": "Insight text shown when division is exact (no remainder). Uses HTML. Parameters: $1 = dividend, $2 = divisor, $3 = quotient, $6 = plural suffix for \"chunk\" (empty when Q=1, \"s\" otherwise).",
"insight_less": "Insight text shown when the dividend is smaller than the divisor. Uses HTML. Parameters: $1 = dividend, $2 = divisor.",
"insight_normal": "Insight text shown for normal division with a remainder. Uses HTML. Parameters: $1 = dividend, $2 = divisor, $3 = quotient, $4 = remainder, $5 = quotient × divisor.",
"label_bar_description": "Label for the bar visualisation section showing the chunk size. Uses PLURAL. Parameters: $1 = the divisor (chunk size).",
"label_choose": "Heading for the input section where the user selects dividend and divisor",
"label_dividend": "Label for the dividend input slider, with a parenthetical explanation",
"label_divisor": "Label for the divisor input slider, with a parenthetical explanation",
"label_equation": "Heading for the division equation section",
"label_repadd": "Heading for the repeated addition section",
"repadd_exact": "Text appended to the repeated addition when division is exact",
"repadd_leftover": "Text shown after the leftover value in the repeated addition",
"repadd_less": "Text shown in the repeated addition section when the dividend is less than the divisor. Parameters: $1 = dividend, $2 = divisor.",
"hint": "Instruction text explaining how to use the division sliders"
},
"ga": {
"_name": "Díbhinn agus Roinnteoir",
"eq_lbl_dividend": "díbhinn",
"eq_lbl_divisor": "roinnteoir",
"eq_lbl_quotient": "cóimheas",
"eq_lbl_remainder": "fuílleach",
"insight_exact": "<strong>$1 ÷ $2 = $3</strong> go díreach — gan aon fhuíoll. Líonann an barra go foirfe isteach i <strong>$3</strong> den chuid chomhionann $6. Ciallaíonn sé seo gur roinnteoir de <strong>$1</strong> é <strong>$2</strong>.",
"insight_less": "Tá <strong>$1</strong> níos lú ná an roinnteoir <strong>$2</strong>, mar sin oireann sé do <strong>0</strong> píosa iomlán. Is é an luach iomlán an t-iarmhar. Bíonn an t-iarmhar níos lú ná an roinnteoir i gcónaí.",
"insight_normal": "<strong>$1 ÷ $2 = $3</strong> fuílleach <strong>$4</strong>. Tabhair faoi deara go mbíonn an píosa dearg (<strong>$4</strong>) i gcónaí níos giorra ná smután gorm amháin (<strong>$2</strong>). Bíonn an fuílleach i gcónaí níos lú ná an roinnteoir. Seiceáil: <strong>$3 × $2 + $4 = $5 + $4 = $1</strong> ✓",
"label_bar_description": "Radharc barra — gach píosa gorm = {{PLURAL:$1|$1 aonad|$1 aonaid}}",
"label_choose": "Roghnaigh díbhinn agus roinnteoir",
"label_dividend": "Díbhinn (an uimhir atá á roinnt)",
"label_divisor": "Roinnteoir (méid an phíosa)",
"label_equation": "Cothromóid roinnte",
"label_repadd": "Breisiú arís agus arís eile",
"repadd_exact": "— roinneann go díreach!",
"repadd_leftover": "fágtha",
"repadd_less": "Tá $1 níos lú ná $2, mar sin is é 0 an cóimheas agus is é an luach iomlán an fuíoll.",
"hint": "Coigeartaigh an díbhinn agus an roinnteoir chun miondealú na roinnte a fheiceáil"
},
"es": {
"_name": "Dividendo y divisor",
"eq_lbl_dividend": "dividendo",
"eq_lbl_divisor": "divisor",
"eq_lbl_quotient": "cociente",
"eq_lbl_remainder": "resto",
"insight_exact": "<strong>$1 ÷ $2 = $3</strong> exacto — sin resto. La barra se llena perfectamente en <strong>$3</strong> fragmentos iguales. Esto significa que <strong>$2</strong> es un divisor de <strong>$1</strong>.<!--$6-->",
"insight_less": "<strong>$1</strong> es menor que el divisor <strong>$2</strong>, por lo que cabe en <strong>0</strong> fragmentos enteros. Todo el valor es el resto. El resto es siempre menor que el divisor.",
"insight_normal": "<strong>$1 ÷ $2 = $3</strong> resto <strong>$4</strong>. Nota que la pieza roja (<strong>$4</strong>) siempre es más corta que un fragmento azul (<strong>$2</strong>). El resto siempre es estrictamente menor que el divisor. Comprobación: <strong>$3 × $2 + $4 = $5 + $4 = $1</strong> ✓",
"label_bar_description": "Vista de barras: cada segmento azul = {{PLURAL:$1|$1 unidad|$1 unidades}}",
"label_choose": "Elige el dividendo y el divisor",
"label_dividend": "Dividendo (el número a dividir)",
"label_divisor": "Divisor (el tamaño del fragmento)",
"label_equation": "Ecuación de división",
"label_repadd": "Suma repetida",
"repadd_exact": "— se divide exactamente!",
"repadd_leftover": "sobra",
"repadd_less": "$1 es menor que $2, por lo que el cociente es 0 y el valor completo es el resto.",
"hint": "Ajusta el dividendo y el divisor para ver el desglose de la división"
},
"mk": {
"_name": "Деленик и делител",
"eq_lbl_dividend": "деленик",
"eq_lbl_divisor": "делител",
"eq_lbl_quotient": "количник",
"eq_lbl_remainder": "остаток",
"insight_exact": "<strong>$1 ÷ $2 = точно $3</strong> — без остаток. Столбот совршено се вклопува во <strong>$3</strong> еднакво парче$6. Ова значи дека <strong>$2</strong> е делител на <strong>$1</strong>.",
"insight_less": "<strong>$1</strong> е помал од делителот <strong>$2</strong>, така што вклопува <strong>0</strong> цели парчиња. Целата вредност е остатокот. Остатокот е секогаш помал од делителот.",
"insight_normal": "<strong>$1 ÷ $2 = $3</strong> остаток <strong>$4</strong>. Забележете дека црвеното парче (<strong>$4</strong>) секогаш е пократко од едно сино парче (<strong>$2</strong>). Остатокот е секогаш строго помал од делителот. Проверка: <strong>$3 × $2 + $4 = $5 + $4 = $1</strong> ✓",
"label_bar_description": "Столбен преглед — секое сино парче = {{PLURAL:$1|$1 единица|$1 единици}}",
"label_choose": "Изберете деленик и делител",
"label_dividend": "Деленик (бројот што се дели)",
"label_divisor": "Делител (големината на парчето)",
"label_equation": "Равенка на делење",
"label_repadd": "Повторено собирање",
"repadd_exact": "— точно се дели!",
"repadd_leftover": "останато",
"repadd_less": "$1 е помало од $2, така што количникот изнесува 0, а целата вредност е остатокот.",
"hint": "Прилагодете ги деленикот и делителот за да добиете разложен преглед на делењето"
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
/* ── BUILD HTML ───────────────────────────────────────────────────────────── */
function buildHTML( root ) {
root.innerHTML =
'<div class="hw-div">' +
/* Controls */
'<div class="hw-div-sec" id="hw-div-lbl-choose"></div>' +
'<div class="hw-div-controls">' +
'<div>' +
'<div class="hw-div-ctrl-lbl" id="hw-div-lbl-dividend"></div>' +
'<div class="hw-div-ctrl-row">' +
'<input type="range" id="hw-div-s-dividend" min="1" max="99" value="23" step="1">' +
'<div class="hw-div-badge" id="hw-div-badge-dividend">23</div>' +
'</div>' +
'</div>' +
'<div>' +
'<div class="hw-div-ctrl-lbl" id="hw-div-lbl-divisor"></div>' +
'<div class="hw-div-ctrl-row">' +
'<input type="range" id="hw-div-s-divisor" min="2" max="15" value="5" step="1">' +
'<div class="hw-div-badge hw-div-badge-divisor" id="hw-div-badge-divisor">5</div>' +
'</div>' +
'</div>' +
'</div>' +
/* Equation */
'<div class="hw-div-sec" id="hw-div-lbl-equation"></div>' +
'<div class="hw-div-equation" id="hw-div-equation">' +
'<div class="hw-div-eq-col">' +
'<div class="hw-div-eq-num" id="hw-div-eq-dividend">23</div>' +
'<div class="hw-div-eq-lbl" id="hw-div-eq-lbl-dividend"></div>' +
'</div>' +
'<div class="hw-div-eq-op">=</div>' +
'<div class="hw-div-eq-col">' +
'<div class="hw-div-eq-num hw-div-eq-quotient" id="hw-div-eq-quotient">4</div>' +
'<div class="hw-div-eq-lbl" id="hw-div-eq-lbl-quotient"></div>' +
'</div>' +
'<div class="hw-div-eq-op">×</div>' +
'<div class="hw-div-eq-col">' +
'<div class="hw-div-eq-num hw-div-eq-divisor" id="hw-div-eq-divisor">5</div>' +
'<div class="hw-div-eq-lbl" id="hw-div-eq-lbl-divisor"></div>' +
'</div>' +
'<div class="hw-div-eq-op" id="hw-div-eq-plus">+</div>' +
'<div class="hw-div-eq-col" id="hw-div-eq-rem-col">' +
'<div class="hw-div-eq-num" id="hw-div-eq-remainder">3</div>' +
'<div class="hw-div-eq-lbl" id="hw-div-eq-lbl-remainder"></div>' +
'</div>' +
'</div>' +
/* Bar */
'<div class="hw-div-sec" id="hw-div-lbl-bar-full"></div>' +
'<div class="hw-div-bar-wrap" id="hw-div-bar-wrap"></div>' +
'<div class="hw-div-bar-labels" id="hw-div-bar-labels"></div>' +
/* Repeated addition */
'<div class="hw-div-sec" style="margin-top:1rem" id="hw-div-lbl-repadd"></div>' +
'<div class="hw-div-repadd" id="hw-div-repadd"></div>' +
/* Insight */
'<div class="hw-div-insight" id="hw-div-insight"></div>' +
'<div class="hw-footer">' +
'<a class="hw-footer-icon" href="' + mw.util.getUrl('Wikipedia:Hiruwiki') + '" title="Hiruwiki">' + hiruwiki.getLogoSvg(22) + '</a>' +
'<span class="hw-footer__text">' + (t('hint') === 'hint' || t('hint') === '<hint>' ? (messages.en.hint || '') : t('hint')) + '</span>' +
'</div>' +
'</div>';
}
/* ── INIT STATIC TEXT ─────────────────────────────────────────────────────── */
function initText( root ) {
root.querySelector( '#hw-div-lbl-choose' ).textContent = t( 'label_choose' );
root.querySelector( '#hw-div-lbl-dividend' ).textContent = t( 'label_dividend' );
root.querySelector( '#hw-div-lbl-divisor' ).textContent = t( 'label_divisor' );
root.querySelector( '#hw-div-lbl-equation' ).textContent = t( 'label_equation' );
root.querySelector( '#hw-div-lbl-repadd' ).textContent = t( 'label_repadd' );
root.querySelector( '#hw-div-eq-lbl-dividend' ).textContent = t( 'eq_lbl_dividend' );
root.querySelector( '#hw-div-eq-lbl-quotient' ).textContent = t( 'eq_lbl_quotient' );
root.querySelector( '#hw-div-eq-lbl-divisor' ).textContent = t( 'eq_lbl_divisor' );
root.querySelector( '#hw-div-eq-lbl-remainder').textContent = t( 'eq_lbl_remainder');
}
/* ── UPDATE ───────────────────────────────────────────────────────────────── */
function update( root ) {
var N = parseInt( root.querySelector( '#hw-div-s-dividend' ).value, 10 );
var D = parseInt( root.querySelector( '#hw-div-s-divisor' ).value, 10 );
var Q = Math.floor( N / D );
var R = N % D;
var exact = R === 0;
var QD = Q * D;
var plural = Q === 1 ? '' : 's';
/* Badges */
root.querySelector( '#hw-div-badge-dividend' ).textContent = N;
root.querySelector( '#hw-div-badge-divisor' ).textContent = D;
root.querySelector( '#hw-div-lbl-bar-full' ).textContent = t( 'label_bar_description', [D] );
/* Equation numbers */
root.querySelector( '#hw-div-eq-dividend' ).textContent = N;
root.querySelector( '#hw-div-eq-quotient' ).textContent = Q;
root.querySelector( '#hw-div-eq-divisor' ).textContent = D;
root.querySelector( '#hw-div-eq-remainder' ).textContent = R;
root.querySelector( '#hw-div-eq-remainder' ).style.color = exact ? '#639922' : '#C0392B';
/* Dim the "+ remainder" part when exact */
root.querySelector( '#hw-div-eq-plus' ).style.opacity = exact ? '0.3' : '1';
root.querySelector( '#hw-div-eq-rem-col' ).style.opacity = exact ? '0.3' : '1';
/* ── Bar ──────────────────────────────────────────────────────── */
var barWrap = root.querySelector( '#hw-div-bar-wrap' );
barWrap.innerHTML = '';
for ( var i = 0; i < Q; i++ ) {
if ( i > 0 ) {
var gap = document.createElement( 'div' );
gap.className = 'hw-div-gap';
barWrap.appendChild( gap );
}
var chunk = document.createElement( 'div' );
chunk.className = 'hw-div-chunk';
/* proportional flex-basis */
chunk.style.flexBasis = ( D / N * 100 ) + '%';
/* round corners */
var rLeft = i === 0 ? '8px' : '0';
var rRight = ( i === Q - 1 && exact ) ? '8px' : '0';
chunk.style.borderRadius = rLeft + ' ' + rRight + ' ' + rRight + ' ' + rLeft;
barWrap.appendChild( chunk );
}
if ( R > 0 ) {
if ( Q > 0 ) {
var gap2 = document.createElement( 'div' );
gap2.className = 'hw-div-gap';
barWrap.appendChild( gap2 );
}
var rem = document.createElement( 'div' );
rem.className = 'hw-div-remainder';
rem.style.flexBasis = ( R / N * 100 ) + '%';
rem.style.borderRadius = Q === 0 ? '8px' : '0 8px 8px 0';
barWrap.appendChild( rem );
}
/* ── Bar labels ───────────────────────────────────────────────── */
var labWrap = root.querySelector( '#hw-div-bar-labels' );
labWrap.innerHTML = '';
var positions = [ 0 ];
for ( var j = 1; j <= Q; j++ ) positions.push( D * j );
if ( R > 0 ) positions.push( N );
/* deduplicate */
var seen = {};
positions.forEach( function ( v ) {
if ( seen[ v ] || v > N ) return;
seen[ v ] = true;
var lbl = document.createElement( 'div' );
lbl.className = 'hw-div-bar-lbl';
lbl.textContent = v;
lbl.style.left = ( v / N * 100 ) + '%';
labWrap.appendChild( lbl );
} );
/* ── Repeated addition ────────────────────────────────────────── */
var repAdd = root.querySelector( '#hw-div-repadd' );
repAdd.innerHTML = '';
if ( Q === 0 ) {
repAdd.innerHTML = t( 'repadd_less', [ N, D ] );
} else {
var parts = [];
for ( var k = 0; k < Q; k++ ) {
parts.push( '<span class="hw-div-chunk-num">' + D + '</span>' );
}
var str = parts.join( ' + ' ) + ' = <span class="hw-div-chunk-num">' + QD + '</span>';
if ( R > 0 ) {
str += ' + <span class="hw-div-rem-num">' + R + '</span> ' + t( 'repadd_leftover' );
} else {
str += ' <span class="hw-div-exact">' + t( 'repadd_exact' ) + '</span>';
}
repAdd.innerHTML = str;
}
/* ── Insight ──────────────────────────────────────────────────── */
var args = [ N, D, Q, R, QD, plural ];
var ins = root.querySelector( '#hw-div-insight' );
if ( exact ) {
ins.innerHTML = t( 'insight_exact', args );
} else if ( Q === 0 ) {
ins.innerHTML = t( 'insight_less', args );
} else {
ins.innerHTML = t( 'insight_normal', args );
}
}
/* ── MOUNT ────────────────────────────────────────────────────────────────── */
function mount( root ) {
buildHTML( root );
initText( root );
var sDividend = root.querySelector( '#hw-div-s-dividend' );
var sDivisor = root.querySelector( '#hw-div-s-divisor' );
sDividend.addEventListener( 'input', function () { update( root ); } );
sDivisor.addEventListener( 'input', function () { update( root ); } );
update( root );
}
/* ── SCAN & INIT ──────────────────────────────────────────────────────────── */
function init() {
document.querySelectorAll( '.hiruwiki[data-module="dividend"]' ).forEach( function ( el ) {
if ( !el.dataset.hwDivInit ) {
el.dataset.hwDivInit = '1';
mount( el );
}
} );
}
if ( document.readyState === 'loading' ) {
document.addEventListener( 'DOMContentLoaded', init );
} else {
init();
}
} )();
dpdio9z7f3bjcoe2f37qmfz95eqevsa
MediaWiki:Hiruwiki/modules/euler-line.js
8
179486
781424
770213
2026-10-28T10:39:02Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781424
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/* =========================================================
* Hiruwiki module: euler-line
* Visualisation of the Euler line: circumcenter (O),
* centroid (G) and orthocentre (H) of a triangle.
* =========================================================
* i18n: add a new key to the `messages` object below and
* a matching entry for every language code you need.
* Fall back to 'en' when the wiki language is absent.
* ========================================================= */
( function () {
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"nl": {
"_name": "Rechte van Euler",
"triangle": "Driehoek",
"eulerLine": "Rechte van Euler",
"hint": "Sleep een hoekpunt · Schaal: 1 cm = 40 px",
"sideA": "Zijde a (BC)",
"sideB": "Zijde b (CA)",
"sideC": "Zijde c (AB)",
"perimeter": "Omtrek",
"circumcenter": "Middelpunt omgeschreven cirkel O",
"centroid": "Zwaartepunt G",
"orthocentre": "Hoogtepunt H",
"scaleUnit": "cm"
},
"fr": {
"_name": "Droite d'Euler",
"triangle": "Triangle",
"eulerLine": "Droite d'Euler",
"hint": "Faites glisser un sommet · Échelle : 1 cm = 40 px",
"sideA": "Côté a (BC)",
"sideB": "Côté b (CA)",
"sideC": "Côté c (AB)",
"perimeter": "Périmètre",
"circumcenter": "Circoncentre O",
"centroid": "Centre de gravité G",
"orthocentre": "Orthocentre H",
"scaleUnit": "cm"
},
"tr": {
"_name": "Euler Doğrusu",
"triangle": "Üçgen",
"eulerLine": "Euler Doğrusu",
"hint": "Herhangi bir köşeyi sürükleyin · Ölçek: 1 cm = 40 piksel",
"sideA": "a kenarı (BC)",
"sideB": "b kenarı (CA)",
"sideC": "c kenarı (AB)",
"perimeter": "Çevre",
"circumcenter": "Çevre merkez O",
"centroid": "Merkez G",
"orthocentre": "Yükseklik merkezi H",
"scaleUnit": "cm"
},
"eu": {
"_name": "Eulerren zuzenea",
"triangle": "Triangelua",
"eulerLine": "Eulerren zuzenea",
"hint": "Erpinak mugitu ditzakezu · Eskala: 1 cm = 40 px",
"sideA": "a aldea (BC)",
"sideB": "b aldea (CA)",
"sideC": "c aldea (AB)",
"perimeter": "Perimetroa",
"circumcenter": "Zirkunzentroa O",
"centroid": "Zentroidea G",
"orthocentre": "Ortozentro H",
"scaleUnit": "cm"
},
"sk": {
"_name": "Eulerova priamka",
"triangle": "Trojuholník",
"eulerLine": "Eulerova priamka",
"hint": "Potiahnite ľubovoľný vrchol · Mierka: 1 cm = 40 px",
"sideA": "Strana a (BC)",
"sideB": "Strana b (CA)",
"sideC": "Strana c (AB)",
"perimeter": "Obvod",
"circumcenter": "Stred opísanej kružnice O",
"centroid": "Ťažisko G",
"orthocentre": "Ortocentrum H",
"scaleUnit": "cm"
},
"skr-arab": {
"_name": "آئلر لکیر",
"triangle": "مثلث",
"eulerLine": "آئلر لکیر",
"hint": "کوئی وی سرا گھسیٹو · پیمانہ: 1 cm = 40 px",
"sideA": "ضلع a (BC)",
"sideB": "ضلع b (CA)",
"sideC": "ضلع c (AB)",
"perimeter": "گھیر",
"circumcenter": "گرد مرکز O",
"centroid": "ثقل مرکز G",
"orthocentre": "قائم مرکز H",
"scaleUnit": "cm"
},
"lb": {
"triangle": "Dräieck",
"sideA": "Säit a (BC)",
"sideB": "Säit b (CA)",
"sideC": "Säit c (AB)",
"scaleUnit": "cm"
},
"ko": {
"_name": "오일러 선",
"triangle": "삼각형",
"eulerLine": "오일러 선",
"hint": "꼭짓점을 드래그하세요 · 축척: 1 cm = 40 px",
"sideA": "변 a (BC)",
"sideB": "변 b (CA)",
"sideC": "변 c (AB)",
"perimeter": "둘레",
"circumcenter": "외심 O",
"centroid": "무게중심 G",
"orthocentre": "수심 H",
"scaleUnit": "cm"
},
"en": {
"_name": "Euler Line",
"triangle": "Triangle",
"eulerLine": "Euler Line",
"hint": "Drag any vertex · Scale: 1 cm = 40 px",
"sideA": "Side a (BC)",
"sideB": "Side b (CA)",
"sideC": "Side c (AB)",
"perimeter": "Perimeter",
"circumcenter": "Circumcenter O",
"centroid": "Centroid G",
"orthocentre": "Orthocentre H",
"scaleUnit": "cm"
},
"qqq": {
"_name": "Name of the Euler Line module",
"triangle": "Section heading for the triangle properties card",
"eulerLine": "Section heading for the Euler line properties card",
"hint": "Instruction text shown in the footer. Includes scale information.",
"sideA": "Label for side a (BC) of the triangle",
"sideB": "Label for side b (CA) of the triangle",
"sideC": "Label for side c (AB) of the triangle",
"perimeter": "Label for the triangle perimeter",
"circumcenter": "Label for the circumcenter point O",
"centroid": "Label for the centroid point G",
"orthocentre": "Label for the orthocentre point H",
"scaleUnit": "Unit abbreviation used for measurements (centimetres)"
},
"ga": {
"_name": "Líne Euler",
"triangle": "Triantán",
"eulerLine": "Líne Euler",
"hint": "Tarraing aon bhuaicphointe · Scála: 1 cm = 40 px",
"sideA": "Taobh a (BC)",
"sideB": "Taobh b (CA)",
"sideC": "Taobh c (AB)",
"perimeter": "Imlíne",
"circumcenter": "Lárphointe imlíne O",
"centroid": "Lárphointe G",
"orthocentre": "Lárphointe ortaipéideach H",
"scaleUnit": "cm"
},
"es": {
"_name": "Recta de Euler",
"triangle": "Triángulo",
"eulerLine": "Recta de Euler",
"hint": "Arrastra un vértice · Escala: 1 cm = 40 px",
"sideA": "Lado a (BC)",
"sideB": "Lado b (CA)",
"sideC": "Lado c (AB)",
"perimeter": "Perímetro",
"circumcenter": "Circuncentro O",
"centroid": "Baricentro G",
"orthocentre": "Ortocentro H",
"scaleUnit": "cm"
},
"mk": {
"_name": "Ојлерова права",
"triangle": "Триаголник",
"eulerLine": "Ојлерова права",
"hint": "Повлечете некое теме · Размер: 1 см = 40 п",
"sideA": "Страна a (BC)",
"sideB": "Страна b (CA)",
"sideC": "Страна c (AB)",
"perimeter": "Обем",
"circumcenter": "Центар на опишана кружница O",
"centroid": "Тежиште G",
"orthocentre": "Ортоцентар H",
"scaleUnit": "см"
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
;
/* ── Constants ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var SCALE = 40; /* pixels per centimetre */
var RULER_H = 36; /* ruler strip height (px) */
var H_PX = 540; /* canvas height — [1] increased ~100 px from 440 */
var DPR = window.devicePixelRatio || 1;
/* ── Geometry helpers ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
function dist( a, b ) {
return Math.hypot( b.x - a.x, b.y - a.y );
}
function midpoint( A, B ) {
return { x: ( A.x + B.x ) / 2, y: ( A.y + B.y ) / 2 };
}
function footPerp( P, V1, V2 ) {
var dx = V2.x - V1.x, dy = V2.y - V1.y;
var t = ( ( P.x - V1.x ) * dx + ( P.y - V1.y ) * dy ) / ( dx * dx + dy * dy );
return { x: V1.x + t * dx, y: V1.y + t * dy };
}
/* Circumcenter — standard determinant formula. */
function circumcenter( A, B, C ) {
var ax = A.x, ay = A.y, bx = B.x, by = B.y, cx = C.x, cy = C.y;
var D = 2 * ( ax * ( by - cy ) + bx * ( cy - ay ) + cx * ( ay - by ) );
if ( Math.abs( D ) < 1e-9 ) {
return { x: ( ax + bx + cx ) / 3, y: ( ay + by + cy ) / 3 };
}
var a2 = ax * ax + ay * ay, b2 = bx * bx + by * by, c2 = cx * cx + cy * cy;
return {
x: ( a2 * ( by - cy ) + b2 * ( cy - ay ) + c2 * ( ay - by ) ) / D,
y: ( a2 * ( cx - bx ) + b2 * ( ax - cx ) + c2 * ( bx - ax ) ) / D
};
}
/* Centroid — arithmetic mean of the three vertices. */
function centroid( A, B, C ) {
return { x: ( A.x + B.x + C.x ) / 3, y: ( A.y + B.y + C.y ) / 3 };
}
/* Orthocentre — intersection of two altitudes. */
function orthocentre( A, B, C ) {
var n1x = -( C.y - B.y ), n1y = C.x - B.x;
var n2x = -( C.y - A.y ), n2y = C.x - A.x;
var det = n1x * n2y - n1y * n2x;
if ( Math.abs( det ) < 1e-9 ) {
return { x: ( A.x + B.x + C.x ) / 3, y: ( A.y + B.y + C.y ) / 3 };
}
var t = ( ( B.x - A.x ) * n2y - ( B.y - A.y ) * n2x ) / det;
return { x: A.x + t * n1x, y: A.y + t * n1y };
}
function angleDeg( V, A, B ) {
var ax = A.x - V.x, ay = A.y - V.y;
var bx = B.x - V.x, by = B.y - V.y;
var cos = ( ax * bx + ay * by ) /
( Math.hypot( ax, ay ) * Math.hypot( bx, by ) );
return Math.acos( Math.max( -1, Math.min( 1, cos ) ) ) * 180 / Math.PI;
}
function labelOffset( V, others, mag ) {
var dx = 0, dy = 0;
others.forEach( function ( O ) { dx += V.x - O.x; dy += V.y - O.y; } );
var l = Math.hypot( dx, dy ) || 1;
return { x: V.x + dx / l * mag, y: V.y + dy / l * mag };
}
/*
* Extend a line through two points P1→P2 to the canvas boundary.
* Returns { x0, y0, x1, y1 } or null when the line is degenerate.
*/
function extendLine( P1, P2, W, H ) {
var dx = P2.x - P1.x, dy = P2.y - P1.y;
if ( Math.hypot( dx, dy ) < 1e-6 ) { return null; }
var ts = [];
if ( Math.abs( dx ) > 1e-9 ) { ts.push( -P1.x / dx ); ts.push( ( W - P1.x ) / dx ); }
if ( Math.abs( dy ) > 1e-9 ) { ts.push( ( RULER_H - P1.y ) / dy ); ts.push( ( H - P1.y ) / dy ); }
ts = ts.filter( function ( t ) {
var px = P1.x + t * dx, py = P1.y + t * dy;
return px >= -5 && px <= W + 5 && py >= RULER_H - 5 && py <= H + 5;
} );
if ( ts.length < 2 ) { return null; }
ts.sort( function ( a, b ) { return a - b; } );
var t0 = ts[ 0 ], t1 = ts[ ts.length - 1 ];
return { x0: P1.x + t0 * dx, y0: P1.y + t0 * dy, x1: P1.x + t1 * dx, y1: P1.y + t1 * dy };
}
function toCm( px ) { return px / SCALE; }
function fmt( n ) { return n.toFixed( 2 ); }
/* ── Build widget ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
function buildWidget( container ) {
var lang = ( window.mw && mw.config.get( 'wgUserLanguage' ) ) || 'en';
lang = lang.split( '-' )[ 0 ];
if ( !messages[ lang ] ) { lang = 'en'; }
var msg = messages[ lang ];
/* -- HTML skeleton -- */
container.innerHTML =
'<div class="hw-euler">' +
'<canvas class="hw-canvas"></canvas>' +
'<div class="hw-params">' +
'<div class="hw-card">' +
'<div class="hw-card-title">' +
'<span class="hw-badge hw-badge-t">' + msg.triangle + '</span>' +
'</div>' +
'<div class="hw-row"><span>' + msg.sideA + '</span><span id="hw-sa"></span></div>' +
'<div class="hw-row"><span>' + msg.sideB + '</span><span id="hw-sb"></span></div>' +
'<div class="hw-row"><span>' + msg.sideC + '</span><span id="hw-sc"></span></div>' +
'<div class="hw-row"><span>' + msg.perimeter + '</span><span id="hw-per"></span></div>' +
'</div>' +
'<div class="hw-card">' +
'<div class="hw-card-title">' +
'<span class="hw-badge hw-badge-euler">' + msg.eulerLine + '</span>' +
'</div>' +
'<div class="hw-row"><span>' + msg.circumcenter + '</span><span id="hw-oc"></span></div>' +
'<div class="hw-row"><span>' + msg.centroid + '</span><span id="hw-gc"></span></div>' +
'<div class="hw-row"><span>' + msg.orthocentre + '</span><span id="hw-hc"></span></div>' +
'</div>' +
'</div>' +
'</div>';
/* -- Footer -- */
var footer = document.createElement( 'div' );
footer.className = 'hw-footer';
var fLogo = document.createElement( 'a' );
fLogo.className = 'hw-footer-icon';
fLogo.href = ( window.mw && mw.util.getUrl( 'Wikipedia:Hiruwiki' ) ) || '#';
fLogo.title = 'Hiruwiki';
if ( window.hiruwiki && window.hiruwiki.getLogoSvg ) {
fLogo.insertAdjacentHTML( 'beforeend', hiruwiki.getLogoSvg( 22 ) );
}
var fText = document.createElement( 'span' );
fText.className = 'hw-footer__text';
fText.textContent = msg.hint;
footer.appendChild( fLogo );
footer.appendChild( fText );
container.appendChild( footer );
var canvas = container.querySelector( '.hw-canvas' );
var ctx = canvas.getContext( '2d' );
var W, H, verts, dragging = -1;
function $( id ) { return container.querySelector( '#' + id ); }
/* -- vertex initialisation -----------------------------------------------
* [3] Equilateral triangle centred in the drawable area below the ruler.
* All three sides are equal by construction (120° rotation symmetry).
* ---------------------------------------------------------------------- */
function initVerts() {
var cx = W / 2;
var cy = RULER_H + ( H - RULER_H ) / 2; /* centre of drawable area */
var r = Math.min( W / 2, ( H - RULER_H ) / 2 ) * 0.62;
verts = [
{ x: cx + r * Math.cos( -Math.PI / 2 ), y: cy + r * Math.sin( -Math.PI / 2 ) },
{ x: cx + r * Math.cos( -Math.PI / 2 + 2 * Math.PI / 3 ), y: cy + r * Math.sin( -Math.PI / 2 + 2 * Math.PI / 3 ) },
{ x: cx + r * Math.cos( -Math.PI / 2 + 4 * Math.PI / 3 ), y: cy + r * Math.sin( -Math.PI / 2 + 4 * Math.PI / 3 ) }
];
}
/* -- colour palette -- */
function isDark() {
var root = document.documentElement;
if ( root.classList.contains( 'skin-theme-clientpref-night' ) ||
root.classList.contains( 'client-dark-mode' ) ||
document.body.classList.contains( 'mw-dark-mode' ) ) {
return true;
}
return false;
}
function palette() {
var dark = isDark();
return {
dark: dark,
tri: dark ? '#AFA9EC' : '#534AB7', /* purple — triangle / vertices */
circ: dark ? '#5DCAA5' : '#0F6E56', /* teal — circumcenter O */
cen: dark ? '#FAC775' : '#BA7517', /* amber — centroid G */
orth: dark ? '#F28BAD' : '#B5294E', /* rose — orthocentre H */
euler: dark ? '#F28BAD' : '#B5294E', /* rose — Euler line itself */
circFill: dark ? 'rgba(93,202,165,.05)' : 'rgba(15,110,86,.04)',
circStroke: dark ? 'rgba(93,202,165,.30)' : 'rgba(15,110,86,.25)',
/* [2] Two altitude colours: solid inner segment, faint dashed extension */
altSolid: dark ? 'rgba(250,199,117,.65)' : 'rgba(186,117,23,.60)',
altExt: dark ? 'rgba(250,199,117,.28)' : 'rgba(186,117,23,.25)',
medLine: dark ? 'rgba(93,202,165,.40)' : 'rgba(15,110,86,.35)',
text: dark ? '#E8E6DC' : '#1a1a18',
ruleBg: dark ? 'rgba(28,28,26,.90)' : 'rgba(241,239,232,.85)',
ruleTick: dark ? '#444441' : '#B4B2A9',
ruleText: dark ? '#888780' : '#888780',
gridMm: 'rgba(128,128,128,.04)',
gridCm: 'rgba(128,128,128,.08)',
dotBg: dark ? '#1e1e1c' : '#ffffff'
};
}
/* -- ruler -- */
function drawRuler( p ) {
ctx.fillStyle = p.ruleBg;
ctx.fillRect( 0, 0, W, RULER_H );
ctx.strokeStyle = p.ruleTick;
ctx.lineWidth = 0.5;
ctx.beginPath(); ctx.moveTo( 0, RULER_H ); ctx.lineTo( W, RULER_H ); ctx.stroke();
var maxCm = Math.ceil( W / SCALE ) + 1;
ctx.font = '10px sans-serif';
ctx.textBaseline = 'bottom';
for ( var cm = 0; cm <= maxCm; cm++ ) {
var px = cm * SCALE;
if ( px > W + SCALE ) { break; }
ctx.strokeStyle = p.ruleTick; ctx.lineWidth = 0.8;
ctx.beginPath(); ctx.moveTo( px, RULER_H - 10 ); ctx.lineTo( px, RULER_H ); ctx.stroke();
if ( cm > 0 ) {
ctx.fillStyle = p.ruleText; ctx.textAlign = 'center';
ctx.fillText( cm, px, RULER_H - 12 );
}
for ( var mm = 1; mm < 10; mm++ ) {
var mpx = px + mm * SCALE / 10;
if ( mpx > W ) { break; }
ctx.strokeStyle = p.ruleTick; ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo( mpx, RULER_H - ( mm === 5 ? 7 : 4 ) );
ctx.lineTo( mpx, RULER_H );
ctx.stroke();
}
}
ctx.fillStyle = p.ruleText;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.font = '500 10px sans-serif';
ctx.fillText( msg.scaleUnit, 4, RULER_H / 2 );
}
/* -- grid -- */
function drawGrid( p ) {
ctx.lineWidth = 0.5;
for ( var cm = 0; cm * SCALE < W + SCALE; cm++ ) {
for ( var mm = 1; mm < 10; mm++ ) {
ctx.strokeStyle = p.gridMm;
ctx.beginPath();
ctx.moveTo( cm * SCALE + mm * SCALE / 10, RULER_H );
ctx.lineTo( cm * SCALE + mm * SCALE / 10, H );
ctx.stroke();
}
ctx.strokeStyle = p.gridCm;
ctx.beginPath();
ctx.moveTo( cm * SCALE, RULER_H ); ctx.lineTo( cm * SCALE, H ); ctx.stroke();
}
for ( var row = 0; RULER_H + row * SCALE < H; row++ ) {
ctx.strokeStyle = p.gridCm;
ctx.beginPath();
ctx.moveTo( 0, RULER_H + row * SCALE ); ctx.lineTo( W, RULER_H + row * SCALE ); ctx.stroke();
}
}
/* -- right-angle mark at altitude foot -- */
function drawRightAngle( T, fromV, V1, V2, color ) {
var sdx = V2.x - V1.x, sdy = V2.y - V1.y;
var sl = Math.hypot( sdx, sdy );
if ( sl < 1e-6 ) { return; }
var su = { x: sdx / sl, y: sdy / sl };
var rdx = fromV.x - T.x, rdy = fromV.y - T.y;
var rl = Math.hypot( rdx, rdy );
if ( rl < 1e-6 ) { return; }
var ru = { x: rdx / rl, y: rdy / rl };
var SZ = 7;
ctx.strokeStyle = color; ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo( T.x + su.x * SZ, T.y + su.y * SZ );
ctx.lineTo( T.x + su.x * SZ + ru.x * SZ, T.y + su.y * SZ + ru.y * SZ );
ctx.lineTo( T.x + ru.x * SZ, T.y + ru.y * SZ );
ctx.stroke();
}
/* -- main draw -- */
function draw() {
if ( !verts ) { return; }
ctx.clearRect( 0, 0, W, H );
var p = palette();
drawGrid( p );
drawRuler( p );
var A = verts[ 0 ], B = verts[ 1 ], C = verts[ 2 ];
var a = dist( B, C ), b = dist( C, A ), c = dist( A, B );
/* The three special points */
var O = circumcenter( A, B, C );
var G = centroid( A, B, C );
var Hpt = orthocentre( A, B, C );
/* Circumcircle radius */
var R = dist( O, A );
/* Medians */
var Ma = midpoint( B, C ), Mb = midpoint( C, A ), Mc = midpoint( A, B );
/* Altitude feet */
var Ha = footPerp( A, B, C );
var Hb = footPerp( B, C, A );
var Hc = footPerp( C, A, B );
var cx3 = ( A.x + B.x + C.x ) / 3, cy3 = ( A.y + B.y + C.y ) / 3;
/* Euler line extended to canvas boundary */
var eulerLine = extendLine( O, Hpt, W, H );
/* [2] Full altitude lines — each is the infinite line through the vertex
* and its foot, clipped to the canvas so H is always reached even
* when outside the triangle. */
var altA = extendLine( A, Ha, W, H );
var altB = extendLine( B, Hb, W, H );
var altC = extendLine( C, Hc, W, H );
/* ── 1. circumcircle ─────────────────────────────────────── */
ctx.save();
ctx.beginPath(); ctx.arc( O.x, O.y, R, 0, Math.PI * 2 );
ctx.strokeStyle = p.circStroke; ctx.lineWidth = 1.2; ctx.stroke();
ctx.fillStyle = p.circFill; ctx.fill();
ctx.restore();
/* ── 2. altitude lines ───────────────────────────────────────
* Pass 1: full extended line, faint + dashed (shows the part
* beyond the foot that reaches H outside the triangle).
* Pass 2: overdraw the vertex→foot segment solidly and opaquely
* so the interior altitude is visually primary. */
ctx.save();
ctx.strokeStyle = p.altExt; ctx.lineWidth = 1.2; ctx.setLineDash( [ 5, 5 ] );
[ altA, altB, altC ].forEach( function ( ln ) {
if ( !ln ) { return; }
ctx.beginPath(); ctx.moveTo( ln.x0, ln.y0 ); ctx.lineTo( ln.x1, ln.y1 ); ctx.stroke();
} );
ctx.setLineDash( [] );
ctx.strokeStyle = p.altSolid; ctx.lineWidth = 1.5;
[ [ A, Ha ], [ B, Hb ], [ C, Hc ] ].forEach( function ( pr ) {
ctx.beginPath(); ctx.moveTo( pr[0].x, pr[0].y ); ctx.lineTo( pr[1].x, pr[1].y ); ctx.stroke();
} );
ctx.restore();
/* ── 3. medians (vertex → midpoint), dashed, teal ───────── */
ctx.save();
ctx.strokeStyle = p.medLine; ctx.lineWidth = 1.2; ctx.setLineDash( [ 6, 5 ] );
[ [ A, Ma ], [ B, Mb ], [ C, Mc ] ].forEach( function ( pr ) {
ctx.beginPath(); ctx.moveTo( pr[0].x, pr[0].y ); ctx.lineTo( pr[1].x, pr[1].y ); ctx.stroke();
} );
ctx.setLineDash( [] ); ctx.restore();
/* ── 4. triangle fill + stroke ───────────────────────────── */
ctx.save();
ctx.beginPath();
ctx.moveTo( A.x, A.y ); ctx.lineTo( B.x, B.y ); ctx.lineTo( C.x, C.y ); ctx.closePath();
ctx.fillStyle = p.dark ? 'rgba(175,169,236,.07)' : 'rgba(83,74,183,.06)';
ctx.fill();
ctx.strokeStyle = p.tri; ctx.lineWidth = 2; ctx.stroke();
ctx.restore();
/* ── 5. Euler line — bold, rose, extended ────────────────── */
if ( eulerLine ) {
ctx.save();
ctx.strokeStyle = p.euler; ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo( eulerLine.x0, eulerLine.y0 );
ctx.lineTo( eulerLine.x1, eulerLine.y1 );
ctx.stroke();
ctx.restore();
}
/* ── 6. right-angle marks at altitude feet ───────────────── */
ctx.save();
drawRightAngle( Ha, A, B, C, p.altSolid );
drawRightAngle( Hb, B, C, A, p.altSolid );
drawRightAngle( Hc, C, A, B, p.altSolid );
ctx.restore();
/* ── 7. altitude foot dots (amber) ───────────────────────── */
[ Ha, Hb, Hc ].forEach( function ( F ) {
ctx.save();
ctx.beginPath(); ctx.arc( F.x, F.y, 4, 0, Math.PI * 2 );
ctx.fillStyle = p.cen; ctx.fill();
ctx.restore();
} );
/* ── 8. midpoint dots (teal) ─────────────────────────────── */
[ Ma, Mb, Mc ].forEach( function ( M ) {
ctx.save();
ctx.beginPath(); ctx.arc( M.x, M.y, 4, 0, Math.PI * 2 );
ctx.fillStyle = p.circ; ctx.fill();
ctx.restore();
} );
/* ── 9. O dot (teal) ─────────────────────────────────────── */
ctx.save();
ctx.beginPath(); ctx.arc( O.x, O.y, 8, 0, Math.PI * 2 );
ctx.fillStyle = p.circ; ctx.fill();
ctx.strokeStyle = p.dotBg; ctx.lineWidth = 2; ctx.stroke();
ctx.restore();
/* ── 10. G dot (amber) ───────────────────────────────────── */
ctx.save();
ctx.beginPath(); ctx.arc( G.x, G.y, 8, 0, Math.PI * 2 );
ctx.fillStyle = p.cen; ctx.fill();
ctx.strokeStyle = p.dotBg; ctx.lineWidth = 2; ctx.stroke();
ctx.restore();
/* ── 11. H dot (rose) ────────────────────────────────────── */
ctx.save();
ctx.beginPath(); ctx.arc( Hpt.x, Hpt.y, 8, 0, Math.PI * 2 );
ctx.fillStyle = p.orth; ctx.fill();
ctx.strokeStyle = p.dotBg; ctx.lineWidth = 2; ctx.stroke();
ctx.restore();
/* ── 12. vertex dots (purple) ────────────────────────────── */
verts.forEach( function ( V, i ) {
ctx.save();
ctx.beginPath(); ctx.arc( V.x, V.y, dragging === i ? 8 : 6.5, 0, Math.PI * 2 );
ctx.fillStyle = p.tri; ctx.fill();
ctx.strokeStyle = p.dotBg; ctx.lineWidth = 2; ctx.stroke();
ctx.restore();
} );
/* ── 13. text labels ─────────────────────────────────────── */
ctx.save();
ctx.textBaseline = 'middle';
/* vertex names */
ctx.font = '700 17px sans-serif';
[ A, B, C ].forEach( function ( V, i ) {
var lbl = labelOffset( V, verts.filter( function ( _, j ) { return j !== i; } ), 26 );
ctx.fillStyle = p.tri; ctx.textAlign = 'center';
ctx.fillText( [ 'A', 'B', 'C' ][ i ], lbl.x, lbl.y );
} );
/* O, G, H labels */
ctx.font = '700 15px sans-serif'; ctx.textAlign = 'center';
ctx.fillStyle = p.circ; ctx.fillText( 'O', O.x + 14, O.y - 14 );
ctx.fillStyle = p.cen; ctx.fillText( 'G', G.x + 14, G.y - 14 );
ctx.fillStyle = p.orth; ctx.fillText( 'H', Hpt.x + 14, Hpt.y - 14 );
/* side length labels */
ctx.font = '500 13px sans-serif';
[
{ p: { x: ( B.x + C.x ) / 2, y: ( B.y + C.y ) / 2 }, lbl: 'a = ' + fmt( toCm( a ) ) + ' ' + msg.scaleUnit },
{ p: { x: ( C.x + A.x ) / 2, y: ( C.y + A.y ) / 2 }, lbl: 'b = ' + fmt( toCm( b ) ) + ' ' + msg.scaleUnit },
{ p: { x: ( A.x + B.x ) / 2, y: ( A.y + B.y ) / 2 }, lbl: 'c = ' + fmt( toCm( c ) ) + ' ' + msg.scaleUnit }
].forEach( function ( item ) {
var dx2 = item.p.x - cx3, dy2 = item.p.y - cy3;
var l2 = Math.hypot( dx2, dy2 ) || 1;
ctx.fillStyle = p.text; ctx.textAlign = 'center';
ctx.fillText( item.lbl, item.p.x + dx2 / l2 * 20, item.p.y + dy2 / l2 * 20 );
} );
ctx.restore();
/* -- stats panel -- */
$( 'hw-sa' ).textContent = fmt( toCm( a ) ) + ' ' + msg.scaleUnit;
$( 'hw-sb' ).textContent = fmt( toCm( b ) ) + ' ' + msg.scaleUnit;
$( 'hw-sc' ).textContent = fmt( toCm( c ) ) + ' ' + msg.scaleUnit;
$( 'hw-per' ).textContent = fmt( toCm( a + b + c ) ) + ' ' + msg.scaleUnit;
$( 'hw-oc' ).textContent = '(' + fmt( toCm( O.x ) ) + ', ' + fmt( toCm( O.y - RULER_H ) ) + ') ' + msg.scaleUnit;
$( 'hw-gc' ).textContent = '(' + fmt( toCm( G.x ) ) + ', ' + fmt( toCm( G.y - RULER_H ) ) + ') ' + msg.scaleUnit;
$( 'hw-hc' ).textContent = '(' + fmt( toCm( Hpt.x ) ) + ', ' + fmt( toCm( Hpt.y - RULER_H ) ) + ') ' + msg.scaleUnit;
}
/* -- resize -- */
function resize() {
W = canvas.offsetWidth;
H = H_PX;
canvas.width = W * DPR;
canvas.height = H * DPR;
ctx.setTransform( DPR, 0, 0, DPR, 0, 0 );
initVerts();
draw();
}
/* -- pointer helpers -- */
function ptFromEvent( e ) {
var rect = canvas.getBoundingClientRect();
var src = e.touches ? e.touches[ 0 ] : e;
return {
x: ( src.clientX - rect.left ) * ( W / rect.width ),
y: ( src.clientY - rect.top ) * ( H / rect.height )
};
}
function hitTest( pt ) {
return verts.findIndex( function ( v ) {
return Math.hypot( v.x - pt.x, v.y - pt.y ) < 16;
} );
}
function clamp( pt ) {
return {
x: Math.max( 10, Math.min( W - 10, pt.x ) ),
y: Math.max( RULER_H + 4, Math.min( H - 10, pt.y ) )
};
}
canvas.addEventListener( 'mousedown', function ( e ) { dragging = hitTest( ptFromEvent( e ) ); if ( dragging >= 0 ) { canvas.style.cursor = 'grabbing'; } } );
canvas.addEventListener( 'mousemove', function ( e ) {
if ( dragging < 0 ) {
canvas.style.cursor = hitTest( ptFromEvent( e ) ) >= 0 ? 'grab' : 'crosshair';
return;
}
verts[ dragging ] = clamp( ptFromEvent( e ) );
draw();
} );
canvas.addEventListener( 'mouseup', function () { dragging = -1; canvas.style.cursor = 'crosshair'; } );
canvas.addEventListener( 'mouseleave', function () { dragging = -1; } );
canvas.addEventListener( 'touchstart', function ( e ) {
e.preventDefault(); dragging = hitTest( ptFromEvent( e ) );
}, { passive: false } );
canvas.addEventListener( 'touchmove', function ( e ) {
e.preventDefault();
if ( dragging < 0 ) { return; }
verts[ dragging ] = clamp( ptFromEvent( e ) );
draw();
}, { passive: false } );
canvas.addEventListener( 'touchend', function () { dragging = -1; } );
/* dark-mode live update (MediaWiki) */
var observer = new MutationObserver( draw );
observer.observe( document.documentElement, { attributes: true, attributeFilter: [ 'class' ] } );
observer.observe( document.body, { attributes: true, attributeFilter: [ 'class' ] } );
/* kick off */
var ro = new ResizeObserver( resize );
ro.observe( canvas );
}
/* ----------------------------------------------------------
* Initialise all matching containers on the page
* ---------------------------------------------------------- */
document.querySelectorAll( '.hiruwiki[data-module="euler-line"]' ).forEach( buildWidget );
}() );
ax6baoodcpjg8h6pc831btpjf4t12en
MediaWiki:Hiruwiki/modules/factors.js
8
179488
781425
770215
2026-10-28T10:39:05Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781425
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/**
* Hiruwiki module: factors
* Visualises prime factorisation and shows how GCD and LCM are built
* from shared and unique prime factors of two numbers.
*
* Usage: <div class="hiruwiki" data-module="factors"></div>
*
* ─── I18N ────────────────────────────────────────────────────────────────────
* To add a language, add an entry to FACTORS_I18N below using the MediaWiki
* language code as the key (e.g. 'eu' for Basque, 'es' for Spanish).
* Every key present in 'en' must also appear in every other entry.
*
* Interpolation tokens:
* {A} – first number chosen by the user
* {B} – second number chosen by the user
* {G} – GCD value
* {L} – LCM value
* {AB} – product A × B
* {GL} – product GCD × LCM (always equals AB)
* {primes} – comma-separated list of shared prime factors
* ─────────────────────────────────────────────────────────────────────────────
*/
( function () {
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"nl": {
"_name": "Priemfactoren",
"gcd_formula_eq": "=",
"gcd_none": "(geen — onderling ondeelbaar)",
"gcd_title": "GGD — behoud alleen gedeelde factoren",
"insight_coprime": "<strong>$1</strong> en <strong>$2</strong> zijn onderling ondeelbaar — ze delen geen priemfactoren, dus GGD = 1. Het KGV is simpelweg hun product. Altijd waar: <strong>$1 × $2 = $5</strong> = GGD × KGV = <strong>1 × $4 = $4</strong>.",
"insight_shared_many": "<strong>$1</strong> en <strong>$2</strong> delen de priemfactoren <strong>$7</strong>. De GGD neemt alleen de groene gedeelde blokken. Het KGV neemt alle blokken — groen gedeeld, blauw extra van A, rood extra van B — zonder een priemfactor twee keer te tellen. Altijd waar: <strong>$1 × $2 = $5</strong> = GGD × KGV = <strong>$3 × $4 = $6</strong>.",
"insight_shared_one": "<strong>$1</strong> en <strong>$2</strong> delen de priemfactor <strong>$7</strong>. De GGD neemt alleen de groene gedeelde blokken. Het KGV neemt alle blokken — groen gedeeld, blauw extra van A, rood extra van B — zonder een priemfactor twee keer te tellen. Altijd waar: <strong>$1 × $2 = $5</strong> = GGD × KGV = <strong>$3 × $4 = $6</strong>.",
"label_choose": "Kies twee getallen",
"label_factors": "Priemfactoren",
"label_how": "Hoe GGD en KGV worden opgebouwd",
"lcm_formula_eq": "=",
"lcm_title": "KGV — unie van alle factoren",
"legend_onlyA": "alleen van A",
"legend_onlyB": "alleen van B",
"legend_shared": "gedeeld (voor GGD)",
"hint": "Kies getallen om hun priemfactoren te zien"
},
"fr": {
"_name": "Facteurs premiers",
"gcd_formula_eq": "=",
"gcd_none": "(aucun — premiers entre eux)",
"gcd_title": "PGCD — ne garder que les facteurs partagés",
"insight_coprime": "<strong>$1</strong> et <strong>$2</strong> sont premiers entre eux — ils ne partagent aucun facteur premier, donc PGCD = 1. Le PPCM est simplement leur produit. Toujours vrai : <strong>$1 × $2 = $5</strong> = PGCD × PPCM = <strong>1 × $4 = $4</strong>.",
"insight_shared_many": "<strong>$1</strong> et <strong>$2</strong> partagent les nombres premiers <strong>$7</strong>. Le PGCD ne prend que les blocs verts partagés. Le PPCM prend tous les blocs — verts partagés, bleus de A, rouges de B — sans compter aucun nombre premier deux fois. Toujours vrai : <strong>$1 × $2 = $5</strong> = PGCD × PPCM = <strong>$3 × $4 = $6</strong>.",
"insight_shared_one": "<strong>$1</strong> et <strong>$2</strong> partagent le nombre premier <strong>$7</strong>. Le PGCD ne prend que les blocs verts partagés. Le PPCM prend tous les blocs — verts partagés, bleus de A, rouges de B — sans compter aucun nombre premier deux fois. Toujours vrai : <strong>$1 × $2 = $5</strong> = PGCD × PPCM = <strong>$3 × $4 = $6</strong>.",
"label_choose": "Choisissez deux nombres",
"label_factors": "Facteurs premiers",
"label_how": "Comment le PGCD et le PPCM sont construits",
"lcm_formula_eq": "=",
"lcm_title": "PPCM — union de tous les facteurs",
"legend_onlyA": "unique à A",
"legend_onlyB": "unique à B",
"legend_shared": "partagé (utilisé pour le PGCD)",
"hint": "Choisissez des nombres pour voir leurs facteurs primaires"
},
"tr": {
"_name": "Asal Çarpanlar",
"gcd_formula_eq": "=",
"gcd_none": "(yok — aralarında asal)",
"gcd_title": "EBOB - sadece ortak çarpanları hesapla",
"label_choose": "İki sayı seçin",
"label_factors": "Asal çarpanlar",
"label_how": "EBOB ve EKOK nasıl hesaplanır?",
"lcm_formula_eq": "=",
"lcm_title": "EKOK - tüm çarpanların birleşimi",
"legend_onlyA": "A'ya özgü",
"legend_onlyB": "B'ye özgü",
"legend_shared": "paylaşılan (EBOB için kullanılır)",
"hint": "Asal çarpanlarını görmek için sayıları seçin"
},
"eu": {
"_name": "Faktore lehenak",
"gcd_formula_eq": "=",
"gcd_none": "(bat ere ez — elkarrekiko lehenak)",
"gcd_title": "ZKH — partekatutako faktoreak bakarrik gordetzen dira",
"insight_coprime": "<strong>$1</strong> eta <strong>$2</strong> elkarrekiko lehenak dira — ez dute zenbaki lehenik partekatzen, beraz ZKH = 1. MKT haien biderkadura da, zuzenean. Beti egia: <strong>$1 × $2 = $5</strong> = <strong>ZKH × MKT</strong> = <strong>1 × $4 = $4</strong>.",
"insight_shared_many": "<strong>$1</strong> eta <strong>$2</strong>-k <strong>$7</strong> zenbaki lehenak partekatzen dituzte. ZKHk bloke berde partekatuak bakarrik hartzen ditu. MKTk bloke guztiak hartzen ditu — berde partekatuak, A-ren soberakinak urdin, B-renak gorri — zenbaki lehenik birritan zenbatu gabe. Beti egia: <strong>$1 × $2 = $5</strong> = <strong>ZKH × MKT</strong> = <strong>$3 × $4 = $6</strong>.",
"insight_shared_one": "<strong>$1</strong> eta <strong>$2</strong>-k <strong>$7</strong> zenbaki lehena partekatzen dute. ZKHk bloke berde partekatuak bakarrik hartzen ditu. MKTk bloke guztiak hartzen ditu — berde partekatuak, A-ren soberakin urdinak, B-renak gorri — zenbaki lehenik birritan zenbatu gabe. Beti egia: <strong>$1 × $2 = $5</strong> = <strong>ZKH × MKT</strong> = <strong>$3 × $4 = $6</strong>.",
"label_choose": "Aukeratu bi zenbaki",
"label_factors": "Zenbaki lehenen faktorizazioa",
"label_how": "Nola eraikitzen dira ZKH eta MKT",
"lcm_formula_eq": "=",
"lcm_title": "MKT — faktore guztien biderketa",
"legend_onlyA": "Ari bakarrik dagokiona",
"legend_onlyB": "Bri bakarrik dagokiona",
"legend_shared": "partekatua (ZKHrako erabilia)",
"hint": "Aukeratu zenbakiak haien faktore lehenak ikusteko"
},
"sk": {
"_name": "Prvočíselné činitele",
"gcd_formula_eq": "=",
"gcd_none": "(žiadne — nesúdeliteľné)",
"gcd_title": "NSD — ponechať len spoločné činitele",
"insight_coprime": "<strong>$1</strong> a <strong>$2</strong> sú nesúdeliteľné — nemajú spoločné prvočíselné činitele, takže NSD = 1. NSN je jednoducho ich súčin. Vždy platí: <strong>$1 × $2 = $5</strong> = NSD × NSN = <strong>1 × $4 = $4</strong>.",
"insight_shared_many": "<strong>$1</strong> a <strong>$2</strong> majú spoločné prvočísla <strong>$7</strong>. NSD berie len zelené spoločné bloky. NSN berie všetky bloky — zelené spoločné, modré navyše z A, červené navyše z B — pričom žiadne prvočíslo sa nezapočíta dvakrát. Vždy platí: <strong>$1 × $2 = $5</strong> = NSD × NSN = <strong>$3 × $4 = $6</strong>.",
"insight_shared_one": "<strong>$1</strong> a <strong>$2</strong> majú spoločné prvočíslo <strong>$7</strong>. NSD berie len zelené spoločné bloky. NSN berie všetky bloky — zelené spoločné, modré navyše z A, červené navyše z B — pričom žiadne prvočíslo sa nezapočíta dvakrát. Vždy platí: <strong>$1 × $2 = $5</strong> = NSD × NSN = <strong>$3 × $4 = $6</strong>.",
"label_choose": "Vyberte dve čísla",
"label_factors": "Prvočíselné činitele",
"label_how": "Ako sa tvoria NSD a NSN",
"lcm_formula_eq": "=",
"lcm_title": "NSN — zjednotenie všetkých činiteľov",
"legend_onlyA": "len v A",
"legend_onlyB": "len v B",
"legend_shared": "spoločné (použité pre NSD)",
"hint": "Vyberte čísla a zobrazia sa ich prvočíselné činitele"
},
"skr-arab": {
"_name": "اولی عوامل",
"gcd_formula_eq": "=",
"gcd_none": "(کوئی نئیں — ہم اول)",
"gcd_title": "عاد اعظم — صرف سانجھے عوامل رکھو",
"insight_coprime": "<strong>$1</strong> تے <strong>$2</strong> ہم اول ہن — انہاں دا کوئی اولی عامل سانجھا نئیں، ایں کیتے عاد اعظم = 1 اے۔ زو اضعاف صرف انہاں دی ضرب اے۔ ہمیشہ سچ اے: <strong>$1 × $2 = $5</strong> = عاد اعظم × زو اضعاف = <strong>1 × $4 = $4</strong>۔",
"insight_shared_many": "<strong>$1</strong> تے <strong>$2</strong> وچ اولی عامل <strong>$7</strong> سانجھے ہن۔ عاد اعظم صرف سانجھے ہرے بلاک گھندے۔ زو اضعاف سارے بلاک گھندا اے — ہرے سانجھے، A دے نیلے وادھو، B دے سرخ وادھو — کوئی اولی عامل ٻہ واری شمار نئیں تھیندا۔ ہمیشہ سچ اے: <strong>$1 × $2 = $5</strong> = عاد اعظم × زو اضعاف = <strong>$3 × $4 = $6</strong>۔",
"insight_shared_one": "<strong>$1</strong> تے <strong>$2</strong> وچ اولی عامل <strong>$7</strong> سانجھا اے۔ عاد اعظم صرف سانجھے ہرے بلاک گھندے۔ زو اضعاف سارے بلاک گھندا اے — ہرے سانجھے، A دے نیلے وادھو، B دے سرخ وادھو — کوئی اولی عامل ٻہ واری شمار نئیں تھیندا۔ ہمیشہ سچ اے: <strong>$1 × $2 = $5</strong> = عاد اعظم × زو اضعاف = <strong>$3 × $4 = $6</strong>۔",
"label_choose": "ٻہ نمبر چݨو",
"label_factors": "اولی عوامل",
"label_how": "عاد اعظم تے زو اضعاف کیویں بݨدن",
"lcm_formula_eq": "=",
"lcm_title": "زو اضعاف — سارے عوامل دا ملاپ",
"legend_onlyA": "A کیتے نرالے",
"legend_onlyB": "B کیتے نرالے",
"legend_shared": "سانجھے (عاد اعظم کیتے)",
"hint": "اولی عوامل ݙیکھݨ کیتے نمبر چݨو"
},
"lb": {
"gcd_formula_eq": "=",
"label_choose": "Wielt zwou Zuelen"
},
"ko": {
"_name": "소인수",
"gcd_formula_eq": "=",
"gcd_none": "(없음 — 서로소)",
"gcd_title": "최대공약수 — 공통 인수만 유지",
"insight_coprime": "<strong>$1</strong>와(과) <strong>$2</strong>은(는) 서로소입니다. 즉, 공통 소인수가 없으므로 최대공약수는 1입니다. 최소공배수는 두 달러의 곱입니다. 따라서 항상: <strong>$1 × $2 = $5</strong> = 최대공약수 × 최소공배수 = <strong>1 × $4 = $4</strong>이(가) 성립합니다.",
"insight_shared_many": "<strong>$1</strong>와(과) <strong>$2</strong>은(는) 공통 소인수로 <strong>$7</strong>을(를) 갖습니다. 최대공약수는 공통으로 있는 초록색 블록만 사용합니다. 최소공배수는 모든 블록(공통 초록색 블록, A에서 추가된 파란색 블록, B에서 추가된 빨간색 블록)을 사용하며, 소수는 중복해서 계산되지 않습니다. 따라서 항상: <strong>$1 × $2 = $5</strong> = 최대공약수 × 최소공배수 = <strong>$3 × $4 = $6</strong>이(가) 성립합니다.",
"insight_shared_one": "<strong>$1</strong>와(과) <strong>$2</strong>은(는) 공통 소인수로 <strong>$7</strong>을(를) 갖습니다. 최대공약수는 공통으로 있는 초록색 블록만 사용합니다. 최소공배수는 모든 블록(공통 초록색 블록, A에서 추가된 파란색 블록, B에서 추가된 빨간색 블록)을 사용하며, 소수는 중복해서 계산되지 않습니다. 따라서 항상: <strong>$1 × $2 = $5</strong> = 최대공약수 × 최소공배수 = <strong>$3 × $4 = $6</strong>이(가) 성립합니다.",
"label_choose": "두 개의 숫자를 선택하세요",
"label_factors": "소인수",
"label_how": "최대공약수와 최소공약수의 원리를 알아보세요",
"lcm_formula_eq": "=",
"lcm_title": "최소공배수 — 모든 인수의 합집합",
"legend_onlyA": "A에만 있음",
"legend_onlyB": "B에만 있음",
"legend_shared": "공통 (최대공약수에 사용됨)",
"hint": "소인수분해를 보려면 숫자를 선택하세요"
},
"en": {
"_name": "Prime Factors",
"gcd_formula_eq": "=",
"gcd_none": "(none — coprime)",
"gcd_title": "GCD — keep only shared factors",
"insight_coprime": "<strong>$1</strong> and <strong>$2</strong> are coprime — they share no prime factors, so GCD = 1. The LCM is simply their product. Always true: <strong>$1 × $2 = $5</strong> = GCD × LCM = <strong>1 × $4 = $4</strong>.",
"insight_shared_many": "<strong>$1</strong> and <strong>$2</strong> share the primes <strong>$7</strong>. The GCD takes only the green shared blocks. The LCM takes all blocks — green shared, blue extras from A, red extras from B — with no prime counted twice. Always true: <strong>$1 × $2 = $5</strong> = GCD × LCM = <strong>$3 × $4 = $6</strong>.",
"insight_shared_one": "<strong>$1</strong> and <strong>$2</strong> share the prime <strong>$7</strong>. The GCD takes only the green shared blocks. The LCM takes all blocks — green shared, blue extras from A, red extras from B — with no prime counted twice. Always true: <strong>$1 × $2 = $5</strong> = GCD × LCM = <strong>$3 × $4 = $6</strong>.",
"label_choose": "Choose two numbers",
"label_factors": "Prime factors",
"label_how": "How GCD and LCM are built",
"lcm_formula_eq": "=",
"lcm_title": "LCM — union of all factors",
"legend_onlyA": "unique to A",
"legend_onlyB": "unique to B",
"legend_shared": "shared (used for GCD)",
"hint": "Choose numbers to see their prime factors"
},
"it": {
"gcd_formula_eq": "=",
"gcd_none": "(nessuno — coprimo)",
"label_choose": "Scegli due numeri",
"lcm_formula_eq": "=",
"lcm_title": "mcm — unione di tutti i fattori"
},
"ca": {
"gcd_formula_eq": "=",
"lcm_formula_eq": "="
},
"qqq": {
"_name": "Name of the Prime Factors module",
"gcd_formula_eq": "Equals sign used in the GCD formula display",
"gcd_none": "Text shown when two numbers share no prime factors (are coprime)",
"gcd_title": "Heading for the GCD (Greatest Common Divisor) section",
"insight_coprime": "Insight text shown when the two numbers are coprime. Uses HTML. Parameters: $1 = first number, $2 = second number, $5 = their product, $4 = LCM.",
"insight_shared_many": "Insight text shown when the two numbers share multiple prime factors. Uses HTML. Parameters: $1 = first number, $2 = second number, $7 = shared primes, $5 = product, $3 = GCD, $4 = LCM, $6 = GCD × LCM.",
"insight_shared_one": "Insight text shown when the two numbers share exactly one prime factor. Uses HTML. Parameters: $1 = first number, $2 = second number, $7 = shared prime, $5 = product, $3 = GCD, $4 = LCM, $6 = GCD × LCM.",
"label_choose": "Heading for the input section where the user selects two numbers",
"label_factors": "Heading for the prime factors display section",
"label_how": "Heading for the section explaining how GCD and LCM are built",
"lcm_formula_eq": "Equals sign used in the LCM formula display",
"lcm_title": "Heading for the LCM (Least Common Multiple) section",
"legend_onlyA": "Legend label for factor blocks unique to number A",
"legend_onlyB": "Legend label for factor blocks unique to number B",
"legend_shared": "Legend label for factor blocks shared between A and B (used for GCD)",
"hint": "Instruction text explaining how to use the number sliders"
},
"ga": {
"_name": "Príomhfhachtóirí",
"gcd_formula_eq": "=",
"gcd_none": "(níl aon cheann — comhphríomh)",
"gcd_title": "GCD — coinnigh fachtóirí comhroinnte amháin",
"insight_coprime": "Is comhphríomha iad <strong>$1</strong> agus <strong>$2</strong> — níl aon phríomhfhachtóirí comhroinnte acu, mar sin tá GCD = 1. Níl san LCM ach a dtáirge. Fíor i gcónaí: <strong>$1 × $2 = $5</strong> = GCD × LCM = <strong>1 × $4 = $4</strong>.",
"insight_shared_many": "Roinneann <strong>$1</strong> agus <strong>$2</strong> na príomhuimhreacha <strong>$7</strong>. Ní ghlacann an GCD ach na bloic chomhroinnte glasa. Glacann an LCM na bloic uile — comhroinnte glasa, breiseáin ghorma ó A, breiseáin dhearga ó B — gan aon phríomhuimhreacha a chomhaireamh faoi dhó. Fíor i gcónaí: <strong>$1 × $2 = $5</strong> = GCD × LCM = <strong>$3 × $4 = $6</strong>.",
"insight_shared_one": "Roinneann <strong>$1</strong> agus <strong>$2</strong> na príomhuimhreacha <strong>$7</strong>. Ní ghlacann an GCD ach na bloic chomhroinnte glasa. Glacann an LCM na bloic uile — bloic chomhroinnte glasa, breiseáin ghorma ó A, breiseáin dhearga ó B — gan aon phríomhuimhreacha a chomhaireamh faoi dhó. Fíor i gcónaí: <strong>$1 × $2 = $5</strong> = GCD × LCM = <strong>$3 × $4 = $6</strong>.",
"label_choose": "Roghnaigh dhá uimhir",
"label_factors": "Príomhfhachtóirí",
"label_how": "Conas a thógtar GCD agus LCM",
"lcm_formula_eq": "=",
"lcm_title": "LCM — aontas na bhfachtóirí uile",
"legend_onlyA": "uathúil do A",
"legend_onlyB": "uathúil do B",
"legend_shared": "roinnte (a úsáidtear le haghaidh GCD)",
"hint": "Roghnaigh uimhreacha chun a bpríomhfhachtóirí a fheiceáil"
},
"es": {
"_name": "Factores primos",
"gcd_formula_eq": "=",
"gcd_none": "(ninguno — coprimos)",
"gcd_title": "MCD — conservar solo los factores compartidos",
"insight_coprime": "<strong>$1</strong> y <strong>$2</strong> son coprimos — no comparten factores primos, por lo que el MCD = 1. El MCM es simplemente su producto. Siempre es cierto: <strong>$1 × $2 = $5</strong> = MCD × MCM = <strong>1 × $4 = $4</strong>.",
"insight_shared_many": "<strong>$1</strong> y <strong>$2</strong> comparten los primos <strong>$7</strong>. El MCD toma solo los bloques verdes compartidos. El MCM toma todos los bloques — verdes compartidos, azules adicionales de A, rojos adicionales de B — sin contar ningún primo dos veces. Siempre es cierto: <strong>$1 × $2 = $5</strong> = MCD × MCM = <strong>$3 × $4 = $6</strong>.",
"insight_shared_one": "<strong>$1</strong> y <strong>$2</strong> comparten el primo <strong>$7</strong>. El MCD toma solo los bloques verdes compartidos. El MCM toma todos los bloques — verdes compartidos, azules adicionales de A, rojos adicionales de B — sin contar ningún primo dos veces. Siempre es cierto: <strong>$1 × $2 = $5</strong> = MCD × MCM = <strong>$3 × $4 = $6</strong>.",
"label_choose": "Elige dos números",
"label_factors": "Factores primos",
"label_how": "Cómo se construyen el MCD y el MCM",
"lcm_formula_eq": "=",
"lcm_title": "MCM — unión de todos los factores",
"legend_onlyA": "solo de A",
"legend_onlyB": "solo de B",
"legend_shared": "compartidos (usados para MCD)",
"hint": "Elige números para ver sus factores primos"
},
"mk": {
"_name": "Прости делители",
"gcd_formula_eq": "=",
"gcd_none": "(нема — заемно прости)",
"gcd_title": "НЗД — задржи само заеднички фактори",
"insight_coprime": "<strong>$1</strong> и <strong>$2</strong> се заемно прости — не делат прости делители, така што НЗС = 1. НЗМ е просто нивниот производ. Секогаш точно: <strong>$1 × $2 = $5</strong> = НЗД × НЗС = <strong>1 × $4 = $4</strong>.",
"insight_shared_many": "<strong>$1</strong> и <strong>$2</strong> делат прости броеви <strong>$7</strong>. НЗД ги зема само зелените заеднички блокови. НЗС ги зема сите блокови — зелените заеднички, сините преостанати од A, црвените преостанати од B — при што ниеден прост делител не се брои двапати. Секогаш точно: <strong>$1 × $2 = $5</strong> = НЗД × НЗС = <strong>$3 × $4 = $6</strong>.",
"insight_shared_one": "<strong>$1</strong> и <strong>$2</strong> го делат истиот прост делител <strong>$7</strong>. НЗД ги зема само зелените заеднички блокови. НЗС ги зема сите блокови — зелените заеднички, сините преостанати од A, црвените преостанати од B — при што ниеден прост делител не се брои двапати. Секогаш точно: <strong>$1 × $2 = $5</strong> = НЗД × НЗС = <strong>$3 × $4 = $6</strong>.",
"label_choose": "Изберете два броја",
"label_factors": "Прости делители",
"label_how": "Како се изградени НЗД и НЗС",
"lcm_formula_eq": "=",
"lcm_title": "НЗС — унија на сите фактори",
"legend_onlyA": "само во A",
"legend_onlyB": "само во B",
"legend_shared": "заеднички (се користи за НЗД)",
"hint": "Изберете броеви за да ги видите нивните прости делители"
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
/* ── MATHS ────────────────────────────────────────────────────────────────── */
function gcd( a, b ) { return b === 0 ? a : gcd( b, a % b ); }
function primeFactors( n ) {
var f = [];
for ( var d = 2; d * d <= n; d++ ) {
while ( n % d === 0 ) { f.push( d ); n = Math.floor( n / d ); }
}
if ( n > 1 ) f.push( n );
return f.sort( function ( a, b ) { return a - b; } );
}
function classify( fA, fB ) {
var cntA = {}, cntB = {}, sharedCnt = {};
fA.forEach( function ( p ) { cntA[ p ] = ( cntA[ p ] || 0 ) + 1; } );
fB.forEach( function ( p ) { cntB[ p ] = ( cntB[ p ] || 0 ) + 1; } );
Object.keys( cntA ).forEach( function ( p ) {
if ( cntB[ p ] ) sharedCnt[ p ] = Math.min( cntA[ p ], cntB[ p ] );
} );
function labelList( factors, ownRole ) {
var usedShared = {};
return factors.map( function ( p ) {
usedShared[ p ] = usedShared[ p ] || 0;
if ( sharedCnt[ p ] && usedShared[ p ] < sharedCnt[ p ] ) {
usedShared[ p ]++;
return 'shared';
}
return ownRole;
} );
}
return {
labA: labelList( fA, 'onlyA' ),
labB: labelList( fB, 'onlyB' ),
sharedCnt: sharedCnt,
cntA: cntA,
cntB: cntB
};
}
function lcmFactorList( cntA, cntB, sharedCnt ) {
var allPrimes = {};
Object.keys( cntA ).forEach( function ( p ) { allPrimes[ p ] = true; } );
Object.keys( cntB ).forEach( function ( p ) { allPrimes[ p ] = true; } );
var result = [];
Object.keys( allPrimes ).map( Number ).sort( function ( a, b ) { return a - b; } ).forEach( function ( p ) {
var shared = sharedCnt[ p ] || 0;
var extraA = ( cntA[ p ] || 0 ) - shared;
var extraB = ( cntB[ p ] || 0 ) - shared;
for ( var i = 0; i < shared; i++ ) result.push( { prime: p, role: 'shared' } );
for ( var i = 0; i < extraA; i++ ) result.push( { prime: p, role: 'onlyA' } );
for ( var i = 0; i < extraB; i++ ) result.push( { prime: p, role: 'onlyB' } );
} );
return result;
}
/* ── DOM HELPERS ──────────────────────────────────────────────────────────── */
var ROLE_STYLE = {
shared: { bg: '#C0DD97', border: '#639922', color: '#27500A' },
onlyA: { bg: '#B5D4F4', border: '#378ADD', color: '#0C447C' },
onlyB: { bg: '#F7C1C1', border: '#C0392B', color: '#791F1F' }
};
function pill( p, role, size ) {
size = size || 38;
var s = ROLE_STYLE[ role ] || ROLE_STYLE.onlyA;
var el = document.createElement( 'div' );
el.className = 'hw-fct-pill';
el.textContent = p;
el.style.width = size + 'px';
el.style.height = size + 'px';
el.style.fontSize = Math.round( size * 0.4 ) + 'px';
el.style.background = s.bg;
el.style.border = '2px solid ' + s.border;
el.style.color = s.color;
return el;
}
function sym( ch ) {
var el = document.createElement( 'span' );
el.className = 'hw-fct-sym';
el.textContent = ch;
return el;
}
function clearEl( id, root ) { root.querySelector( '#' + id ).innerHTML = ''; }
function appendPills( rowId, items, size, root ) {
// items: [{prime, role}] or numbers (role defaults to 'shared')
var row = root.querySelector( '#' + rowId );
items.forEach( function ( item, i ) {
if ( i > 0 ) row.appendChild( sym( '×' ) );
var p = typeof item === 'object' ? item.prime : item;
var role = typeof item === 'object' ? item.role : 'shared';
row.appendChild( pill( p, role, size ) );
} );
}
/* ── BUILD HTML ───────────────────────────────────────────────────────────── */
function buildHTML( root ) {
root.innerHTML =
'<div class="hw-factors">' +
'<div class="hw-fct-sec" id="hw-fct-lbl-choose"></div>' +
'<div class="hw-fct-controls">' +
'<div class="hw-fct-ctrl-row">' +
'<input type="range" id="hw-fct-sA" min="2" max="60" value="12" step="1">' +
'<div class="hw-fct-badge" id="hw-fct-badgeA">12</div>' +
'</div>' +
'<div class="hw-fct-ctrl-row">' +
'<input type="range" id="hw-fct-sB" min="2" max="60" value="32" step="1">' +
'<div class="hw-fct-badge" id="hw-fct-badgeB">32</div>' +
'</div>' +
'</div>' +
'<div class="hw-fct-sec" id="hw-fct-lbl-factors"></div>' +
'<div class="hw-fct-strip-wrap">' +
'<div class="hw-fct-strip-row" id="hw-fct-rowA"></div>' +
'<div class="hw-fct-strip-row" id="hw-fct-rowB"></div>' +
'</div>' +
'<div class="hw-fct-legend">' +
'<div class="hw-fct-leg"><div class="hw-fct-leg-sq hw-fct-leg-onlyA"></div><span id="hw-fct-leg-onlyA"></span></div>' +
'<div class="hw-fct-leg"><div class="hw-fct-leg-sq hw-fct-leg-onlyB"></div><span id="hw-fct-leg-onlyB"></span></div>' +
'<div class="hw-fct-leg"><div class="hw-fct-leg-sq hw-fct-leg-shared"></div><span id="hw-fct-leg-shared"></span></div>' +
'</div>' +
'<div class="hw-fct-sec" id="hw-fct-lbl-how"></div>' +
'<div class="hw-fct-results">' +
'<div class="hw-fct-rcard">' +
'<div class="hw-fct-rcard-lbl" id="hw-fct-gcd-title"></div>' +
'<div class="hw-fct-rcard-val" id="hw-fct-r-gcd" style="color:#639922"></div>' +
'<div class="hw-fct-strip-row" id="hw-fct-gcd-pills"></div>' +
'<div class="hw-fct-rcard-formula" id="hw-fct-gcd-formula"></div>' +
'</div>' +
'<div class="hw-fct-rcard">' +
'<div class="hw-fct-rcard-lbl" id="hw-fct-lcm-title"></div>' +
'<div class="hw-fct-rcard-val" id="hw-fct-r-lcm" style="color:#8E44AD"></div>' +
'<div class="hw-fct-strip-row" id="hw-fct-lcm-pills"></div>' +
'<div class="hw-fct-rcard-formula" id="hw-fct-lcm-formula"></div>' +
'</div>' +
'</div>' +
'<div class="hw-fct-insight" id="hw-fct-insight"></div>' +
'<div class="hw-footer">' +
'<a class="hw-footer-icon" href="' + mw.util.getUrl('Wikipedia:Hiruwiki') + '" title="Hiruwiki">' + hiruwiki.getLogoSvg(22) + '</a>' +
'<span class="hw-footer__text">' + (t('hint') === 'hint' || t('hint') === '<hint>' ? (messages.en.hint || '') : t('hint')) + '</span>' +
'</div>' +
'</div>';
}
/* ── INIT STATIC TEXT ─────────────────────────────────────────────────────── */
function initText( root ) {
root.querySelector( '#hw-fct-lbl-choose' ).textContent = t( 'label_choose' );
root.querySelector( '#hw-fct-lbl-factors' ).textContent = t( 'label_factors' );
root.querySelector( '#hw-fct-lbl-how' ).textContent = t( 'label_how' );
root.querySelector( '#hw-fct-gcd-title' ).textContent = t( 'gcd_title' );
root.querySelector( '#hw-fct-lcm-title' ).textContent = t( 'lcm_title' );
root.querySelector( '#hw-fct-leg-onlyA' ).textContent = t( 'legend_onlyA' );
root.querySelector( '#hw-fct-leg-onlyB' ).textContent = t( 'legend_onlyB' );
root.querySelector( '#hw-fct-leg-shared' ).textContent = t( 'legend_shared' );
}
/* ── UPDATE ───────────────────────────────────────────────────────────────── */
function update( root ) {
var A = parseInt( root.querySelector( '#hw-fct-sA' ).value, 10 );
var B = parseInt( root.querySelector( '#hw-fct-sB' ).value, 10 );
root.querySelector( '#hw-fct-badgeA' ).textContent = A;
root.querySelector( '#hw-fct-badgeB' ).textContent = B;
var fA = primeFactors( A ), fB = primeFactors( B );
var cl = classify( fA, fB );
var G = gcd( A, B ), L = A / G * B;
var lcmList = lcmFactorList( cl.cntA, cl.cntB, cl.sharedCnt );
// ── Row A
var rowA = root.querySelector( '#hw-fct-rowA' );
rowA.innerHTML = '';
var lblA = document.createElement( 'div' );
lblA.className = 'hw-fct-strip-label'; lblA.style.color = '#378ADD'; lblA.textContent = A;
rowA.appendChild( lblA ); rowA.appendChild( sym( '=' ) );
fA.forEach( function ( p, i ) {
if ( i > 0 ) rowA.appendChild( sym( '×' ) );
rowA.appendChild( pill( p, cl.labA[ i ] ) );
} );
// ── Row B
var rowB = root.querySelector( '#hw-fct-rowB' );
rowB.innerHTML = '';
var lblB = document.createElement( 'div' );
lblB.className = 'hw-fct-strip-label'; lblB.style.color = '#C0392B'; lblB.textContent = B;
rowB.appendChild( lblB ); rowB.appendChild( sym( '=' ) );
fB.forEach( function ( p, i ) {
if ( i > 0 ) rowB.appendChild( sym( '×' ) );
rowB.appendChild( pill( p, cl.labB[ i ] ) );
} );
// ── GCD pills
var gcdRow = root.querySelector( '#hw-fct-gcd-pills' );
gcdRow.innerHTML = '';
var sharedList = [];
Object.keys( cl.sharedCnt ).map( Number ).sort( function ( a, b ) { return a - b; } ).forEach( function ( p ) {
for ( var i = 0; i < cl.sharedCnt[ p ]; i++ ) sharedList.push( p );
} );
if ( sharedList.length === 0 ) {
var none = document.createElement( 'span' );
none.className = 'hw-fct-none';
none.textContent = t( 'gcd_none' );
gcdRow.appendChild( none );
} else {
sharedList.forEach( function ( p, i ) {
if ( i > 0 ) gcdRow.appendChild( sym( '×' ) );
gcdRow.appendChild( pill( p, 'shared', 32 ) );
} );
}
root.querySelector( '#hw-fct-r-gcd' ).textContent = G;
root.querySelector( '#hw-fct-gcd-formula' ).textContent =
sharedList.length ? sharedList.join( ' × ' ) + t( 'gcd_formula_eq' ) + G : 'GCD = 1';
// ── LCM pills
var lcmRow = root.querySelector( '#hw-fct-lcm-pills' );
lcmRow.innerHTML = '';
lcmList.forEach( function ( item, i ) {
if ( i > 0 ) lcmRow.appendChild( sym( '×' ) );
lcmRow.appendChild( pill( item.prime, item.role, 32 ) );
} );
root.querySelector( '#hw-fct-r-lcm' ).textContent = L;
root.querySelector( '#hw-fct-lcm-formula' ).textContent =
lcmList.map( function ( x ) { return x.prime; } ).join( ' × ' ) + t( 'lcm_formula_eq' ) + L;
// ── Insight
var insightEl = root.querySelector( '#hw-fct-insight' );
var sharedPrimes = Object.keys( cl.sharedCnt ).map( Number ).sort( function ( a, b ) { return a - b; } );
var vars = [ A, B, G, L, A * B, G * L, sharedPrimes.join( ', ' ) ];
if ( G === 1 ) {
insightEl.innerHTML = t( 'insight_coprime', vars );
} else if ( sharedPrimes.length === 1 ) {
insightEl.innerHTML = t( 'insight_shared_one', vars );
} else {
insightEl.innerHTML = t( 'insight_shared_many', vars );
}
}
/* ── MOUNT ────────────────────────────────────────────────────────────────── */
function mount( root ) {
buildHTML( root );
initText( root );
var sA = root.querySelector( '#hw-fct-sA' );
var sB = root.querySelector( '#hw-fct-sB' );
sA.addEventListener( 'input', function () { update( root ); } );
sB.addEventListener( 'input', function () { update( root ); } );
update( root );
}
/* ── SCAN & INIT ──────────────────────────────────────────────────────────── */
function init() {
document.querySelectorAll( '.hiruwiki[data-module="factors"]' ).forEach( function ( el ) {
if ( !el.dataset.hwFctInit ) {
el.dataset.hwFctInit = '1';
mount( el );
}
} );
}
if ( document.readyState === 'loading' ) {
document.addEventListener( 'DOMContentLoaded', init );
} else {
init();
}
} )();
3lh0r8br4pynfrsgpmz92ycmcwpfv97
MediaWiki:Hiruwiki/modules/fibonacci.js
8
179490
781426
770217
2026-10-28T10:39:08Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781426
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/**
* Hiruwiki module: fibonacci
* Visualises how the Fibonacci sequence is built step by step,
* and how the ratio of consecutive terms converges to the golden ratio φ.
*
* Usage: <div class="hiruwiki" data-module="fibonacci"></div>
*
* ─── I18N ────────────────────────────────────────────────────────────────────
* To add a language, add an entry to FIBONACCI_I18N below using the MediaWiki
* language code as the key (e.g. 'eu' for Basque, 'es' for Spanish).
* Every key present in 'en' must also appear in every other entry.
*
* Interpolation tokens:
* {step} – current step number
* {total} – total number of steps
* {fa} – left addend
* {fb} – right addend
* {fr} – result of the addition
* {ratio} – current ratio to 6 decimal places
* {error} – absolute error from φ
* ─────────────────────────────────────────────────────────────────────────────
*/
( function () {
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"nl": {
"_name": "Fibonacci-reeks",
"card_phi_lbl": "Gulden snede φ",
"card_phi_sub": "(1 + √5) / 2 = 1,6180339887…",
"card_ratio_error": "fout ten opzichte van φ:",
"card_ratio_lbl": "Huidige verhouding F(n+1) ÷ F(n)",
"chart_step": "stap",
"diff": "verschil:",
"insight_close": "<strong>$1 + $2 = $3</strong>. De verhouding = <strong>$4</strong> — binnen <strong>$5</strong> van φ. Bijna volledig geconvergeerd.",
"insight_converged": "<strong>$1 + $2 = $3</strong>. De verhouding = <strong>$4</strong> — binnen <strong>$5</strong> van φ. De reeks is geconvergeerd tot 6 decimalen.",
"insight_normal": "<strong>$1 + $2 = $3</strong>. De verhouding = <strong>$4</strong>, nog <strong>$5</strong> verwijderd van φ = 1.61803…",
"insight_step1": "We beginnen met <strong>1 + 1 = 2</strong>. De verhouding 2 ÷ 1 = <strong>2.000000</strong> — nog ver van φ ≈ 1.61803.",
"insight_step2": "<strong>1 + 2 = 3</strong>. De verhouding 3 ÷ 2 = <strong>1.500000</strong> — al dichterbij. Elke stap verkleint het verschil.",
"sec_addition": "Optelling",
"sec_chart": "Convergentie naar φ",
"sec_sequence": "Reeks tot nu toe",
"sec_steps": "Stap door de Fibonacci-reeks",
"step_label": "Stap $1 van $2",
"hint": "Klik op de knop om de Fibonacci-reeks te doorlopen en de convergentie naar de Gulden Snede te zien."
},
"fr": {
"_name": "Suite de Fibonacci",
"card_phi_lbl": "Nombre d'or φ",
"card_phi_sub": "(1 + √5) / 2 = 1.6180339887…",
"card_ratio_error": "erreur par rapport à φ :",
"card_ratio_lbl": "Rapport actuel F(n+1) ÷ F(n)",
"chart_step": "étape",
"diff": "diff :",
"insight_close": "<strong>$1 + $2 = $3</strong>. Le rapport = <strong>$4</strong> — à <strong>$5</strong> de φ près. Presque totalement convergé.",
"insight_converged": "<strong>$1 + $2 = $3</strong>. Le rapport = <strong>$4</strong> — à <strong>$5</strong> de φ près. La suite a convergé à 6 décimales.",
"insight_normal": "<strong>$1 + $2 = $3</strong>. Le rapport = <strong>$4</strong>, encore à <strong>$5</strong> de φ = 1.61803…",
"insight_step1": "On commence par <strong>1 + 1 = 2</strong>. Le rapport 2 ÷ 1 = <strong>2.000000</strong> — encore loin de φ ≈ 1.61803.",
"insight_step2": "<strong>1 + 2 = 3</strong>. Le rapport 3 ÷ 2 = <strong>1.500000</strong> — déjà plus proche. Chaque étape réduit l'écart.",
"sec_addition": "Addition",
"sec_chart": "Convergence vers φ",
"sec_sequence": "Suite jusqu'ici",
"sec_steps": "Étapes de la suite de Fibonacci",
"step_label": "Étape $1 sur $2",
"hint": "Effectuer la séquence pour voir la convergence"
},
"tr": {
"_name": "Fibonacci Dizisi",
"card_phi_lbl": "Altın oran φ",
"card_phi_sub": "(1 + √5) / 2 = 1.6180339887…",
"card_ratio_error": "φ'den kaynaklanan hata:",
"card_ratio_lbl": "Cari oran F(n+1) ÷ F(n)",
"chart_step": "adım",
"diff": "fark:",
"sec_addition": "Ek",
"sec_chart": "φ'ye yakınsama",
"sec_sequence": "Şu ana kadar olan süreç",
"sec_steps": "Fibonacci dizisini adım adım inceleyin",
"step_label": "$1/$2 adım",
"hint": "Yakınsamayı görmek için adım adım ilerleyin"
},
"eu": {
"_name": "Fibonacci sekuentzia",
"card_phi_lbl": "Urrezko arrazoia φ",
"card_phi_sub": "(1 + √5) / 2 = 1,6180339887…",
"card_ratio_error": "φ-rekiko errorea:",
"card_ratio_lbl": "Uneko erlazioa F(n+1) ÷ F(n)",
"chart_step": "urratsa",
"diff": "aldea:",
"insight_close": "<strong>$1 + $2 = $3</strong>. Erlazioa = <strong>$4</strong> — φ-tik <strong>$5</strong>-ra. Ia erabat hurbildu da.",
"insight_converged": "<strong>$1 + $2 = $3</strong>. Erlazioa = <strong>$4</strong> — φ-tik <strong>$5</strong>-ra. Segida 6 dezimaletara hurbildu da.",
"insight_normal": "<strong>$1 + $2 = $3</strong>. Erlazioa = <strong>$4</strong>, oraindik <strong>$5</strong> urrun φ = 1,61803…-tik.",
"insight_step1": "<strong>1 + 1 = 2</strong>-rekin hasten gara. 2 ÷ 1 = <strong>2,000000</strong> erlazioa — oraindik urrun φ ≈ 1,61803-tik.",
"insight_step2": "<strong>1 + 2 = 3</strong>. 3 ÷ 2 = <strong>1,500000</strong> erlazioa — hurbilago dagoeneko. Urrats bakoitzak tartea murriztuko du.",
"sec_addition": "Batuketa",
"sec_chart": "φ-rantz konbergentzia",
"sec_sequence": "Orain arteko segida",
"sec_steps": "Fibonacci segidaren urratsak",
"step_label": "$1. urratsa $2tik",
"hint": "Jarraitu sekuentzian zehar konbergentzia ikusteko"
},
"sk": {
"_name": "Fibonacciho postupnosť",
"card_phi_lbl": "Zlatý rez φ",
"card_phi_sub": "(1 + √5) / 2 = 1.6180339887…",
"card_ratio_error": "odchýlka od φ:",
"card_ratio_lbl": "Aktuálny pomer F(n+1) ÷ F(n)",
"chart_step": "krok",
"diff": "rozdiel:",
"insight_close": "<strong>$1 + $2 = $3</strong>. Pomer = <strong>$4</strong> — vzdialený od φ o <strong>$5</strong>. Takmer úplne skonvergoval.",
"insight_converged": "<strong>$1 + $2 = $3</strong>. Pomer = <strong>$4</strong> — vzdialený od φ o <strong>$5</strong>. Postupnosť skonvergovala na 6 desatinných miest.",
"insight_normal": "<strong>$1 + $2 = $3</strong>. Pomer = <strong>$4</strong>, stále <strong>$5</strong> od φ = 1.61803…",
"insight_step1": "Začíname s <strong>1 + 1 = 2</strong>. Pomer 2 ÷ 1 = <strong>2.000000</strong> — stále ďaleko od φ ≈ 1.61803.",
"insight_step2": "<strong>1 + 2 = 3</strong>. Pomer 3 ÷ 2 = <strong>1.500000</strong> — už bližšie. Každý krok ďalej zmenšuje rozdiel.",
"sec_addition": "Sčítanie",
"sec_chart": "Konvergencia k φ",
"sec_sequence": "Doterajšia postupnosť",
"sec_steps": "Prechádzajte Fibonacciho postupnosťou",
"step_label": "Krok $1 z $2",
"hint": "Prechádzajte postupnosťou a sledujte konvergenciu"
},
"skr-arab": {
"_name": "فبوناچی تسلسل",
"card_phi_lbl": "سنہری تناسب φ",
"card_phi_sub": "(1 + √5) / 2 = 1.6180339887…",
"card_ratio_error": "φ توں غلطی:",
"card_ratio_lbl": "موجودہ تناسب F(n+1) ÷ F(n)",
"chart_step": "قدم",
"diff": "فرق:",
"insight_close": "<strong>$1 + $2 = $3</strong>۔ تناسب = <strong>$4</strong> — φ توں <strong>$5</strong> اندر۔ تقریباً پوری طرح ملݨ تئیں پہنچ ڳیا۔",
"insight_converged": "<strong>$1 + $2 = $3</strong>۔ تناسب = <strong>$4</strong> — φ توں <strong>$5</strong> اندر۔ تسلسل 6 اعشاریہ مقام تئیں مل ڳیا اے۔",
"insight_normal": "<strong>$1 + $2 = $3</strong>۔ تناسب = <strong>$4</strong>، اڄاں φ = 1.61803… توں <strong>$5</strong> پرے۔",
"insight_step1": "اساں <strong>1 + 1 = 2</strong> نال شروع کریندے ہیں۔ تناسب 2 ÷ 1 = <strong>2.000000</strong> — اڄاں φ ≈ 1.61803 توں پرے۔",
"insight_step2": "<strong>1 + 2 = 3</strong>۔ تناسب 3 ÷ 2 = <strong>1.500000</strong> — پہلے ہی نیڑے۔ ہر قدم فرق کوں ٻیا تنگ کریندے۔",
"sec_addition": "جمع",
"sec_chart": "φ تئیں ملݨ",
"sec_sequence": "اڄ تئیں دا تسلسل",
"sec_steps": "فبوناچی تسلسل وچ قدم بہ قدم چلو",
"step_label": "قدم $1 وچوں $2",
"hint": "ملݨ ݙیکھݨ کیتے تسلسل وچ قدم بہ قدم چلو"
},
"lb": {
"card_phi_lbl": "Gëllene Schnëtt φ",
"chart_step": "Schrëtt",
"sec_addition": "Additioun",
"step_label": "Schrëtt $1 vu(n) $2"
},
"ko": {
"_name": "피보나치 수열",
"card_phi_lbl": "황금비 φ",
"card_phi_sub": "(1 + √5) / 2 = 1.6180339887…",
"card_ratio_error": "φ와의 오차:",
"card_ratio_lbl": "현재 비율 F(n+1) ÷ F(n)",
"chart_step": "단계",
"diff": "차이:",
"insight_close": "<strong>$1 + $2 = $3</strong>. 비율은 <strong>$4</strong>(으)로, φ값에서 <strong>$5</strong> 이내입니다. 거의 완전히 수렴했습니다.",
"insight_converged": "<strong>$1 + $2 = $3</strong>. 비율은 <strong>$4</strong>(으)로, φ값에서 <strong>$5</strong> 이내입니다. 소수점 6자리까지 수렴했습니다.",
"insight_normal": "<strong>$1 + $2 = $3</strong>. 비율은 <strong>$4</strong>(으)로, φ = 1.61803...값에서 아직 <strong>$5</strong> 만큼 떨어져 있습니다.",
"insight_step1": "<strong>1 + 1 = 2</strong> 부터 시작합니다. 비율은 2 ÷ 1 = <strong>2.000000</strong> 인데, 이는 여전히 φ ≈ 1.61803과는 거리가 멉니다.",
"insight_step2": "<strong>1 + 2 = 3</strong>. 비율은 3 ÷ 2 = <strong>1.500000</strong> 입니다. 더 가까워졌습니다. 단계가 진행될수록 격차는 더욱 줄어듭니다.",
"sec_addition": "추가",
"sec_chart": "φ를 향해 수렴",
"sec_sequence": "지금까디릐 수열",
"sec_steps": "피보나치 수열을 단계별로 따라가 보세요",
"step_label": "$2단계 중 $1단계",
"hint": "수렴 과정을 단계별로 살펴보세요"
},
"en": {
"_name": "Fibonacci Sequence",
"card_phi_lbl": "Golden ratio φ",
"card_phi_sub": "(1 + √5) / 2 = 1.6180339887…",
"card_ratio_error": "error from φ:",
"card_ratio_lbl": "Current ratio F(n+1) ÷ F(n)",
"chart_step": "step",
"diff": "diff:",
"insight_close": "<strong>$1 + $2 = $3</strong>. The ratio = <strong>$4</strong> — within <strong>$5</strong> of φ. Almost fully converged.",
"insight_converged": "<strong>$1 + $2 = $3</strong>. The ratio = <strong>$4</strong> — within <strong>$5</strong> of φ. The sequence has converged to 6 decimal places.",
"insight_normal": "<strong>$1 + $2 = $3</strong>. The ratio = <strong>$4</strong>, still <strong>$5</strong> away from φ = 1.61803…",
"insight_step1": "We start with <strong>1 + 1 = 2</strong>. The ratio 2 ÷ 1 = <strong>2.000000</strong> — still far from φ ≈ 1.61803.",
"insight_step2": "<strong>1 + 2 = 3</strong>. The ratio 3 ÷ 2 = <strong>1.500000</strong> — already closer. Each step narrows the gap further.",
"sec_addition": "Addition",
"sec_chart": "Convergence toward φ",
"sec_sequence": "Sequence so far",
"sec_steps": "Step through the Fibonacci sequence",
"step_label": "Step $1 of $2",
"hint": "Step through the sequence to see convergence"
},
"ca": {
"diff": "dif:"
},
"qqq": {
"_name": "Name of the Fibonacci Sequence module",
"card_phi_lbl": "Label for the golden ratio φ card",
"card_phi_sub": "Subtitle showing the formula and decimal value of the golden ratio",
"card_ratio_error": "Label for the error from φ, followed by a numeric value",
"card_ratio_lbl": "Label for the current ratio F(n+1) ÷ F(n) card",
"chart_step": "X-axis label for the convergence chart",
"diff": "Label prefix for the difference value in the sequence display. Followed by a number.",
"insight_close": "Insight text shown when the ratio is close to φ but not yet converged. Uses HTML. Parameters: $1 = left addend, $2 = right addend, $3 = sum, $4 = current ratio, $5 = error from φ.",
"insight_converged": "Insight text shown when the ratio has converged to 6 decimal places. Uses HTML. Parameters: $1 = left addend, $2 = right addend, $3 = sum, $4 = current ratio, $5 = error from φ.",
"insight_normal": "Insight text shown during normal steps. Uses HTML. Parameters: $1 = left addend, $2 = right addend, $3 = sum, $4 = current ratio, $5 = error from φ.",
"insight_step1": "Insight text shown at the first step of the Fibonacci sequence. Uses HTML.",
"insight_step2": "Insight text shown at the second step of the Fibonacci sequence. Uses HTML.",
"sec_addition": "Section heading for the addition display",
"sec_chart": "Section heading for the convergence chart",
"sec_sequence": "Section heading for the sequence display",
"sec_steps": "Section heading for the step-through controls",
"step_label": "Label showing the current step. Parameters: $1 = current step number, $2 = total number of steps.",
"hint": "Instruction text for the Fibonacci step-through controls"
},
"ga": {
"_name": "Seicheamh Fibonacci",
"card_phi_lbl": "Cóimheas órga φ",
"card_phi_sub": "(1 + √5) / 2 = 1.6180339887…",
"card_ratio_error": "earráid ó φ:",
"card_ratio_lbl": "Cóimheas reatha F(n+1) ÷ F(n)",
"chart_step": "céim",
"diff": "difríocht:",
"insight_close": "$1 + $2 = $3. An cóimheas = $4 — laistigh de $5 de φ. Beagnach comhtháite go hiomlán.",
"insight_converged": "<strong>$1 + $2 = $3</strong>. An cóimheas = <strong>$4</strong> — laistigh de <strong>$5</strong> de φ. Tá an seicheamh comhtháite go 6 ionad deachúil.",
"insight_normal": "<strong>$1 + $2 = $3</strong>. An cóimheas = <strong>$4</strong>, fós <strong>$5</strong> ar shiúl ó φ = 1.61803…",
"insight_step1": "Tosaímid le *1 + 1 = 2*. Tá an cóimheas 2 ÷ 1 = *2.000000* — fós i bhfad ó φ ≈ 1.61803.",
"insight_step2": "<strong>1 + 2 = 3</strong>. Tá an cóimheas 3 ÷ 2 = <strong>1.500000</strong> níos gaire cheana féin. Laghdaíonn gach céim an bhearna níos mó.",
"sec_addition": "Breisiú",
"sec_chart": "Comhtháthú i dtreo φ",
"sec_sequence": "Seicheamh go dtí seo",
"sec_steps": "Céim tríd an seicheamh Fibonacci",
"step_label": "Céim $1 de $2",
"hint": "Céim tríd an seicheamh chun comhtháthú a fheiceáil"
},
"es": {
"_name": "Sucesión de Fibonacci",
"card_phi_lbl": "Proporción áurea φ",
"card_phi_sub": "(1 + √5) / 2 = 1.6180339887…",
"card_ratio_error": "error respecto a φ:",
"card_ratio_lbl": "Proporción actual F(n+1) ÷ F(n)",
"chart_step": "paso",
"diff": "dif:",
"insight_close": "<strong>$1 + $2 = $3</strong>. La proporción = <strong>$4</strong> — a menos de <strong>$5</strong> de φ. Casi completamente convergido.",
"insight_converged": "<strong>$1 + $2 = $3</strong>. La proporción = <strong>$4</strong> — a menos de <strong>$5</strong> de φ. La secuencia ha convergido a 6 decimales.",
"insight_normal": "<strong>$1 + $2 = $3</strong>. La proporción = <strong>$4</strong>, todavía a <strong>$5</strong> de φ = 1.61803…",
"insight_step1": "Empezamos con <strong>1 + 1 = 2</strong>. La proporción 2 ÷ 1 = <strong>2.000000</strong> — todavía lejos de φ ≈ 1.61803.",
"insight_step2": "<strong>1 + 2 = 3</strong>. La proporción 3 ÷ 2 = <strong>1.500000</strong> — ya más cerca. Cada paso reduce la brecha.",
"sec_addition": "Suma",
"sec_chart": "Convergencia hacia φ",
"sec_sequence": "Secuencia hasta ahora",
"sec_steps": "Pasos de la secuencia de Fibonacci",
"step_label": "Paso $1 de $2",
"hint": "Recorre la secuencia paso a paso para observar la convergencia"
},
"mk": {
"_name": "Фибоначиева низа",
"card_phi_lbl": "Златен пресек φ",
"card_phi_sub": "(1 + √5) / 2 = 1,6180339887…",
"card_ratio_error": "грешка од φ:",
"card_ratio_lbl": "Тековен сооднос F(n+1) ÷ F(n)",
"chart_step": "чекор",
"diff": "разлика:",
"insight_close": "<strong>$1 + $2 = $3</strong>. Соодносот = <strong>$4</strong> — во рамките на <strong>$5</strong> од φ. Речиси наполно сретнати.",
"insight_converged": "<strong>$1 + $2 = $3</strong>. Соодносот = <strong>$4</strong> — во рамките на <strong>$5</strong> од φ. Низата се сретнала на 6 децимални места.",
"insight_normal": "<strong>$1 + $2 = $3</strong>. Соодносот = <strong>$4</strong>, сепак треба уште <strong>$5</strong> за да биде φ = 1,61803…",
"insight_step1": "Почнуваме со <strong>1 + 1 = 2</strong>. Соодносот 2 ÷ 1 = <strong>2,000000</strong> — сè уште далеку од φ ≈ 1,61803.",
"insight_step2": "<strong>1 + 2 = 3</strong>. Соодносот 3 ÷ 2 = <strong>1,500000</strong> — веќе е поблиску. Секој чекор понатаму го намалува јазот.",
"sec_addition": "Собирање",
"sec_chart": "Среќавање кон φ",
"sec_sequence": "Низата досега",
"sec_steps": "Чекорете низ Фибоначиевата низа",
"step_label": "Чекор $1 од $2",
"hint": "Чекорете низ низата за да го видите среќавањето"
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
/* ── CONSTANTS ────────────────────────────────────────────────────────────── */
var MAX_STEPS = 20;
var PHI = ( 1 + Math.sqrt( 5 ) ) / 2; // 1.6180339887...
// Build fibs[0..MAX_STEPS+1] (we need fibs[MAX_STEPS+1] for the last ratio)
var fibs = [ 1, 1 ];
while ( fibs.length < MAX_STEPS + 2 ) {
fibs.push( fibs[ fibs.length - 1 ] + fibs[ fibs.length - 2 ] );
}
var COLORS = [
{ bg: '#DBEAFE', border: '#3B82F6', text: '#1D4ED8' },
{ bg: '#DCF0C8', border: '#6CB93A', text: '#3A7D11' },
{ bg: '#FDE8CB', border: '#E07A10', text: '#9C4F00' },
{ bg: '#F3D4F8', border: '#A855F7', text: '#7E22CE' },
{ bg: '#FEE2E2', border: '#EF4444', text: '#991B1B' },
{ bg: '#D1FAF0', border: '#10B981', text: '#065F46' },
{ bg: '#FEF9C3', border: '#CA8A04', text: '#713F12' },
];
function col( i ) { return COLORS[ i % COLORS.length ]; }
function applyColor( el, idx ) {
var c = col( idx );
el.style.background = c.bg;
el.style.color = c.text;
el.style.border = '2px solid ' + c.border;
}
/* ── BUILD HTML ───────────────────────────────────────────────────────────── */
function buildHTML( root ) {
root.innerHTML =
'<div class="hw-fib">' +
/* Controls */
'<div class="hw-fib-sec" id="hw-fib-sec-steps"></div>' +
'<div class="hw-fib-controls">' +
'<button class="hw-fib-btn" id="hw-fib-btn-prev" disabled>‹</button>' +
'<div class="hw-fib-step-label" id="hw-fib-step-label"></div>' +
'<button class="hw-fib-btn" id="hw-fib-btn-next">›</button>' +
'</div>' +
/* Sequence — top */
'<div class="hw-fib-sec" id="hw-fib-sec-sequence"></div>' +
'<div class="hw-fib-seq-row" id="hw-fib-seq-row"></div>' +
/* Addition */
'<div class="hw-fib-sec" id="hw-fib-sec-addition"></div>' +
'<div class="hw-fib-addition">' +
'<div class="hw-fib-num" id="hw-fib-add-a">1</div>' +
'<div class="hw-fib-op">+</div>' +
'<div class="hw-fib-num" id="hw-fib-add-b">1</div>' +
'<div class="hw-fib-op">=</div>' +
'<div class="hw-fib-num" id="hw-fib-add-r">2</div>' +
'</div>' +
/* Two cards */
'<div class="hw-fib-cards">' +
/* Ratio card */
'<div class="hw-fib-rcard" id="hw-fib-ratio-card">' +
'<div class="hw-fib-rcard-lbl" id="hw-fib-card-ratio-lbl"></div>' +
'<div class="hw-fib-ratio-eq">' +
'<div class="hw-fib-ratio-pill" id="hw-fib-ratio-num">2</div>' +
'<div class="hw-fib-ratio-op">÷</div>' +
'<div class="hw-fib-ratio-pill" id="hw-fib-ratio-den">1</div>' +
'<div class="hw-fib-ratio-op">=</div>' +
'<div class="hw-fib-ratio-result" id="hw-fib-ratio-result">2.000000</div>' +
'</div>' +
'<div class="hw-fib-rcard-sub">' +
'<span id="hw-fib-card-ratio-error"></span> ' +
'<span class="hw-fib-phi-error" id="hw-fib-phi-error">0.38197</span>' +
'</div>' +
'</div>' +
/* φ card */
'<div class="hw-fib-rcard">' +
'<div class="hw-fib-rcard-lbl" id="hw-fib-card-phi-lbl"></div>' +
'<div class="hw-fib-rcard-val" style="color:#C09B2A">1.618034…</div>' +
'<div class="hw-fib-rcard-sub" id="hw-fib-card-phi-sub"></div>' +
'</div>' +
'</div>' +
/* Chart */
'<div class="hw-fib-sec" id="hw-fib-sec-chart"></div>' +
'<canvas class="hw-fib-chart" id="hw-fib-chart"></canvas>' +
/* Insight */
'<div class="hw-fib-insight" id="hw-fib-insight"></div>' +
'</div>';
}
/* ── INIT STATIC TEXT ─────────────────────────────────────────────────────── */
function initText( root ) {
root.querySelector( '#hw-fib-sec-steps' ).textContent = t( 'sec_steps' );
root.querySelector( '#hw-fib-sec-sequence' ).textContent = t( 'sec_sequence' );
root.querySelector( '#hw-fib-sec-addition' ).textContent = t( 'sec_addition' );
root.querySelector( '#hw-fib-card-ratio-lbl' ).textContent = t( 'card_ratio_lbl' );
root.querySelector( '#hw-fib-card-ratio-error').textContent = t( 'card_ratio_error');
root.querySelector( '#hw-fib-card-phi-lbl' ).textContent = t( 'card_phi_lbl' );
root.querySelector( '#hw-fib-card-phi-sub' ).textContent = t( 'card_phi_sub' );
root.querySelector( '#hw-fib-sec-chart' ).textContent = t( 'sec_chart' );
}
/* ── UPDATE ───────────────────────────────────────────────────────────────── */
function update( root, step ) {
var fa = fibs[ step - 1 ];
var fb = fibs[ step ];
var fr = fibs[ step + 1 ];
/* Controls */
root.querySelector( '#hw-fib-step-label' ).textContent = t( 'step_label', [ step, MAX_STEPS ] );
root.querySelector( '#hw-fib-btn-prev' ).disabled = step <= 1;
root.querySelector( '#hw-fib-btn-next' ).disabled = step >= MAX_STEPS;
/* Sequence row */
var seqRow = root.querySelector( '#hw-fib-seq-row' );
seqRow.innerHTML = '';
for ( var i = 0; i <= step + 1; i++ ) {
if ( i > 0 ) {
var sep = document.createElement( 'span' );
sep.className = 'hw-fib-seq-sep'; sep.textContent = ',';
seqRow.appendChild( sep );
}
var pill = document.createElement( 'div' );
pill.className = 'hw-fib-seq-pill';
pill.textContent = fibs[ i ];
var c = col( i );
pill.style.background = c.bg;
pill.style.borderColor = c.border;
pill.style.color = c.text;
if ( i === step - 1 || i === step || i === step + 1 ) {
pill.style.transform = 'scale(1.12)';
pill.style.fontWeight = '600';
}
seqRow.appendChild( pill );
}
/* Addition */
var addA = root.querySelector( '#hw-fib-add-a' );
var addB = root.querySelector( '#hw-fib-add-b' );
var addR = root.querySelector( '#hw-fib-add-r' );
addA.textContent = fa; applyColor( addA, step - 1 );
addB.textContent = fb; applyColor( addB, step );
addR.textContent = fr; applyColor( addR, step + 1 );
/* Ratio */
var ratio = fr / fb;
var error = Math.abs( ratio - PHI );
var errNorm = Math.min( error / 0.4, 1 );
var rColor = errNorm < 0.05 ? '#639922' : errNorm < 0.25 ? '#E07A10' : '#C0392B';
var ratBg = errNorm < 0.05 ? 'rgba(99,153,34,.07)' :
errNorm < 0.25 ? 'rgba(224,122,16,.07)' : 'rgba(192,57,43,.07)';
var ratioNum = root.querySelector( '#hw-fib-ratio-num' );
var ratioDen = root.querySelector( '#hw-fib-ratio-den' );
ratioNum.textContent = fr; applyColor( ratioNum, step + 1 );
ratioDen.textContent = fb; applyColor( ratioDen, step );
var rr = root.querySelector( '#hw-fib-ratio-result' );
rr.textContent = ratio.toFixed( 6 );
rr.style.color = rColor;
var rc = root.querySelector( '#hw-fib-ratio-card' );
rc.style.borderColor = rColor;
rc.style.background = ratBg;
var pe = root.querySelector( '#hw-fib-phi-error' );
pe.textContent = error.toFixed( 8 );
pe.style.color = rColor;
/* Insight */
var ins = root.querySelector( '#hw-fib-insight' );
var errFmt = error < 0.000001 ? error.toFixed( 10 ) : error < 0.0001 ? error.toFixed( 8 ) : error.toFixed( 6 );
var vars = [ fa, fb, fr, ratio.toFixed( 6 ), errFmt ];
if ( step === 1 ) {
ins.innerHTML = t( 'insight_step1', vars );
} else if ( step === 2 ) {
ins.innerHTML = t( 'insight_step2', vars );
} else if ( error < 0.000001 ) {
ins.innerHTML = t( 'insight_converged', vars );
} else if ( error < 0.0001 ) {
ins.innerHTML = t( 'insight_close', vars );
} else {
ins.innerHTML = t( 'insight_normal', vars );
}
drawChart( root, step );
}
/* ── CHART ────────────────────────────────────────────────────────────────── */
function drawChart( root, step ) {
var canvas = root.querySelector( '#hw-fib-chart' );
var dpr = window.devicePixelRatio || 1;
var W = Math.max( canvas.parentElement.clientWidth || 0, 300 );
var H = 180;
canvas.width = W * dpr;
canvas.height = H * dpr;
canvas.style.width = W + 'px';
canvas.style.height = H + 'px';
var ctx = canvas.getContext( '2d' );
ctx.setTransform( dpr, 0, 0, dpr, 0, 0 );
var pad = { top: 16, right: 24, bottom: 32, left: 56 };
var cw = W - pad.left - pad.right;
var ch = H - pad.top - pad.bottom;
var ratios = [];
for ( var s = 1; s <= MAX_STEPS; s++ ) ratios.push( fibs[ s + 1 ] / fibs[ s ] );
var yMin = 1.0, yMax = 2.1;
function xPos( i ) { return pad.left + ( i / ( MAX_STEPS - 1 ) ) * cw; }
function yPos( v ) { return pad.top + ch - ( v - yMin ) / ( yMax - yMin ) * ch; }
ctx.fillStyle = hiruwiki.getThemeColor('background-color-neutral-subtle', '#f8f8f7'); ctx.fillRect( 0, 0, W, H );
ctx.fillStyle = hiruwiki.getThemeColor('background-color-base', '#ffffff'); ctx.fillRect( pad.left, pad.top, cw, ch );
ctx.strokeStyle = hiruwiki.getThemeColor('border-color-base', '#e5e5e5'); ctx.lineWidth = 1;
[ 1.2, 1.4, 1.6, 1.8, 2.0 ].forEach( function ( v ) {
var y = yPos( v );
ctx.beginPath(); ctx.moveTo( pad.left, y ); ctx.lineTo( pad.left + cw, y ); ctx.stroke();
ctx.fillStyle = hiruwiki.getThemeColor('color-placeholder', '#aaa'); ctx.font = '11px sans-serif'; ctx.textAlign = 'right';
ctx.fillText( v.toFixed( 1 ), pad.left - 6, y + 4 );
} );
ctx.fillStyle = hiruwiki.getThemeColor('color-placeholder', '#aaa'); ctx.font = '11px sans-serif'; ctx.textAlign = 'center';
for ( var s2 = 1; s2 <= MAX_STEPS; s2 += 2 ) {
ctx.fillText( s2, xPos( s2 - 1 ), H - pad.bottom + 16 );
}
ctx.fillText( t( 'chart_step' ), pad.left + cw / 2, H - 4 );
var phiY = yPos( PHI );
ctx.strokeStyle = '#C09B2A'; ctx.lineWidth = 1.5; ctx.setLineDash( [ 5, 4 ] );
ctx.beginPath(); ctx.moveTo( pad.left, phiY ); ctx.lineTo( pad.left + cw, phiY ); ctx.stroke();
ctx.setLineDash( [] );
ctx.fillStyle = '#C09B2A'; ctx.font = 'bold 11px sans-serif'; ctx.textAlign = 'left';
ctx.fillText( 'φ', pad.left + cw + 5, phiY + 4 );
/* Future dots — faint */
for ( var sf = step; sf < MAX_STEPS; sf++ ) {
ctx.beginPath(); ctx.arc( xPos( sf ), yPos( ratios[ sf ] ), 3.5, 0, Math.PI * 2 );
ctx.fillStyle = 'rgba(150,150,150,.2)'; ctx.fill();
}
/* Past line */
ctx.strokeStyle = '#4A90D9'; ctx.lineWidth = 2.5; ctx.lineJoin = 'round';
ctx.beginPath();
for ( var sp = 0; sp < step; sp++ ) {
sp === 0 ? ctx.moveTo( xPos( sp ), yPos( ratios[ sp ] ) )
: ctx.lineTo( xPos( sp ), yPos( ratios[ sp ] ) );
}
ctx.stroke();
/* Past dots */
for ( var sd = 0; sd < step - 1; sd++ ) {
ctx.beginPath(); ctx.arc( xPos( sd ), yPos( ratios[ sd ] ), 4, 0, Math.PI * 2 );
ctx.fillStyle = '#4A90D9'; ctx.fill();
}
/* Current dot */
var cx = xPos( step - 1 ), cy = yPos( ratios[ step - 1 ] );
ctx.beginPath(); ctx.arc( cx, cy, 7, 0, Math.PI * 2 );
ctx.fillStyle = '#4A90D9'; ctx.fill();
ctx.strokeStyle = hiruwiki.getThemeColor('background-color-base', '#fff'); ctx.lineWidth = 2.5;
ctx.beginPath(); ctx.arc( cx, cy, 7, 0, Math.PI * 2 ); ctx.stroke();
var err = Math.abs( ratios[ step - 1 ] - PHI );
ctx.fillStyle = '#4A90D9'; ctx.font = 'bold 11px sans-serif'; ctx.textAlign = 'center';
ctx.fillText( t( 'diff' ) + ' ' + err.toFixed( 5 ), cx, cy < pad.top + 24 ? cy + 18 : cy - 12 );
}
/* ── MOUNT ────────────────────────────────────────────────────────────────── */
function mount( root ) {
var step = 1;
buildHTML( root );
initText( root );
root.querySelector( '#hw-fib-btn-prev' ).addEventListener( 'click', function () {
if ( step > 1 ) { step--; update( root, step ); }
} );
root.querySelector( '#hw-fib-btn-next' ).addEventListener( 'click', function () {
if ( step < MAX_STEPS ) { step++; update( root, step ); }
} );
window.addEventListener( 'resize', function () { drawChart( root, step ); } );
update( root, step );
// Footer branding
var footer = document.createElement("div");
footer.className = "hw-footer";
var fLogo = document.createElement("a");
fLogo.className = "hw-footer-icon";
fLogo.href = mw.util.getUrl('Wikipedia:Hiruwiki');
fLogo.title = 'Hiruwiki';
fLogo.innerHTML = hiruwiki.getLogoSvg(22);
var fText = document.createElement("span");
fText.innerHTML = t('hint');
footer.appendChild(fLogo);
footer.appendChild(fText);
root.appendChild(footer);
}
/* ── SCAN & INIT ──────────────────────────────────────────────────────────── */
function init() {
document.querySelectorAll( '.hiruwiki[data-module="fibonacci"]' ).forEach( function ( el ) {
if ( !el.dataset.hwFibInit ) {
el.dataset.hwFibInit = '1';
mount( el );
}
} );
}
if ( document.readyState === 'loading' ) {
document.addEventListener( 'DOMContentLoaded', init );
} else {
init();
}
} )();
1uzynjplyc728mfe4h7q8ij5wvtmrrk
MediaWiki:Hiruwiki/modules/gauss-legendre.js
8
179492
781427
770219
2026-10-28T10:39:12Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781427
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/* Gauss-Legendre Pi Algorithm Module — Hiruwiki */
(function () {
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"nl": {
"_name": "Gauss-Legendre-algoritme",
"iteration": "Iteratie",
"variable_a": "Rekenkundig gemiddelde (a)",
"variable_b": "Meetkundig gemiddelde (b)",
"variable_t": "Foutterm (t)",
"variable_p": "Machtsterm (p)",
"step": "Volgende stap",
"reset": "↺ Opnieuw instellen",
"pi_approx": "Benadering van π",
"correct_digits": "{{PLURAL:$1|$1 correct cijfer|$1 correcte cijfers}}",
"precision_note": "Convergeert kwadratisch: het aantal cijfers verdubbelt bij elke stap.",
"calculation_heading": "Hoe het wordt berekend",
"formula_label": "Formule",
"update_rules_heading": "Updateregels",
"hint": "Bereken iteraties om kwadratische convergentie te zien"
},
"fr": {
"_name": "Algorithme de Gauss-Legendre",
"iteration": "Itération",
"variable_a": "Moyenne arithmétique (a)",
"variable_b": "Moyenne géométrique (b)",
"variable_t": "Terme d'erreur (t)",
"variable_p": "Terme de puissance (p)",
"step": "Étape suivante",
"reset": "Réinitialiser",
"pi_approx": "Approximation de π",
"correct_digits": "{{PLURAL:$1|$1 chiffre correct|$1 chiffres corrects}}",
"precision_note": "Converge quadratiquement : le nombre de chiffres double à chaque étape.",
"calculation_heading": "Comment c'est calculé",
"formula_label": "Formule",
"update_rules_heading": "Règles de mise à jour",
"hint": "Calculez les itérations pour voir la convergence quadratique"
},
"eu": {
"_name": "Gauss-Legendre algoritmoa",
"iteration": "Iterazioa",
"variable_a": "Batez besteko aritmetikoa (a)",
"variable_b": "Batez besteko geometrikoa (b)",
"variable_t": "Errore-terminoa (t)",
"variable_p": "Berredura-terminoa (p)",
"step": "Hurrengo urratsa",
"reset": "↺ Berrezarri",
"pi_approx": "π-ren hurbilketa",
"correct_digits": "{{PLURAL:$1|$1 zifra zuzen|$1 zifra zuzen}}",
"precision_note": "Kuadratikoki konbergentea: digituak bikoiztu egiten dira urrats bakoitzean.",
"calculation_heading": "Nola kalkulatzen den",
"formula_label": "Formula",
"update_rules_heading": "Eguneratze arauak",
"hint": "Kalkulatu iterazioak konbergentzia kuadratikoa ikusteko"
},
"sk": {
"_name": "Gaussov-Legendrov algoritmus",
"iteration": "Iterácia",
"variable_a": "Aritmetický priemer (a)",
"variable_b": "Geometrický priemer (b)",
"variable_t": "Chybový člen (t)",
"variable_p": "Mocninový člen (p)",
"step": "Ďalší krok",
"reset": "Vynulovať",
"pi_approx": "Aproximácia π",
"correct_digits": "{{PLURAL:$1|$1 správna číslica|$1 správne číslice|$1 správnych číslic}}",
"precision_note": "Konverguje kvadraticky: počet číslic sa každým krokom zdvojnásobí.",
"calculation_heading": "Ako sa to počíta",
"formula_label": "Vzorec",
"update_rules_heading": "Pravidlá aktualizácie",
"hint": "Vypočítajte iterácie a uvidíte kvadratickú konvergenciu"
},
"skr-arab": {
"_name": "گاؤس-لیجندر الگوردم",
"iteration": "تکرار",
"variable_a": "حسابی اوسط (a)",
"variable_b": "ہندسی اوسط (b)",
"variable_t": "غلطی رقم (t)",
"variable_p": "طاقت رقم (p)",
"step": "اڳلا قدم",
"reset": "دوبارہ",
"pi_approx": "π دا تقریبا",
"correct_digits": "{{PLURAL:$1|$1 صحیح ہندسہ|$1 صحیح ہندسے}}",
"precision_note": "دوڑی مطابقت: ہر قدم تے ہندسے ݙگݨے تھی ویندن۔",
"calculation_heading": "ایندا حساب",
"formula_label": "کلیہ",
"update_rules_heading": "اپ ڈیٹ دے اصول",
"hint": "تکراراں دا حساب کر کے دوڑی مطابقت ݙیکھو"
},
"ko": {
"_name": "가우스-르장드르 알고리즘",
"iteration": "반복 횟수",
"variable_a": "산술 평균 (a)",
"variable_b": "기하 평균 (b)",
"variable_t": "오차항 (t)",
"variable_p": "지수항 (p)",
"step": "다음 단계",
"reset": "초기화",
"pi_approx": "π의 근사값",
"correct_digits": "{{PLURAL:$1|올바른 자릿수 $1개}}",
"precision_note": "이차 함수적으로 수렴합니다. 즉, 각 단계에서 자릿수가 두 배가 됩니다.",
"calculation_heading": "산출 방법",
"formula_label": "공식",
"update_rules_heading": "규칙 업데이트",
"hint": "2차 수렴을 확인하기 위해 반복 계산을 수행하세요"
},
"en": {
"_name": "Gauss-Legendre Algorithm",
"iteration": "Iteration",
"variable_a": "Arithmetic Mean (a)",
"variable_b": "Geometric Mean (b)",
"variable_t": "Error Term (t)",
"variable_p": "Power Term (p)",
"step": "Next Step",
"reset": "Reset",
"pi_approx": "Approximation of π",
"correct_digits": "{{PLURAL:$1|$1 correct digit|$1 correct digits}}",
"precision_note": "Converges quadratically: digits double each step.",
"calculation_heading": "How it's calculated",
"formula_label": "Formula",
"update_rules_heading": "Update Rules",
"hint": "Compute iterations to see quadratic convergence"
},
"qqq": {
"_name": "Name of the Gauss-Legendre Algorithm module",
"iteration": "Label for the current iteration number",
"variable_a": "Label for the arithmetic mean variable 'a'",
"variable_b": "Label for the geometric mean variable 'b'",
"variable_t": "Label for the error term variable 't'",
"variable_p": "Label for the power term variable 'p'",
"step": "Button label to compute the next iteration",
"reset": "Button label to reset the algorithm to its initial state",
"pi_approx": "Label for the current approximation of π",
"correct_digits": "Label showing the number of correct digits found. Uses PLURAL. Parameters: $1 = the number of correct digits.",
"precision_note": "Note explaining the quadratic convergence of the algorithm",
"calculation_heading": "Heading for the step-by-step calculation breakdown",
"formula_label": "Label for the mathematical formula being used",
"update_rules_heading": "Heading for the section explaining how variables change each step",
"hint": "Instruction text for the Gauss-Legendre algorithm interaction"
},
"ga": {
"_name": "Algartam Gauss-Legendre",
"iteration": "Athraitheach",
"variable_a": "Meán Uimhríochta (a)",
"variable_b": "Meán Geoiméadrach (b)",
"variable_t": "Téarma Earráide (t)",
"variable_p": "Téarma Cumhachta (p)",
"step": "An Chéad Chéim Eile",
"reset": "Athshocraigh",
"pi_approx": "Measúnú ar π",
"correct_digits": "{{PLURAL:$1|$1 dhigit cheart|$1 dhigit cheart}}",
"precision_note": "Comhtháthaíonn go cearnachach: dúblaíonn na digití i ngach céim.",
"calculation_heading": "Conas a ríomhtar é",
"formula_label": "Foirmle",
"update_rules_heading": "Rialacha Nuashonraithe",
"hint": "Ríomh athrá chun cóineasú cearnach a fheiceáil"
},
"es": {
"_name": "Algoritmo de Gauss-Legendre",
"iteration": "Iteración",
"variable_a": "Media aritmética (a)",
"variable_b": "Media geométrica (b)",
"variable_t": "Término de error (t)",
"variable_p": "Término de potencia (p)",
"step": "Próximo paso",
"reset": "Restablecer",
"pi_approx": "Aproximación de π",
"correct_digits": "{{PLURAL:$1|$1 dígito correcto|$1 dígitos correctos}}",
"precision_note": "Converge cuadráticamente: los dígitos se duplican en cada paso.",
"calculation_heading": "Cómo se calcula",
"formula_label": "Fórmula",
"update_rules_heading": "Reglas de actualización",
"hint": "Calcula las iteraciones para observar la convergencia cuadrática"
},
"mk": {
"_name": "Гаус-Лежандров алгоритам",
"iteration": "Итерација",
"variable_a": "Аритметичка средина (a)",
"variable_b": "Геометриска средина (b)",
"variable_t": "Член на грешка (t)",
"variable_p": "Степенов член (p)",
"step": "Следен чекор",
"reset": "Одново",
"pi_approx": "Приближување на π",
"correct_digits": "{{PLURAL:$1|$1 точна цифра|$1 точни цифри}}",
"precision_note": "Конвергира квадратно: цифрите се удвојуваат на секој чекор.",
"calculation_heading": "Како се пресметува",
"formula_label": "Формула",
"update_rules_heading": "Правила на поднова",
"hint": "Пресметајте итерации за да видите квадратна конвергенција"
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
const MATH_PI_STR = "3.1415926535897932384626433832795028841971693993751058209749445923078164062862089986280348253421170679";
// High precision BigInt constants
const SCALE = 10n ** 105n;
const HALF = SCALE / 2n;
function bigIntSqrt(value) {
if (value < 0n) return null;
if (value < 2n) return value;
let x = value / 2n + 1n;
let y = (x + value / x) / 2n;
while (y < x) {
x = y;
y = (x + value / x) / 2n;
}
return x;
}
function formatBig(val, decimals = 14) {
let s = val.toString();
if (val < SCALE) {
s = s.padStart(106, '0');
}
let integerPart = s.slice(0, s.length - 105);
if (integerPart === "") integerPart = "0";
let fractionalPart = s.slice(s.length - 105);
return integerPart + "." + fractionalPart.slice(0, decimals);
}
function getCorrectDigitsMarkup(approxStr) {
let markup = "";
let match = true;
for (let i = 0; i < Math.min(approxStr.length, MATH_PI_STR.length); i++) {
const char = approxStr[i];
const target = MATH_PI_STR[i];
if (match && char === target) {
markup += `<span class="gl-correct-digit">${char}</span>`;
} else {
match = false;
markup += `<span class="gl-wrong-digit">${char}</span>`;
}
}
if (approxStr.length > MATH_PI_STR.length) {
markup += `<span class="gl-wrong-digit">${approxStr.slice(MATH_PI_STR.length)}</span>`;
}
return markup;
}
function countCorrectDigits(approxStr) {
let count = 0;
for (let i = 0; i < Math.min(approxStr.length, MATH_PI_STR.length); i++) {
if (approxStr[i] === MATH_PI_STR[i]) {
if (approxStr[i] !== '.') count++;
} else {
break;
}
}
return count;
}
function createWidget(container) {
if (container.dataset.glInit) return;
container.dataset.glInit = "1";
let n = 0;
let a = SCALE;
// b = 1/sqrt(2) = sqrt(1/2) = sqrt(0.5)
let b = bigIntSqrt(SCALE * SCALE / 2n);
// t = 1/4 = 0.25
let t_val = SCALE / 4n;
let p = SCALE;
container.innerHTML = `
<div class="gl-wrap">
<div class="gl-header">
<div class="gl-iteration-badge">${t('iteration')}: <span id="gl-n">0</span></div>
</div>
<div class="gl-variables-grid">
<div class="gl-var-item">
<span class="gl-var-label">${t('variable_a')}</span>
<div class="gl-var-val-row">
<span class="gl-var-val" id="gl-a">1.000</span>
<div class="gl-info-icon" id="gl-ti-a">i<div class="gl-tooltip" id="gl-tt-a"></div></div>
</div>
</div>
<div class="gl-var-item">
<span class="gl-var-label">${t('variable_b')}</span>
<div class="gl-var-val-row">
<span class="gl-var-val" id="gl-b">0.707</span>
<div class="gl-info-icon" id="gl-ti-b">i<div class="gl-tooltip" id="gl-tt-b"></div></div>
</div>
</div>
<div class="gl-var-item">
<span class="gl-var-label">${t('variable_t')}</span>
<div class="gl-var-val-row">
<span class="gl-var-val" id="gl-t">0.250</span>
<div class="gl-info-icon" id="gl-ti-t">i<div class="gl-tooltip" id="gl-tt-t"></div></div>
</div>
</div>
<div class="gl-var-item">
<span class="gl-var-label">${t('variable_p')}</span>
<div class="gl-var-val-row">
<span class="gl-var-val" id="gl-p">1</span>
<div class="gl-info-icon" id="gl-ti-p">i<div class="gl-tooltip" id="gl-tt-p"></div></div>
</div>
</div>
</div>
<div class="gl-result-section">
<span class="gl-card-label">
${t('pi_approx')}
<div class="gl-info-icon" id="gl-ti-pi">i<div class="gl-tooltip" id="gl-tt-pi"></div></div>
</span>
<div class="gl-pi-display" id="gl-pi-val">-</div>
<div class="gl-digit-info">
<span id="gl-digit-count">0 ${t('correct_digits', [0])}</span>
<span>${t('precision_note')}</span>
</div>
</div>
<div class="gl-controls">
<button class="gl-btn gl-btn-primary" id="gl-btn-step">${t('step')}</button>
<button class="gl-btn gl-btn-destructive" id="gl-btn-reset">${t('reset')}</button>
</div>
</div>
`;
const el = {
n: container.querySelector('#gl-n'),
a: container.querySelector('#gl-a'),
b: container.querySelector('#gl-b'),
t: container.querySelector('#gl-t'),
p: container.querySelector('#gl-p'),
pi: container.querySelector('#gl-pi-val'),
digits: container.querySelector('#gl-digit-count'),
ttA: container.querySelector('#gl-tt-a'),
ttB: container.querySelector('#gl-tt-b'),
ttT: container.querySelector('#gl-tt-t'),
ttP: container.querySelector('#gl-tt-p'),
ttPi: container.querySelector('#gl-tt-pi'),
btnStep: container.querySelector('#gl-btn-step'),
btnReset: container.querySelector('#gl-btn-reset')
};
let prev_a = null;
let prev_b = null;
let prev_t = null;
let prev_p = null;
function updateUI() {
el.n.textContent = n;
el.a.textContent = formatBig(a);
el.b.textContent = formatBig(b);
el.t.textContent = formatBig(t_val);
el.p.textContent = formatBig(p, 0);
if (n === 0) {
const initialText = 'Initial value';
el.ttA.textContent = initialText;
el.ttB.textContent = initialText;
el.ttT.textContent = initialText;
el.ttP.textContent = initialText;
} else {
el.ttA.innerHTML = `a = (a<span class="gl-sub">prev</span> + b<span class="gl-sub">prev</span>) / 2 = (${formatBig(prev_a, 3)} + ${formatBig(prev_b, 3)}) / 2`;
el.ttB.innerHTML = `b = <span class="gl-sqrt-prefix">√</span><span class="gl-sqrt">a<span class="gl-sub">prev</span> · b<span class="gl-sub">prev</span></span> = <span class="gl-sqrt-prefix">√</span><span class="gl-sqrt">${formatBig(prev_a, 3)} · ${formatBig(prev_b, 3)}</span>`;
el.ttT.innerHTML = `t = t<span class="gl-sub">prev</span> − p<span class="gl-sub">prev</span>(a<span class="gl-sub">prev</span> − a)<span class="gl-sup">2</span> = ${formatBig(prev_t, 3)} − ${formatBig(prev_p, 0)}(${formatBig(prev_a, 3)} − ${formatBig(a, 3)})<span class="gl-sup">2</span>`;
el.ttP.innerHTML = `p = 2 · p<span class="gl-sub">prev</span> = 2 · ${formatBig(prev_p, 0)}`;
}
const sum = a + b;
const pi_approx_scaled = (sum * sum / (4n * t_val));
const piStrFull = formatBig(pi_approx_scaled, 101);
el.ttPi.innerHTML = `π ≈ (a + b)<span class="gl-sup">2</span> / 4t = (${formatBig(a, 3)} + ${formatBig(b, 3)})<span class="gl-sup">2</span> / (4 · ${formatBig(t_val, 3)})`;
el.pi.innerHTML = getCorrectDigitsMarkup(piStrFull);
const count = countCorrectDigits(piStrFull);
el.digits.textContent = t('correct_digits', [count]);
if (n >= 6) {
el.btnStep.disabled = true;
el.btnStep.style.opacity = "0.5";
el.btnStep.style.cursor = "not-allowed";
} else {
el.btnStep.disabled = false;
el.btnStep.style.opacity = "1";
el.btnStep.style.cursor = "pointer";
}
}
function step() {
if (n >= 6) return;
// Save previous values for tooltips
prev_a = a;
prev_b = b;
prev_t = t_val;
prev_p = p;
const next_a = (a + b) / 2n;
const next_b = bigIntSqrt(a * b);
const diff = a - next_a;
const next_t = t_val - (p * diff * diff / (SCALE * SCALE));
const next_p = 2n * p;
a = next_a;
b = next_b;
t_val = next_t;
p = next_p;
n++;
updateUI();
}
function reset() {
n = 0;
a = SCALE;
b = bigIntSqrt(SCALE * SCALE / 2n);
t_val = SCALE / 4n;
p = SCALE; // Keep p scaled by SCALE to represent 2^0 * SCALE
prev_a = null;
prev_b = null;
prev_t = null;
prev_p = null;
updateUI();
}
el.btnStep.addEventListener('click', step);
el.btnReset.addEventListener('click', reset);
updateUI();
// Footer branding
var footer = document.createElement("div");
footer.className = "hw-footer";
var fLogo = document.createElement("a");
fLogo.className = "hw-footer-icon";
fLogo.href = mw.util.getUrl('Wikipedia:Hiruwiki');
fLogo.title = 'Hiruwiki';
fLogo.innerHTML = hiruwiki.getLogoSvg(22);
var fText = document.createElement("span");
fText.innerHTML = t('hint');
footer.appendChild(fLogo);
footer.appendChild(fText);
container.appendChild(footer);
}
function init() {
document.querySelectorAll('.hiruwiki[data-module="gauss-legendre"]').forEach(createWidget);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
jo72txqu3hyziinmlugy8hask7lqd81
MediaWiki:Hiruwiki/modules/hexagon-angles.js
8
179494
781430
770221
2026-10-28T10:39:22Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781430
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
( function () {
'use strict';
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"nl": {
"2quads": "2 vierhoeken × 360°",
"4triangles": "4 driehoeken × 180°",
"_name": "Hoeken van een zeshoek",
"interiorAngles": "Binnenhoeken",
"reset": "↺ Opnieuw instellen",
"total": "Totaal",
"hint": "Verdeel de zeshoek in driehoeken of vierhoeken om de som van de hoeken te zien"
},
"fr": {
"2quads": "2 quadrilatères × 360°",
"4triangles": "4 triangles × 180°",
"_name": "Angles de l’hexagone",
"interiorAngles": "Angles intérieurs",
"reset": "↺ Réinitialiser",
"total": "Total",
"hint": "Décomposer l'hexagone en triangles ou quadrilatères"
},
"tr": {
"2quads": "2 dörtgen × 360°",
"4triangles": "4 üçgen × 180°",
"_name": "Altıgen Açıları",
"interiorAngles": "İç açılar",
"reset": "↺ Sıfırla",
"total": "Toplam",
"hint": "Altgeni, üçgenlere veya dörtgenlere ayırın"
},
"eu": {
"2quads": "2 lauki × 360°",
"4triangles": "4 triangelu × 180°",
"_name": "Hexagonoaren angeluak",
"interiorAngles": "Barne angeluak",
"reset": "↺ Berrezarri",
"total": "Guztira",
"hint": "Hexagonoa triangelu edo laukizuzenetan deskonposatu"
},
"sk": {
"2quads": "2 štvoruholníky × 360°",
"4triangles": "4 trojuholníky × 180°",
"_name": "Uhly šesťuholníka",
"interiorAngles": "Vnútorné uhly",
"reset": "↺ Vynulovať",
"total": "Spolu",
"hint": "Rozložte šesťuholník na trojuholníky alebo štvoruholníky"
},
"skr-arab": {
"2quads": "2 چوکور × 360°",
"4triangles": "4 مثلث × 180°",
"_name": "مسدس زاویے",
"interiorAngles": "اندرونی زاویے",
"reset": "↺ دوبارہ",
"total": "کل",
"hint": "مسدس کوں مثلثاں یا چوکوراں وچ ونڈو"
},
"ko": {
"2quads": "2개의 사각형 × 360°",
"4triangles": "4개의 삼각형 × 180°",
"_name": "육각형의 각들",
"interiorAngles": "내부 각들",
"reset": "↺ 초기화",
"total": "합계",
"hint": "육각형을 삼각형이나 사각형으로 분해하세요."
},
"en": {
"2quads": "2 quadrilaterals × 360°",
"4triangles": "4 triangles × 180°",
"_name": "Hexagon Angles",
"interiorAngles": "Interior angles",
"reset": "↺ Reset",
"total": "Total",
"hint": "Decompose the hexagon into triangles or quadrilaterals"
},
"ca": {
"total": "Total"
},
"qqq": {
"2quads": "Decomposition label showing 2 quadrilaterals × 360°",
"4triangles": "Decomposition label showing 4 triangles × 180°",
"_name": "Name of the Hexagon Angles module",
"interiorAngles": "Heading for the interior angles section",
"reset": "Button label to reset the visualisation",
"total": "Label for the total sum of interior angles",
"hint": "Instruction text for the hexagon angle decomposition applet"
},
"ga": {
"2quads": "2 cheathairshleasán × 360°",
"4triangles": "4 thriantán × 180°",
"_name": "Uillinneacha Heicseagáin",
"interiorAngles": "Uillinneacha inmheánacha",
"reset": "↺ Athshocraigh",
"total": "Iomlán",
"hint": "Déan an heicseagán a dhíscaoileadh ina thriantáin nó ina cheathairshleasáin"
},
"es": {
"2quads": "2 cuadriláteros × 360°",
"4triangles": "4 triángulos × 180°",
"_name": "Ángulos del hexágono",
"interiorAngles": "Ángulos interiores",
"reset": "↺ Reiniciar",
"total": "Total",
"hint": "Descompone el hexágono en triángulos o cuadrangulares"
},
"mk": {
"2quads": "2 четириаголника × 360°",
"4triangles": "4 триаголници × 180°",
"_name": "Агли на шестаголник",
"interiorAngles": "Внатрешни агли",
"reset": "↺ Одново",
"total": "Вкупно",
"hint": "Разложете го шестаголникот на триаголници или четириаголници"
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
var SVG_W = 580, SVG_H = 520;
var SVG_NS = 'http://www.w3.org/2000/svg';
var KEYS = ['A','B','C','D','E','F'];
var COLORS = { A:'red', B:'green', C:'blue', D:'orange', E:'#9966cc', F:'#00897b' };
function initHexagonAngles( root ) {
var wrapper = document.createElement('div');
wrapper.className = 'hexagon-angles';
root.parentNode.replaceChild(wrapper, root);
// ── SVG ───────────────────────────────────────────────────────────────
var svgEl = document.createElementNS(SVG_NS,'svg');
svgEl.setAttribute('viewBox', '0 0 ' + SVG_W + ' ' + SVG_H);
svgEl.setAttribute('width', SVG_W);
svgEl.classList.add('qa-svg');
// SVG background rect — fill with background-color-base so it tracks dark mode.
// This is the "why the hexagon border doesn't change" fix: the SVG had no
// explicit background element, so the CSS background on .qa-svg was doing
// the work — but SVG attributes set in JS (like the polygon stroke) were
// already being resolved via getThemeColor at init time. Adding a background
// rect here makes the SVG self-contained and independent of CSS background.
var bgRect = document.createElementNS(SVG_NS,'rect');
bgRect.setAttribute('width','100%'); bgRect.setAttribute('height','100%');
bgRect.setAttribute('fill', hiruwiki.getThemeColor('background-color-base', '#ffffff'));
svgEl.appendChild(bgRect);
var defs = document.createElementNS(SVG_NS,'defs');
var pat = document.createElementNS(SVG_NS,'pattern');
pat.setAttribute('id','ha-grid'); pat.setAttribute('width','20');
pat.setAttribute('height','20'); pat.setAttribute('patternUnits','userSpaceOnUse');
var gp = document.createElementNS(SVG_NS,'path');
gp.setAttribute('d','M 20 0 L 0 0 0 20'); gp.setAttribute('fill','none');
// Grid lines: border-color-subtle (very faint) — #c8ccd1 light / #2e3136 dark
gp.setAttribute('stroke', hiruwiki.getThemeColor('border-color-subtle', '#c8ccd1'));
gp.setAttribute('stroke-width','0.5');
pat.appendChild(gp); defs.appendChild(pat); svgEl.appendChild(defs);
var gr = document.createElementNS(SVG_NS,'rect');
gr.setAttribute('width','100%'); gr.setAttribute('height','100%');
gr.setAttribute('fill','url(#ha-grid)'); svgEl.appendChild(gr);
// 4 triangle fills — data colors at low opacity, no token needed
var triFills = [];
var triColors = ['rgba(255,200,200,0.25)','rgba(200,255,200,0.25)',
'rgba(200,200,255,0.25)','rgba(255,230,150,0.25)'];
for (var ti=0;ti<4;ti++){
var tf = document.createElementNS(SVG_NS,'polygon');
tf.setAttribute('fill',triColors[ti]); tf.setAttribute('stroke','none');
svgEl.appendChild(tf); triFills.push(tf);
}
// Polygon outline — color-base: #202122 light / ~#f8f9fa dark
var polyEl = document.createElementNS(SVG_NS,'polygon');
polyEl.setAttribute('fill','none');
polyEl.setAttribute('stroke', hiruwiki.getThemeColor('color-base', '#202122'));
polyEl.setAttribute('stroke-width','2'); svgEl.appendChild(polyEl);
// Fan diagonals — color-placeholder: #72777d light / #72777d dark
var fanDiags = [];
for (var di=0;di<3;di++){
var dl = document.createElementNS(SVG_NS,'line');
dl.setAttribute('stroke', hiruwiki.getThemeColor('color-placeholder', '#72777d'));
dl.setAttribute('stroke-width','1');
dl.setAttribute('stroke-dasharray','5,4'); svgEl.appendChild(dl);
fanDiags.push(dl);
}
// Quad split diagonal — slightly stronger than fan diagonals, use color-subtle
var quadDiag = document.createElementNS(SVG_NS,'line');
quadDiag.setAttribute('stroke', hiruwiki.getThemeColor('color-subtle', '#54595d'));
quadDiag.setAttribute('stroke-width','1.5');
quadDiag.setAttribute('stroke-dasharray','8,4'); svgEl.appendChild(quadDiag);
// Arcs, labels, circles
var arcs={}, lbls={}, circs={};
KEYS.forEach(function(k){
var arc=document.createElementNS(SVG_NS,'path');
arc.setAttribute('fill',COLORS[k]); arc.setAttribute('opacity','0.4');
svgEl.appendChild(arc); arcs[k]=arc;
var lbl=document.createElementNS(SVG_NS,'text');
lbl.setAttribute('fill',COLORS[k]); lbl.setAttribute('font-weight','bold');
lbl.setAttribute('font-size','15'); lbl.setAttribute('font-family','Arial,sans-serif');
lbl.setAttribute('pointer-events','none'); svgEl.appendChild(lbl); lbls[k]=lbl;
var circ=document.createElementNS(SVG_NS,'circle');
circ.setAttribute('r','8'); circ.setAttribute('fill',COLORS[k]);
// Drag handle ring — background-color-base so it punches out of the polygon
circ.setAttribute('stroke', hiruwiki.getThemeColor('background-color-base', '#ffffff'));
circ.setAttribute('stroke-width','2');
circ.style.cursor='pointer'; svgEl.appendChild(circ); circs[k]=circ;
});
// ── Panel ─────────────────────────────────────────────────────────────
var panel = document.createElement('div');
panel.className = 'qa-panel';
var h3 = document.createElement('h3');
h3.textContent = t('interiorAngles');
panel.appendChild(h3);
var valEls={}, barEls={};
KEYS.forEach(function(k){
var row=document.createElement('div'); row.className='qa-angle-row';
var dot=document.createElement('div'); dot.className='qa-dot'; dot.style.background=COLORS[k];
var letter=document.createElement('div'); letter.className='qa-letter';
letter.style.color=COLORS[k]; letter.textContent=k;
var bw=document.createElement('div'); bw.className='qa-bar-wrap';
var bar=document.createElement('div'); bar.className='qa-bar';
bar.style.background=COLORS[k]; bw.appendChild(bar); barEls[k]=bar;
var val=document.createElement('div'); val.className='qa-val';
val.style.color=COLORS[k]; val.textContent='\u2014'; valEls[k]=val;
row.appendChild(dot); row.appendChild(letter); row.appendChild(bw); row.appendChild(val);
panel.appendChild(row);
});
// ── Triangle decomposition section ────────────────────────────────────
var triSection=document.createElement('div'); triSection.className='qa-tri-section';
var triSectionLabel=document.createElement('div'); triSectionLabel.className='qa-section-label';
triSectionLabel.textContent=t('4triangles'); triSection.appendChild(triSectionLabel);
var triRowLabels=[], triBarBgs=[], triBarSegs=[], triBarLbls=[];
for (var ti2=0;ti2<4;ti2++){
var rowWrap=document.createElement('div'); rowWrap.className='qa-tri-row';
var rowLabel=document.createElement('div'); rowLabel.className='qa-tri-label';
rowWrap.appendChild(rowLabel); triRowLabels.push(rowLabel);
var bsvg=document.createElementNS(SVG_NS,'svg');
bsvg.setAttribute('viewBox','0 0 195 14'); bsvg.setAttribute('width','100%');
bsvg.setAttribute('height','14');
var bg=document.createElementNS(SVG_NS,'rect');
bg.setAttribute('x','0'); bg.setAttribute('y','0');
bg.setAttribute('width','160'); bg.setAttribute('height','14');
// Bar track background — background-color-neutral (#eaecf0 / #2a2d34)
bg.setAttribute('fill', hiruwiki.getThemeColor('background-color-neutral', '#eaecf0'));
bg.setAttribute('rx','3');
bsvg.appendChild(bg); triBarBgs.push(bg);
var segs=[];
for(var si=0;si<3;si++){
var seg=document.createElementNS(SVG_NS,'rect');
seg.setAttribute('y','0'); seg.setAttribute('height','14');
seg.setAttribute('width','0'); seg.setAttribute('x','0');
seg.setAttribute('opacity','0.7'); bsvg.appendChild(seg); segs.push(seg);
}
triBarSegs.push(segs);
var border=document.createElementNS(SVG_NS,'rect');
border.setAttribute('x','0'); border.setAttribute('y','0');
border.setAttribute('width','160'); border.setAttribute('height','14');
border.setAttribute('fill','none');
// Bar outline — border-color-base (#a2a9b1 / #3d424d)
border.setAttribute('stroke', hiruwiki.getThemeColor('border-color-base', '#a2a9b1'));
border.setAttribute('stroke-width','1'); border.setAttribute('rx','3');
bsvg.appendChild(border);
var lbl180=document.createElementNS(SVG_NS,'text');
lbl180.setAttribute('x','164'); lbl180.setAttribute('y','11');
lbl180.setAttribute('font-size','10');
// "180°" label — color-placeholder (#72777d, same in both modes)
lbl180.setAttribute('fill', hiruwiki.getThemeColor('color-placeholder', '#72777d'));
lbl180.setAttribute('font-family','Arial,sans-serif');
lbl180.textContent='180\u00b0'; bsvg.appendChild(lbl180); triBarLbls.push(lbl180);
rowWrap.appendChild(bsvg); triSection.appendChild(rowWrap);
}
panel.appendChild(triSection);
// ── Quad section: two pies ─────────────────────────────────────────────
var quadSection=document.createElement('div'); quadSection.className='qa-quad-section';
var quadSectionLabel=document.createElement('div'); quadSectionLabel.className='qa-section-label';
quadSectionLabel.textContent=t('2quads'); quadSection.appendChild(quadSectionLabel);
var piesRow=document.createElement('div'); piesRow.className='qa-pies-row';
var pie1SVG=document.createElementNS(SVG_NS,'svg');
var pie2SVG=document.createElementNS(SVG_NS,'svg');
var pie1Name=document.createElement('div'); pie1Name.className='qa-pie-name';
var pie2Name=document.createElement('div'); pie2Name.className='qa-pie-name';
[pie1SVG,pie2SVG].forEach(function(ps){
ps.setAttribute('width','80'); ps.setAttribute('height','80');
ps.setAttribute('viewBox','-40 -40 80 80');
});
var wrap1=document.createElement('div'); wrap1.className='qa-pie-wrap';
wrap1.appendChild(pie1Name); wrap1.appendChild(pie1SVG);
var wrap2=document.createElement('div'); wrap2.className='qa-pie-wrap';
wrap2.appendChild(pie2Name); wrap2.appendChild(pie2SVG);
piesRow.appendChild(wrap1); piesRow.appendChild(wrap2);
quadSection.appendChild(piesRow);
panel.appendChild(quadSection);
var eq=document.createElement('div'); eq.className='qa-equation';
eq.innerHTML='<span style="color:red">A</span>+<span style="color:green">B</span>+' +
'<span style="color:blue">C</span>+<span style="color:orange">D</span>+' +
'<span style="color:#9966cc">E</span>+<span style="color:#00897b">F</span>=720\u00b0';
panel.appendChild(eq);
var totalRow=document.createElement('div'); totalRow.className='qa-total-row';
var totalLabel=document.createElement('span'); totalLabel.className='qa-total-label';
totalLabel.textContent=t('total');
var totalVal=document.createElement('span'); totalVal.className='qa-total-val';
totalVal.textContent='720.0\u00b0';
totalRow.appendChild(totalLabel); totalRow.appendChild(totalVal);
panel.appendChild(totalRow);
var resetBtn=document.createElement('button'); resetBtn.className='qa-reset-btn';
resetBtn.textContent='\u21ba ' + t('reset'); panel.appendChild(resetBtn);
wrapper.appendChild(svgEl); wrapper.appendChild(panel);
// ── State ─────────────────────────────────────────────────────────────
var cx=290,cy=310,r=175;
var initPos={};
KEYS.forEach(function(k,i){
var a=-Math.PI/2+(2*Math.PI/6)*i;
initPos[k]={x:cx+r*Math.cos(a),y:cy+r*Math.sin(a)};
});
var pos={}; KEYS.forEach(function(k){pos[k]={};});
var dragKey=null;
function applyPos(){
KEYS.forEach(function(k){
circs[k].cx.baseVal.value=pos[k].x;
circs[k].cy.baseVal.value=pos[k].y;
});
}
function resetPos(p){
KEYS.forEach(function(k){pos[k]={x:p[k].x,y:p[k].y};});
applyPos();
}
resetPos(initPos);
// ── Geometry ──────────────────────────────────────────────────────────
function dist(a,b){return Math.hypot(a.x-b.x,a.y-b.y);}
function segIntersect(p1,p2,p3,p4){
var d1x=p2.x-p1.x,d1y=p2.y-p1.y,d2x=p4.x-p3.x,d2y=p4.y-p3.y;
var den=d1x*d2y-d1y*d2x;
if(Math.abs(den)<1e-10) return false;
var t=((p3.x-p1.x)*d2y-(p3.y-p1.y)*d2x)/den;
var u=((p3.x-p1.x)*d1y-(p3.y-p1.y)*d1x)/den;
return t>0&&t<1&&u>0&&u<1;
}
function isSelfIntersecting(p){
// 9 non-adjacent pairs for hexagon
return segIntersect(p.A,p.B,p.C,p.D)||segIntersect(p.A,p.B,p.D,p.E)||
segIntersect(p.A,p.B,p.E,p.F)||segIntersect(p.B,p.C,p.D,p.E)||
segIntersect(p.B,p.C,p.E,p.F)||segIntersect(p.B,p.C,p.F,p.A)||
segIntersect(p.C,p.D,p.E,p.F)||segIntersect(p.C,p.D,p.F,p.A)||
segIntersect(p.D,p.E,p.F,p.A);
}
function signedArea(p){
var a=0;
for(var i=0;i<6;i++){var pi=p[KEYS[i]],pj=p[KEYS[(i+1)%6]];a+=pi.x*pj.y-pj.x*pi.y;}
return a/2;
}
function triSignedArea(P1,P2,P3){
return ((P2.x-P1.x)*(P3.y-P1.y)-(P3.x-P1.x)*(P2.y-P1.y))/2;
}
function triAngle(V,P,N){
var a=dist(P,N),b=dist(V,N),c=dist(V,P);
return Math.acos(Math.max(-1,Math.min(1,(b*b+c*c-a*a)/(2*b*c))))*180/Math.PI;
}
function interiorAngle(V,P,N,area){
var a=dist(P,N),b=dist(V,N),c=dist(V,P);
var base=Math.acos(Math.max(-1,Math.min(1,(b*b+c*c-a*a)/(2*b*c))))*180/Math.PI;
var cross=(P.x-V.x)*(N.y-V.y)-(P.y-V.y)*(N.x-V.x);
return (area>0?cross>0:cross<0)?360-base:base;
}
function drawAngleArc(center,p1,p2,radius,isReflex){
var v1x=p1.x-center.x,v1y=p1.y-center.y;
var v2x=p2.x-center.x,v2y=p2.y-center.y;
var a1=Math.atan2(v1y,v1x),a2=Math.atan2(v2y,v2x);
var start=a1,end=a2,cross=v1x*v2y-v1y*v2x,tmp;
if(isReflex?cross>0:cross<0){tmp=start;start=end;end=tmp;}
if(end<start) end+=2*Math.PI;
var large=(end-start)>Math.PI?1:0;
var x1=center.x+radius*Math.cos(start),y1=center.y+radius*Math.sin(start);
var x2=center.x+radius*Math.cos(end), y2=center.y+radius*Math.sin(end);
return 'M'+center.x+','+center.y+' L'+x1+','+y1+
' A'+radius+','+radius+' 0 '+large+' 1 '+x2+','+y2+' Z';
}
// Fan triangulation: try each vertex as origin; pick first where all 4 triangles valid
function findFanKeys(p,polyArea){
for(var i=0;i<6;i++){
var idx=[]; for(var j=0;j<6;j++) idx.push(KEYS[(i+j)%6]);
var sa=[triSignedArea(p[idx[0]],p[idx[1]],p[idx[2]]),
triSignedArea(p[idx[0]],p[idx[2]],p[idx[3]]),
triSignedArea(p[idx[0]],p[idx[3]],p[idx[4]]),
triSignedArea(p[idx[0]],p[idx[4]],p[idx[5]])];
var ok=polyArea>0?sa.every(function(s){return s>0;}):sa.every(function(s){return s<0;});
if(ok) return idx;
}
return KEYS.slice();
}
// Quad split: three possible long diagonals A-D, B-E, C-F
var QUAD_SPLITS=[
{q1:['A','B','C','D'],q2:['A','D','E','F']},
{q1:['B','C','D','E'],q2:['B','E','F','A']},
{q1:['C','D','E','F'],q2:['C','F','A','B']}
];
function quadValid(keys,p,polyArea){
var sa1=triSignedArea(p[keys[0]],p[keys[1]],p[keys[2]]);
var sa2=triSignedArea(p[keys[0]],p[keys[2]],p[keys[3]]);
return polyArea>0?(sa1>0&&sa2>0):(sa1<0&&sa2<0);
}
function findQuadSplit(p,polyArea){
for(var i=0;i<QUAD_SPLITS.length;i++){
var s=QUAD_SPLITS[i];
if(quadValid(s.q1,p,polyArea)&&quadValid(s.q2,p,polyArea)) return s;
}
return QUAD_SPLITS[0];
}
// Draw a pie chart for a quadrilateral into a given SVG element
function updateQuadPie(svgPie, quadKeys, p, polyArea) {
while(svgPie.firstChild) svgPie.removeChild(svgPie.firstChild);
var r=34;
var qArea = (triSignedArea(p[quadKeys[0]],p[quadKeys[1]],p[quadKeys[2]])+
triSignedArea(p[quadKeys[0]],p[quadKeys[2]],p[quadKeys[3]]));
var qAngles=[];
for(var i=0;i<4;i++){
var V=p[quadKeys[i]];
var P=p[quadKeys[(i+3)%4]];
var N=p[quadKeys[(i+1)%4]];
qAngles.push(interiorAngle(V,P,N,qArea));
}
var start=-Math.PI/2;
for(var i=0;i<4;i++){
var span=qAngles[i]*Math.PI/180, end=start+span, large=span>Math.PI?1:0;
var x1=r*Math.cos(start),y1=r*Math.sin(start);
var x2=r*Math.cos(end), y2=r*Math.sin(end);
var path=document.createElementNS(SVG_NS,'path');
path.setAttribute('d','M0,0 L'+x1+','+y1+' A'+r+','+r+' 0 '+large+' 1 '+x2+','+y2+' Z');
path.setAttribute('fill',COLORS[quadKeys[i]]);
path.setAttribute('opacity','0.6');
svgPie.appendChild(path);
start=end;
}
// Pie centre "hole" — background-color-base so it matches the panel background
var c=document.createElementNS(SVG_NS,'circle');
c.setAttribute('r','12');
c.setAttribute('fill', hiruwiki.getThemeColor('background-color-base', '#ffffff'));
svgPie.appendChild(c);
// "360°" label — color-subtle (#54595d / ~#a2a9b1)
var tEl=document.createElementNS(SVG_NS,'text');
tEl.setAttribute('text-anchor','middle'); tEl.setAttribute('dominant-baseline','central');
tEl.setAttribute('font-family','Arial,sans-serif'); tEl.setAttribute('font-size','7');
tEl.setAttribute('font-weight','bold');
tEl.setAttribute('fill', hiruwiki.getThemeColor('color-subtle', '#54595d'));
tEl.textContent='360\u00b0'; svgPie.appendChild(tEl);
}
function pts2str(arr){return arr.map(function(pt){return pt.x+','+pt.y;}).join(' ');}
function updatePoly(){
var p={};
KEYS.forEach(function(k){p[k]={x:circs[k].cx.baseVal.value,y:circs[k].cy.baseVal.value};});
polyEl.setAttribute('points',KEYS.map(function(k){return p[k].x+','+p[k].y;}).join(' '));
var area=signedArea(p);
var verts={};
KEYS.forEach(function(k,i){
verts[k]={V:p[k],P:p[KEYS[(i+5)%6]],N:p[KEYS[(i+1)%6]]};
});
// Hexagon interior angles
var angles={},isReflex={};
KEYS.forEach(function(k){
var V=verts[k].V,P=verts[k].P,N=verts[k].N;
var cross=(P.x-V.x)*(N.y-V.y)-(P.y-V.y)*(N.x-V.x);
isReflex[k]=area>0?cross>0:cross<0;
angles[k]=interiorAngle(V,P,N,area);
arcs[k].setAttribute('d',drawAngleArc(V,P,N,32,isReflex[k]));
});
// Labels
var centX=0,centY=0;
KEYS.forEach(function(k){centX+=p[k].x;centY+=p[k].y;}); centX/=6;centY/=6;
KEYS.forEach(function(k){
var V=verts[k].V,P=verts[k].P,N=verts[k].N;
var u1x=P.x-V.x,u1y=P.y-V.y,u2x=N.x-V.x,u2y=N.y-V.y;
var l1=Math.hypot(u1x,u1y),l2=Math.hypot(u2x,u2y);
var bx=u1x/l1+u2x/l2,by=u1y/l1+u2y/l2,bm=Math.hypot(bx,by);
if(bm<1e-6){bx=centX-V.x;by=centY-V.y;}else{bx/=bm;by/=bm;}
if(bx*(centX-V.x)+by*(centY-V.y)<0){bx=-bx;by=-by;}
lbls[k].setAttribute('x',V.x+bx*50-20);
lbls[k].setAttribute('y',V.y+by*50+5);
lbls[k].textContent=angles[k].toFixed(1)+'\u00b0';
});
// ── Fan triangulation ──────────────────────────────────────────────
var fk=findFanKeys(p,area);
fanDiags[0].setAttribute('x1',p[fk[0]].x); fanDiags[0].setAttribute('y1',p[fk[0]].y);
fanDiags[0].setAttribute('x2',p[fk[2]].x); fanDiags[0].setAttribute('y2',p[fk[2]].y);
fanDiags[1].setAttribute('x1',p[fk[0]].x); fanDiags[1].setAttribute('y1',p[fk[0]].y);
fanDiags[1].setAttribute('x2',p[fk[3]].x); fanDiags[1].setAttribute('y2',p[fk[3]].y);
fanDiags[2].setAttribute('x1',p[fk[0]].x); fanDiags[2].setAttribute('y1',p[fk[0]].y);
fanDiags[2].setAttribute('x2',p[fk[4]].x); fanDiags[2].setAttribute('y2',p[fk[4]].y);
triFills[0].setAttribute('points',pts2str([p[fk[0]],p[fk[1]],p[fk[2]]]));
triFills[1].setAttribute('points',pts2str([p[fk[0]],p[fk[2]],p[fk[3]]]));
triFills[2].setAttribute('points',pts2str([p[fk[0]],p[fk[3]],p[fk[4]]]));
triFills[3].setAttribute('points',pts2str([p[fk[0]],p[fk[4]],p[fk[5]]]));
// Triangle bars
var triDefs=[[fk[0],fk[1],fk[2]],[fk[0],fk[2],fk[3]],[fk[0],fk[3],fk[4]],[fk[0],fk[4],fk[5]]];
triDefs.forEach(function(tri,ti){
triRowLabels[ti].textContent='\u25b3 '+tri[0]+tri[1]+tri[2];
var a0=triAngle(p[tri[0]],p[tri[1]],p[tri[2]]);
var a1=triAngle(p[tri[1]],p[tri[0]],p[tri[2]]);
var a2=triAngle(p[tri[2]],p[tri[0]],p[tri[1]]);
var angs=[a0,a1,a2], x=0, BAR_W=160;
tri.forEach(function(k,si){
var w=(angs[si]/180)*BAR_W;
triBarSegs[ti][si].setAttribute('fill',COLORS[k]);
triBarSegs[ti][si].setAttribute('x',x);
triBarSegs[ti][si].setAttribute('width',w);
x+=w;
});
});
// ── Quad split ────────────────────────────────────────────────────
var qs=findQuadSplit(p,area);
quadDiag.setAttribute('x1',p[qs.q1[0]].x); quadDiag.setAttribute('y1',p[qs.q1[0]].y);
quadDiag.setAttribute('x2',p[qs.q1[2]].x); quadDiag.setAttribute('y2',p[qs.q1[2]].y);
pie1Name.textContent='\u25a1 '+qs.q1.join('');
pie2Name.textContent='\u25a1 '+qs.q2.join('');
updateQuadPie(pie1SVG, qs.q1, p, area);
updateQuadPie(pie2SVG, qs.q2, p, area);
// Panel values
var sum=0;
KEYS.forEach(function(k){
sum+=angles[k];
valEls[k].textContent=angles[k].toFixed(1)+'\u00b0';
barEls[k].style.width=(angles[k]/720*100)+'%';
});
totalVal.textContent=sum.toFixed(1)+'\u00b0';
}
// ── Drag (mouse + touch) ──────────────────────────────────────────────
function tryMove(nx,ny) {
var cand={}; KEYS.forEach(function(k){cand[k]={x:pos[k].x,y:pos[k].y};});
cand[dragKey]={x:nx,y:ny};
if(!isSelfIntersecting(cand)){pos[dragKey]={x:nx,y:ny};applyPos();
updatePoly();}
}
function svgPoint(clientX, clientY) {
var pt = svgEl.createSVGPoint();
pt.x = clientX;
pt.y = clientY;
return pt.matrixTransform(svgEl.getScreenCTM().inverse());
}
// Mouse
KEYS.forEach(function(k){
circs[k].addEventListener('mousedown',function(e){dragKey=k;e.preventDefault();});
});
svgEl.addEventListener('mousemove',function(e){
if(!dragKey) return;
var pt=svgPoint(e.clientX,e.clientY); tryMove(pt.x,pt.y);
});
svgEl.addEventListener('mouseup', function(){dragKey=null;});
svgEl.addEventListener('mouseleave',function(){dragKey=null;});
// Touch
svgEl.addEventListener('touchstart', function(e) {
e.preventDefault();
var pt = svgPoint(e.touches[0].clientX, e.touches[0].clientY);
var best = null, bestDist = 40;
KEYS.forEach(function(k) {
var d = Math.hypot(pos[k].x - pt.x, pos[k].y - pt.y);
if (d < bestDist) { bestDist = d; best = k; }
});
if (best) dragKey = best;
}, {passive: false});
svgEl.addEventListener('touchmove', function(e) {
if (!dragKey) return;
e.preventDefault();
var pt = svgPoint(e.touches[0].clientX, e.touches[0].clientY);
tryMove(pt.x, pt.y);
}, {passive: false});
svgEl.addEventListener('touchend', function() { dragKey = null; });
svgEl.addEventListener('touchcancel', function() { dragKey = null; });
resetBtn.addEventListener('click', function(){resetPos(initPos);updatePoly();});
updatePoly();
// Footer branding
var footer = document.createElement("div");
footer.className = "hw-footer";
var fLogo = document.createElement("a");
fLogo.className = "hw-footer-icon";
fLogo.href = mw.util.getUrl('Wikipedia:Hiruwiki');
fLogo.title = 'Hiruwiki';
fLogo.innerHTML = hiruwiki.getLogoSvg(22);
var fText = document.createElement("span");
fText.innerHTML = t('hint');
footer.appendChild(fLogo);
footer.appendChild(fText);
wrapper.appendChild(footer);
}
document.querySelectorAll('.hiruwiki[data-module="hexagon-angles"]').forEach(function(el){
initHexagonAngles(el);
});
}() );
jtb6yydtl2kklbplh7nv1qdyrtrpogj
MediaWiki:Hiruwiki/modules/incentre.js
8
179497
781431
770224
2026-10-28T10:39:26Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781431
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/* =========================================================
* Hiruwiki module: incentre
* Visualisation of the incircle inscribed in a triangle
* =========================================================
* i18n: add a new key to the `messages` object below and
* a matching entry for every language code you need.
* Fall back to 'en' when the wiki language is absent.
* ========================================================= */
( function () {
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"nl": {
"_name": "Aangeschreven middelpunt",
"hint": "Sleep een hoekpunt om de ingeschreven cirkel te verkennen"
},
"fr": {
"_name": "Incentre",
"hint": "Tirez n'importe quel sommet pour explorer l'circoncircle"
},
"tr": {
"_name": "İç teğet çemberi merkezi",
"hint": "Herhangi bir köşeyi sürükleyin · Ölçek: 1 cm = 40 px"
},
"eu": {
"_name": "Inzentroa",
"hint": "Erpinak mugitu ditzakezu zirkulu inskribuatua ikusteko"
},
"sk": {
"_name": "Stred vpísanej kružnice",
"hint": "Potiahnutím ľubovoľného vrcholu preskúmate vpísanú kružnicu"
},
"skr-arab": {
"_name": "اندرونی گرد مرکز",
"hint": "اندرونی دائرہ ݙیکھݨ کیتے کوئی وی سرا گھسیٹو"
},
"ko": {
"_name": "내심",
"hint": "꼭짓점을 드래그해서 내접원을 탐구하세요"
},
"en": {
"_name": "Incentre",
"hint": "Drag any vertex to explore the incircle"
},
"it": {
"hint": "Trascina un vertice qualsiasi per esplorare il cerchio"
},
"qqq": {
"_name": "Name of the Incentre module",
"hint": "Instruction text for the interactive triangle."
},
"ga": {
"_name": "Lárionad",
"hint": "Tarraing aon bhuaicphointe chun an ciorcal istigh a iniúchadh"
},
"es": {
"_name": "Incentro",
"hint": "Arrastra un vértice para explorar el incírculo"
},
"mk": {
"_name": "Центар на впишана кружница",
"hint": "Повлечете некое теме за да ја истражите впишаната кружница"
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
/* ----------------------------------------------------------
* Constants
* ---------------------------------------------------------- */
var SCALE = 40; /* pixels per centimetre */
var RULER_H = 0; /* ruler removed */
var DPR = window.devicePixelRatio || 1;
/* ----------------------------------------------------------
* Geometry helpers
* ---------------------------------------------------------- */
function dist( a, b ) {
return Math.hypot( b.x - a.x, b.y - a.y );
}
function incircle( A, B, C ) {
var a = dist( B, C ), b = dist( C, A ), c = dist( A, B );
var perim = a + b + c;
var s = perim / 2;
var area = Math.abs(
( B.x - A.x ) * ( C.y - A.y ) - ( C.x - A.x ) * ( B.y - A.y )
) / 2;
var r = area / s;
var ix = ( a * A.x + b * B.x + c * C.x ) / perim;
var iy = ( a * A.y + b * B.y + c * C.y ) / perim;
return { r: r, ix: ix, iy: iy, a: a, b: b, c: c, s: s, area: area };
}
function footPerp( P, V1, V2 ) {
var dx = V2.x - V1.x, dy = V2.y - V1.y;
var t = ( ( P.x - V1.x ) * dx + ( P.y - V1.y ) * dy ) / ( dx * dx + dy * dy );
return { x: V1.x + t * dx, y: V1.y + t * dy };
}
function angleDeg( V, A, B ) {
var ax = A.x - V.x, ay = A.y - V.y;
var bx = B.x - V.x, by = B.y - V.y;
var cos = ( ax * bx + ay * by ) /
( Math.hypot( ax, ay ) * Math.hypot( bx, by ) );
return Math.acos( Math.max( -1, Math.min( 1, cos ) ) ) * 180 / Math.PI;
}
function bisectorDir( V, A, B ) {
var la = Math.hypot( A.x - V.x, A.y - V.y );
var lb = Math.hypot( B.x - V.x, B.y - V.y );
var ux = ( A.x - V.x ) / la + ( B.x - V.x ) / lb;
var uy = ( A.y - V.y ) / la + ( B.y - V.y ) / lb;
var l = Math.hypot( ux, uy );
return { x: ux / l, y: uy / l };
}
function labelOffset( V, others, mag ) {
var dx = 0, dy = 0;
others.forEach( function ( O ) { dx += V.x - O.x; dy += V.y - O.y; } );
var l = Math.hypot( dx, dy ) || 1;
return { x: V.x + dx / l * mag, y: V.y + dy / l * mag };
}
function toCm( px ) { return px / SCALE; }
function fmt( n, d ) { return n.toFixed( d !== undefined ? d : 2 ); }
function toArea( px2 ) { return px2 / ( SCALE * SCALE ); }
/* ----------------------------------------------------------
* Build DOM for one container element
* ---------------------------------------------------------- */
function buildWidget( container ) {
var lang = ( window.mw && mw.config.get( 'wgUserLanguage' ) ) || 'en';
lang = lang.split( '-' )[ 0 ];
if ( !messages[ lang ] ) { lang = 'en'; }
/* -- HTML skeleton -- */
container.innerHTML =
'<div class="hw-incentre">' +
'<canvas class="hw-canvas"></canvas>' +
'</div>';
// Footer — hint text from this module's own i18n, not loaded externally
var footer = document.createElement( 'div' );
footer.className = 'hw-footer';
var fLogo = document.createElement( 'a' );
fLogo.className = 'hw-footer-icon';
fLogo.href = mw.util.getUrl('Wikipedia:Hiruwiki');
fLogo.title = 'Hiruwiki';
fLogo.innerHTML = hiruwiki.getLogoSvg(22);
var fText = document.createElement( 'span' );
fText.innerHTML = t( 'hint' );
footer.appendChild( fLogo );
footer.appendChild( fText );
container.appendChild( footer );
var canvas = container.querySelector( '.hw-canvas' );
var ctx = canvas.getContext( '2d' );
var W, H, verts, dragging = -1;
/* -- vertex initialisation -- */
function initVerts() {
var ox = W / 2 - 5.5 * SCALE;
var oy = 20;
verts = [
{ x: ox + 5.5 * SCALE, y: oy + 1.2 * SCALE },
{ x: ox + 1.2 * SCALE, y: oy + 8.0 * SCALE },
{ x: ox + 9.8 * SCALE, y: oy + 8.0 * SCALE }
];
}
/* -- colour palette -- */
function palette() {
return {
tri: hiruwiki.getThemeColor('color-progressive', '#534AB7'),
circ: hiruwiki.getThemeColor('color-success', '#0F6E56'),
rad: hiruwiki.getThemeColor('color-warning', '#BA7517'),
bis: hiruwiki.getThemeColor('background-color-progressive-subtle', 'rgba(83,74,183,.20)'),
touch: hiruwiki.getThemeColor('color-destructive', '#993556'),
contact: hiruwiki.getThemeColor('color-error', '#E24B4A'),
text: hiruwiki.getThemeColor('color-base', '#1a1a18'),
grid: hiruwiki.getThemeColor('border-color-base', '#B4B2A9'),
dotBg: hiruwiki.getThemeColor('background-color-base', '#ffffff')
};
}
/* -- right-angle mark -- */
function drawRightAngle( T, I, V1, V2, color ) {
var sdx = V2.x - V1.x, sdy = V2.y - V1.y;
var sl = Math.hypot( sdx, sdy );
var su = { x: sdx / sl, y: sdy / sl };
var rdx = I.x - T.x, rdy = I.y - T.y;
var rl = Math.hypot( rdx, rdy );
var ru = { x: rdx / rl, y: rdy / rl };
var SZ = 12;
ctx.strokeStyle = color;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo( T.x + su.x * SZ, T.y + su.y * SZ );
ctx.lineTo( T.x + su.x * SZ + ru.x * SZ, T.y + su.y * SZ + ru.y * SZ );
ctx.lineTo( T.x + ru.x * SZ, T.y + ru.y * SZ );
ctx.stroke();
}
/* -- grid (major cm lines only, very faint) -- */
function drawGrid( p ) {
ctx.lineWidth = 0.5;
ctx.strokeStyle = p.grid;
ctx.globalAlpha = 0.18;
/* vertical lines */
for ( var cm = 0; cm * SCALE < W + SCALE; cm++ ) {
ctx.beginPath();
ctx.moveTo( cm * SCALE, 0 );
ctx.lineTo( cm * SCALE, H );
ctx.stroke();
}
/* horizontal lines */
for ( var row = 0; row * SCALE < H + SCALE; row++ ) {
ctx.beginPath();
ctx.moveTo( 0, row * SCALE );
ctx.lineTo( W, row * SCALE );
ctx.stroke();
}
ctx.globalAlpha = 1.0;
}
/* -- main draw -- */
function draw() {
if ( !verts ) { return; }
ctx.clearRect( 0, 0, W, H );
var p = palette();
drawGrid( p );
var A = verts[ 0 ], B = verts[ 1 ], C = verts[ 2 ];
var ic = incircle( A, B, C );
var r = ic.r, ix = ic.ix, iy = ic.iy;
var a = ic.a, b = ic.b, c = ic.c;
var s = ic.s, area = ic.area;
var I = { x: ix, y: iy };
/* single source of truth for touch points */
var T_a = footPerp( I, B, C );
var T_b = footPerp( I, C, A );
var T_c = footPerp( I, A, B );
/* 1. angle bisectors */
ctx.save();
ctx.strokeStyle = p.bis; ctx.lineWidth = 1.2; ctx.setLineDash( [ 5, 5 ] );
[ [ A, B, C ], [ B, A, C ], [ C, A, B ] ].forEach( function ( trio ) {
var d = bisectorDir( trio[ 0 ], trio[ 1 ], trio[ 2 ] );
ctx.beginPath();
ctx.moveTo( trio[ 0 ].x, trio[ 0 ].y );
ctx.lineTo( trio[ 0 ].x + d.x * 600, trio[ 0 ].y + d.y * 600 );
ctx.stroke();
} );
ctx.setLineDash( [] ); ctx.restore();
/* 2. triangle */
ctx.save();
ctx.beginPath();
ctx.moveTo( A.x, A.y ); ctx.lineTo( B.x, B.y ); ctx.lineTo( C.x, C.y ); ctx.closePath();
ctx.fillStyle = p.dark ? 'rgba(175,169,236,.07)' : 'rgba(83,74,183,.06)';
ctx.fill();
ctx.strokeStyle = p.tri; ctx.lineWidth = 2; ctx.stroke();
ctx.restore();
/* 3. incircle */
ctx.save();
ctx.beginPath(); ctx.arc( ix, iy, r, 0, Math.PI * 2 );
ctx.fillStyle = p.dark ? 'rgba(93,202,165,.10)' : 'rgba(29,158,117,.09)';
ctx.fill();
ctx.strokeStyle = p.circ; ctx.lineWidth = 2; ctx.stroke();
ctx.restore();
/* 4. vertex → incentre lines */
ctx.save();
ctx.strokeStyle = p.dark ? 'rgba(175,169,236,.4)' : 'rgba(83,74,183,.35)';
ctx.lineWidth = 1;
[ A, B, C ].forEach( function ( V ) {
ctx.beginPath(); ctx.moveTo( V.x, V.y ); ctx.lineTo( ix, iy ); ctx.stroke();
} );
ctx.restore();
/* 5. amber dashed radii + right-angle marks */
ctx.save();
[ [ T_a, B, C ], [ T_b, C, A ], [ T_c, A, B ] ].forEach( function ( t ) {
var T = t[ 0 ], V1 = t[ 1 ], V2 = t[ 2 ];
ctx.strokeStyle = p.rad; ctx.lineWidth = 1.4; ctx.setLineDash( [ 4, 3 ] );
ctx.beginPath(); ctx.moveTo( ix, iy ); ctx.lineTo( T.x, T.y ); ctx.stroke();
ctx.setLineDash( [] );
drawRightAngle( T, I, V1, V2, p.rad );
} );
ctx.restore();
/* 6. red dashed contact triangle */
ctx.save();
ctx.strokeStyle = p.contact; ctx.lineWidth = 1.8; ctx.setLineDash( [ 6, 4 ] );
ctx.beginPath();
ctx.moveTo( T_a.x, T_a.y );
ctx.lineTo( T_b.x, T_b.y );
ctx.lineTo( T_c.x, T_c.y );
ctx.closePath();
ctx.stroke();
ctx.setLineDash( [] ); ctx.restore();
/* 7. touch-point dots */
[ T_a, T_b, T_c ].forEach( function ( T ) {
ctx.save();
ctx.beginPath(); ctx.arc( T.x, T.y, 5, 0, Math.PI * 2 );
ctx.fillStyle = p.touch; ctx.fill();
ctx.restore();
} );
/* 8. incentre dot */
ctx.save();
ctx.beginPath(); ctx.arc( ix, iy, 8, 0, Math.PI * 2 );
ctx.fillStyle = p.circ; ctx.fill();
ctx.strokeStyle = p.dotBg; ctx.lineWidth = 2; ctx.stroke();
ctx.restore();
/* 9. vertex dots */
verts.forEach( function ( V, i ) {
ctx.save();
ctx.beginPath(); ctx.arc( V.x, V.y, dragging === i ? 8 : 6.5, 0, Math.PI * 2 );
ctx.fillStyle = p.tri; ctx.fill();
ctx.strokeStyle = p.dotBg; ctx.lineWidth = 2; ctx.stroke();
ctx.restore();
} );
/* 10. text labels */
ctx.save();
ctx.textBaseline = 'middle';
/* vertex names */
ctx.font = "italic 700 18px 'Latin Modern Math', 'STIX Two Math', 'Computer Modern', 'Times New Roman', serif";
[ A, B, C ].forEach( function ( V, i ) {
var lbl = labelOffset( V, verts.filter( function ( _, j ) { return j !== i; } ), 26 );
ctx.fillStyle = p.tri; ctx.textAlign = 'center';
ctx.fillText( [ 'A', 'B', 'C' ][ i ], lbl.x, lbl.y );
} );
/* incentre label */
ctx.fillStyle = p.circ;
ctx.font = "italic 700 18px 'Latin Modern Math', 'STIX Two Math', 'Computer Modern', 'Times New Roman', serif";
ctx.textAlign = 'center';
ctx.fillText( 'I', ix + 16, iy - 16 );
/* radius label (offset perpendicular to the radius line) — side measurements removed */
ctx.fillStyle = p.rad;
ctx.font = "italic 500 14px 'Latin Modern Math', 'STIX Two Math', 'Computer Modern', 'Times New Roman', serif";
var mid = { x: ( ix + T_c.x ) / 2, y: ( iy + T_c.y ) / 2 };
var rdx = T_c.x - ix, rdy = T_c.y - iy, rl = Math.hypot( rdx, rdy );
ctx.textAlign = 'center';
ctx.fillText(
'r',
mid.x - rdy / rl * 16,
mid.y + rdx / rl * 16
);
ctx.restore();
}
/* -- resize -- */
function resize() {
W = canvas.offsetWidth;
H = 440;
canvas.width = W * DPR;
canvas.height = H * DPR;
ctx.setTransform( DPR, 0, 0, DPR, 0, 0 );
initVerts();
draw();
}
/* -- pointer helpers -- */
function ptFromEvent( e ) {
var rect = canvas.getBoundingClientRect();
var src = e.touches ? e.touches[ 0 ] : e;
return {
x: ( src.clientX - rect.left ) * ( W / rect.width ),
y: ( src.clientY - rect.top ) * ( H / rect.height )
};
}
function hitTest( pt ) {
return verts.findIndex( function ( v ) {
return Math.hypot( v.x - pt.x, v.y - pt.y ) < 16;
} );
}
function clamp( pt ) {
return {
x: Math.max( 10, Math.min( W - 10, pt.x ) ),
y: Math.max( 4, Math.min( H - 10, pt.y ) )
};
}
canvas.addEventListener( 'mousedown', function ( e ) { dragging = hitTest( ptFromEvent( e ) ); if ( dragging >= 0 ) { canvas.style.cursor = 'grabbing'; } } );
canvas.addEventListener( 'mousemove', function ( e ) {
if ( dragging < 0 ) {
canvas.style.cursor = hitTest( ptFromEvent( e ) ) >= 0 ? 'grab' : 'crosshair';
return;
}
verts[ dragging ] = clamp( ptFromEvent( e ) );
draw();
} );
canvas.addEventListener( 'mouseup', function () { dragging = -1; canvas.style.cursor = 'crosshair'; } );
canvas.addEventListener( 'mouseleave', function () { dragging = -1; } );
canvas.addEventListener( 'touchstart', function ( e ) {
e.preventDefault(); dragging = hitTest( ptFromEvent( e ) );
}, { passive: false } );
canvas.addEventListener( 'touchmove', function ( e ) {
e.preventDefault();
if ( dragging < 0 ) { return; }
verts[ dragging ] = clamp( ptFromEvent( e ) );
draw();
}, { passive: false } );
canvas.addEventListener( 'touchend', function () { dragging = -1; } );
/* dark-mode live update (MediaWiki) */
var observer = new MutationObserver( draw );
observer.observe( document.documentElement, { attributes: true, attributeFilter: ['class'] } );
observer.observe( document.body, { attributes: true, attributeFilter: ['class'] } );
/* kick off */
var ro = new ResizeObserver( resize );
ro.observe( canvas );
}
/* ----------------------------------------------------------
* Initialise all matching containers on the page
* ---------------------------------------------------------- */
document.querySelectorAll( '.hiruwiki[data-module="incentre"]' ).forEach( buildWidget );
}() );
76674k0yqp683ai9maa6j1vziefba9x
MediaWiki:Hiruwiki/modules/integer-coordinates.js
8
179499
781432
770226
2026-10-28T10:39:29Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781432
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
( function () {
'use strict';
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"nl": {
"_name": "Integercoördinaten",
"btnChallenge": "🎯 Uitdaging",
"btnCheck": "Controleren ✓",
"btnExplore": "🔭 Verkennen",
"btnNext": "Volgende →",
"feedbackCorrect": "✓ Klopt!",
"feedbackWrong": "✗ Onjuist. De zwakke ster was het juiste antwoord.",
"hintChallenge": "Verplaats ★ naar de voorgestelde coördinaten en klik op Controleren",
"hintExplore": "Verplaats ★ door erop te klikken — springt naar hele getallen",
"movePrompt": "Verplaats ★ naar:<br>($1, $2)",
"pointLabel": "Punt:",
"titleExplore": "Verken het coördinatensysteem",
"hint": "Verplaats de ster om de coördinaten te verkennen"
},
"fr": {
"_name": "Coordonnées entières",
"btnChallenge": "🎯 Défi",
"btnCheck": "Vérifier ✓",
"btnExplore": "🔭 Explorer",
"btnNext": "Suivant →",
"feedbackCorrect": "✓ Correct !",
"feedbackWrong": "✗ Incorrect. L'étoile pâle était la bonne réponse.",
"hintChallenge": "Déplacez ★ vers les coordonnées proposées et cliquez sur Vérifier",
"hintExplore": "Déplacez ★ en cliquant dessus — il s'accroche aux entiers",
"movePrompt": "Déplacez ★ vers :<br>($1, $2)",
"pointLabel": "Point :",
"titleExplore": "Explorer le système de coordonnées",
"hint": "Déplacez l'étoile pour explorer les coordonnées"
},
"eu": {
"_name": "Koordenatu positiboak",
"btnChallenge": "🎯 Erronka",
"btnCheck": "Zuzendu ✓",
"btnExplore": "🔭 Esploratu",
"btnNext": "Hurrengoa →",
"feedbackCorrect": "✓ Zuzen!",
"feedbackWrong": "✗ Ez da zuzena. Izar ahula zen erantzun zuzena.",
"hintChallenge": "Mugi ezazu ★ proposatutako koordenatuetara eta klik egin Zuzendu botoian",
"hintExplore": "Mugi ezazu ★ bere gainean klik eginez — zenbaki arruntak erabiltzen ditu",
"movePrompt": "Mugitu ★ hona:<br>($1, $2)",
"pointLabel": "Puntua:",
"titleExplore": "Esploratu koordenatuen sistema",
"hint": "Mugitu izarra koordenatuak aztertzeko"
},
"sk": {
"_name": "Celočíselné súradnice",
"btnChallenge": "🎯 Výzva",
"btnCheck": "Skontrolovať ✓",
"btnExplore": "🔭 Skúmať",
"btnNext": "Ďalej →",
"feedbackCorrect": "✓ Správne!",
"feedbackWrong": "✗ Nesprávne. Správnou odpoveďou bola bledá hviezda.",
"hintChallenge": "Presuňte ★ na navrhnuté súradnice a kliknite na Skontrolovať",
"hintExplore": "Presuňte ★ kliknutím naň — prichytí sa na celé čísla",
"movePrompt": "Presuňte ★ na:<br>($1, $2)",
"pointLabel": "Bod:",
"titleExplore": "Skúmať súradnicovú sústavu",
"hint": "Presúvaním hviezdy skúmajte súradnice"
},
"skr-arab": {
"_name": "صحیح مختصات",
"btnChallenge": "🎯 چیلنج",
"btnCheck": "چیک کرو ✓",
"btnExplore": "🔭 کھوج",
"btnNext": "اڳلا ←",
"feedbackCorrect": "✓ ٹھیک!",
"feedbackWrong": "✗ غلط۔ مدھم ستارا ٹھیک جواب ہا۔",
"hintChallenge": "★ کوں مجوزہ مختصات تے گھن ونجو تے چیک دباؤ",
"hintExplore": "★ تے کلک کر کے اونکوں ہلاو — اوہ صحیح اعداد تے ٹک ویندے",
"movePrompt": "★ کوں اتھاں گھن ونجو:<br>($1, $2)",
"pointLabel": "نقطہ:",
"titleExplore": "مختصاتی نظام دی کھوج",
"hint": "ستارے کوں ہلا کے مختصات دی کھوج کرو"
},
"lb": {
"_name": "Ganzzueleg Koordinaten",
"pointLabel": "Punkt:"
},
"ko": {
"_name": "정수 좌표",
"btnChallenge": "🎯 도전",
"btnCheck": "확인 ✓",
"btnExplore": "🔭 탐색",
"btnNext": "다음 →",
"feedbackCorrect": "✓ 정답입니다!",
"feedbackWrong": "✗ 틀렸습니다. 희미한 별이 정답입니다.",
"hintChallenge": "★ 표시를 제시된 좌표로 이동한 후 확인을 클릭하세요",
"hintExplore": "★를 클릭하여 이동하세요. 정수 위치에 맞춰집니다",
"movePrompt": "★을 다음으로 이동:<br>($1, $2)",
"pointLabel": "점수:",
"titleExplore": "좌표계를 탐색하세요",
"hint": "별을 움직여 좌표를 탐색하세요"
},
"en": {
"_name": "Integer Coordinates",
"btnChallenge": "🎯 Challenge",
"btnCheck": "Check ✓",
"btnExplore": "🔭 Explore",
"btnNext": "Next →",
"feedbackCorrect": "✓ Correct!",
"feedbackWrong": "✗ Incorrect. The faint star was the correct answer.",
"hintChallenge": "Move ★ to the proposed coordinates and click Check",
"hintExplore": "Move ★ by clicking on it — it snaps to integers",
"movePrompt": "Move ★ to:<br>($1, $2)",
"pointLabel": "Point:",
"titleExplore": "Explore Coordinate System",
"hint": "Move the star to explore coordinates"
},
"it": {
"_name": "Coordinate intere",
"btnNext": "Successivo →",
"feedbackCorrect": "✓ Corretto!",
"pointLabel": "Punto:"
},
"ca": {
"btnNext": "Endavant →",
"feedbackCorrect": "✓ Correcte!"
},
"qqq": {
"_name": "Name of the Positive Coordinates module (integer grid variant)",
"btnChallenge": "Button label to switch to challenge mode",
"btnCheck": "Button label to check the player's answer",
"btnExplore": "Button label to switch to explore mode",
"btnNext": "Button label to proceed to the next challenge",
"feedbackCorrect": "Feedback shown when the player places the star correctly",
"feedbackWrong": "Feedback shown when the player places the star incorrectly",
"hintChallenge": "Instruction text shown in challenge mode",
"hintExplore": "Instruction text shown in explore mode",
"movePrompt": "Prompt telling the player where to move the star. Uses HTML. Parameters: $1 = target X coordinate, $2 = target Y coordinate.",
"pointLabel": "Label for the current point coordinates. Followed by coordinate values.",
"titleExplore": "Title shown in explore mode",
"hint": "Instruction text for the coordinate system interaction"
},
"ga": {
"_name": "Comhordanáidí Slánuimhreacha",
"btnChallenge": "🎯 Dúshlán",
"btnCheck": "Seiceáil ✓",
"btnExplore": "🔭 Taiscéal",
"btnNext": "Ar Aghaidh →",
"feedbackCorrect": "✓ Ceart!",
"feedbackWrong": "✗ Mícheart. Ba é an réalta lag an freagra ceart.",
"hintChallenge": "Bog ★ go dtí na comhordanáidí atá beartaithe agus cliceáil Seiceáil",
"hintExplore": "Bog ★ trí chliceáil air — snapálann sé chuig slánuimhreacha",
"movePrompt": "Bog ★ go:<br>($1, $2)",
"pointLabel": "Pointe:",
"titleExplore": "Iniúchadh a dhéanamh ar an gCóras Comhordanáidí",
"hint": "Bog an réalta chun comhordanáidí a iniúchadh"
},
"es": {
"_name": "Coordenadas positivas",
"btnChallenge": "🎯 Reto",
"btnCheck": "Comprobar ✓",
"btnExplore": "🔭 Explorar",
"btnNext": "Siguiente →",
"feedbackCorrect": "✓ ¡Correcto!",
"feedbackWrong": "✗ Incorrecto. La estrella tenue era la respuesta correcta.",
"hintChallenge": "Mueve ★ a las coordenadas propuestas y pulsa Comprobar",
"hintExplore": "Mueve ★ haciendo clic en él — se ajusta a enteros",
"movePrompt": "Mueve ★ a:<br>($1, $2)",
"pointLabel": "Punto:",
"titleExplore": "Explorar el sistema de coordenadas",
"hint": "Mueve la estrella para explorar las coordenadas"
},
"mk": {
"_name": "Целобројни координати",
"btnChallenge": "🎯 Предизвик",
"btnCheck": "Провери ✓",
"btnExplore": "🔭 Истражи",
"btnNext": "Следно →",
"feedbackCorrect": "✓ Точно!",
"feedbackWrong": "✗ Неточно. Слабата ѕвезда е точниот одговор.",
"hintChallenge": "Поместете го ★ на предложените координати и стиснете на Провери",
"hintExplore": "Поместете го ★ стискајќи на него — се сведува на цели броеви",
"movePrompt": "Поместете го ★ на:<br>($1, $2)",
"pointLabel": "Точка:",
"titleExplore": "Истражете го координатниот систем",
"hint": "Поместете ја ѕвездата за да истражите координати"
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
document.querySelectorAll( '.hiruwiki[data-module="integer-coordinates"]' ).forEach( function ( host ) {
host.innerHTML = [
'<div class="icx-wrapper">',
' <div class="icx-mode-bar">',
' <button class="icx-mode-btn icx-active" data-mode="explorer">' + t('btnExplore') + '</button>',
' <button class="icx-mode-btn" data-mode="challenge">' + t('btnChallenge') + '</button>',
' </div>',
' <div class="icx-body">',
' <canvas class="icx-canvas"></canvas>',
' <div class="icx-sidebar">',
' <div class="icx-explorer-panel">',
' <div class="icx-sidebar-title">' + t('titleExplore') + '</div>',
' <div class="icx-info-bar">',
' ' + t('pointLabel') + '<br>',
' (<span class="icx-cx"></span>, <span class="icx-cy"></span>)',
' </div>',
' <div class="icx-hint">' + t('hintExplore') + '</div>',
' </div>',
' <div class="icx-challenge-panel">',
' <div class="icx-challenge-prompt"></div>',
' <div class="icx-feedback"></div>',
' <div class="icx-btn-col">',
' <button class="icx-action-btn icx-check-btn">' + t('btnCheck') + '</button>',
' <button class="icx-action-btn icx-next-btn">' + t('btnNext') + '</button>',
' </div>',
' <div class="icx-hint">' + t('hintChallenge') + '</div>',
' </div>',
' </div>',
' </div>',
'</div>'
].join( '\n' );
initInstance( host );
// Footer branding
var footer = document.createElement("div");
footer.className = "hw-footer";
var fLogo = document.createElement("a");
fLogo.className = "hw-footer-icon";
fLogo.href = mw.util.getUrl('Wikipedia:Hiruwiki');
fLogo.title = 'Hiruwiki';
fLogo.innerHTML = hiruwiki.getLogoSvg(22);
var fText = document.createElement("span");
fText.innerHTML = t('hint');
footer.appendChild(fLogo);
footer.appendChild(fText);
host.appendChild(footer);
} );
function initInstance( host ) {
var canvas = host.querySelector( '.icx-canvas' );
var ctx = canvas.getContext( '2d' );
var btnExplorer = host.querySelector( '[data-mode="explorer"]' );
var btnChallenge = host.querySelector( '[data-mode="challenge"]' );
var explorerPanel = host.querySelector( '.icx-explorer-panel' );
var challengePanel = host.querySelector( '.icx-challenge-panel' );
var promptBar = host.querySelector( '.icx-challenge-prompt' );
var spCX = host.querySelector( '.icx-cx' );
var spCY = host.querySelector( '.icx-cy' );
var feedbackEl = host.querySelector( '.icx-feedback' );
var checkBtn = host.querySelector( '.icx-check-btn' );
var nextBtn = host.querySelector( '.icx-next-btn' );
/* -- Grid constants -- */
var X_MIN = -12;
var X_MAX = 12;
var Y_MIN = -8;
var Y_MAX = 8;
var X_STEPS = X_MAX - X_MIN; /* 24 */
var Y_STEPS = Y_MAX - Y_MIN; /* 16 */
function isDark() {
var root = document.documentElement;
if ( root.classList.contains( 'skin-theme-clientpref-night' ) ||
root.classList.contains( 'client-dark-mode' ) ||
document.body.classList.contains( 'mw-dark-mode' ) ) {
return true;
}
return !!( window.matchMedia && window.matchMedia( '(prefers-color-scheme: dark)' ).matches );
}
function themeColor( token, lightFallback, darkFallback ) {
return hiruwiki.getThemeColor( token, isDark() ? darkFallback : lightFallback );
}
function palette() {
return {
x: themeColor( 'color-destructive', '#e74c3c', '#ff7f73' ),
y: themeColor( 'color-progressive', '#2980b9', '#7aa8ff' ),
axis: themeColor( 'color-base', '#2c3e50', '#e8e6dc' ),
bg: themeColor( 'background-color-base', '#fafcff', '#1e1e1c' ),
grid: themeColor( 'border-color-base', '#d5e0ec', '#4a4a48' ),
tickText: themeColor( 'color-subtle', '#555', '#b4b2a9' ),
tickMark: themeColor( 'color-placeholder', '#888', '#888780' ),
minorTick: themeColor( 'border-color-base', '#ccc', '#555552' ),
labelBg: themeColor( 'background-color-base', 'rgba(255,255,255,0.92)', 'rgba(30,30,28,0.95)' ),
labelBorder: themeColor( 'border-color-base', '#bdc3c7', '#555552' )
};
}
var PAD_LEFT = 46;
var PAD_RIGHT = 20;
var PAD_TOP = 20;
var PAD_BOTTOM = 34;
var CELL = 30;
var CW = PAD_LEFT + X_STEPS * CELL + PAD_RIGHT;
var CH = PAD_TOP + Y_STEPS * CELL + PAD_BOTTOM;
canvas.width = CW;
canvas.height = CH;
/* -- State -- */
var ptX = 3, ptY = 4;
var targetX = 0, targetY = 0;
var mode = 'explorer';
var dragging = false;
var checked = false;
var correct = false;
/* ---- Coordinate helpers ---- */
function toCanvas( gx, gy ) {
return {
x: PAD_LEFT + ( gx - X_MIN ) * CELL,
y: PAD_TOP + ( Y_MAX - gy ) * CELL
};
}
function toGrid( cx, cy ) {
var gx = Math.round( ( cx - PAD_LEFT ) / CELL + X_MIN );
var gy = Math.round( Y_MAX - ( cy - PAD_TOP ) / CELL );
return {
gx: Math.max( X_MIN, Math.min( X_MAX, gx ) ),
gy: Math.max( Y_MIN, Math.min( Y_MAX, gy ) )
};
}
/* ---- Drawing helpers ---- */
function drawArrow( x1, y1, x2, y2, color, lw ) {
var headLen = 7;
var angle = Math.atan2( y2 - y1, x2 - x1 );
ctx.save();
ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = lw;
ctx.beginPath(); ctx.moveTo( x1, y1 ); ctx.lineTo( x2, y2 ); ctx.stroke();
ctx.beginPath();
ctx.moveTo( x2, y2 );
ctx.lineTo( x2 - headLen * Math.cos( angle - Math.PI / 7 ), y2 - headLen * Math.sin( angle - Math.PI / 7 ) );
ctx.lineTo( x2 - headLen * Math.cos( angle + Math.PI / 7 ), y2 - headLen * Math.sin( angle + Math.PI / 7 ) );
ctx.closePath(); ctx.fill();
ctx.restore();
}
function drawStar( cx, cy, r, fillColor, strokeColor, glowColor, glowBlur, alpha ) {
var spikes = 5, outerR = r, innerR = r * 0.42;
var rot = ( Math.PI / 2 ) * 3, step = Math.PI / spikes, i;
ctx.save();
ctx.globalAlpha = ( alpha !== undefined ) ? alpha : 1;
ctx.beginPath();
ctx.moveTo( cx, cy - outerR );
for ( i = 0; i < spikes; i++ ) {
ctx.lineTo( cx + Math.cos( rot ) * outerR, cy + Math.sin( rot ) * outerR ); rot += step;
ctx.lineTo( cx + Math.cos( rot ) * innerR, cy + Math.sin( rot ) * innerR ); rot += step;
}
ctx.closePath();
if ( glowColor ) { ctx.shadowColor = glowColor; ctx.shadowBlur = glowBlur || 0; }
ctx.fillStyle = fillColor; ctx.fill();
ctx.shadowBlur = 0; ctx.strokeStyle = strokeColor; ctx.lineWidth = 1.2; ctx.stroke();
ctx.restore();
}
function drawProjectionLines( gx, gy, xColor, yColor, alpha ) {
var pt = toCanvas( gx, gy );
var originC = toCanvas( 0, 0 );
ctx.save();
ctx.globalAlpha = ( alpha !== undefined ) ? alpha : 1;
ctx.setLineDash( [ 4, 3 ] ); ctx.lineWidth = 1.4;
ctx.strokeStyle = xColor;
ctx.beginPath(); ctx.moveTo( pt.x, pt.y ); ctx.lineTo( pt.x, originC.y ); ctx.stroke();
ctx.strokeStyle = yColor;
ctx.beginPath(); ctx.moveTo( pt.x, pt.y ); ctx.lineTo( originC.x, pt.y ); ctx.stroke();
ctx.setLineDash( [] );
ctx.beginPath(); ctx.arc( pt.x, originC.y, 3.5, 0, Math.PI * 2 ); ctx.fillStyle = xColor; ctx.fill();
ctx.beginPath(); ctx.arc( originC.x, pt.y, 3.5, 0, Math.PI * 2 ); ctx.fillStyle = yColor; ctx.fill();
ctx.restore();
}
function drawDimensionBrackets( gx, gy, xColor, yColor, alpha ) {
var pt = toCanvas( gx, gy );
var originC = toCanvas( 0, 0 );
var brk, mid;
ctx.save();
ctx.globalAlpha = ( alpha !== undefined ) ? alpha : 1;
if ( gx !== 0 ) {
brk = originC.y + 18;
ctx.strokeStyle = xColor; ctx.fillStyle = xColor; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo( originC.x, brk ); ctx.lineTo( pt.x, brk ); ctx.stroke();
ctx.beginPath(); ctx.moveTo( originC.x, brk - 3 ); ctx.lineTo( originC.x, brk + 3 ); ctx.stroke();
ctx.beginPath(); ctx.moveTo( pt.x, brk - 3 ); ctx.lineTo( pt.x, brk + 3 ); ctx.stroke();
mid = originC.x + ( pt.x - originC.x ) / 2;
ctx.font = 'bold 9px Montserrat, Arial, sans-serif'; ctx.textAlign = 'center';
ctx.fillText( 'x = ' + gx, mid, brk + 10 );
}
if ( gy !== 0 ) {
brk = ( gx >= 0 ) ? originC.x - 22 : originC.x + 22;
ctx.strokeStyle = yColor; ctx.fillStyle = yColor; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo( brk, originC.y ); ctx.lineTo( brk, pt.y ); ctx.stroke();
ctx.beginPath(); ctx.moveTo( brk - 3, originC.y ); ctx.lineTo( brk + 3, originC.y ); ctx.stroke();
ctx.beginPath(); ctx.moveTo( brk - 3, pt.y ); ctx.lineTo( brk + 3, pt.y ); ctx.stroke();
mid = originC.y + ( pt.y - originC.y ) / 2;
ctx.save();
ctx.translate( brk + ( gx >= 0 ? -10 : 10 ), mid );
ctx.rotate( -Math.PI / 2 );
ctx.font = 'bold 9px Montserrat, Arial, sans-serif'; ctx.textAlign = 'center';
ctx.fillText( 'y = ' + gy, 0, 0 );
ctx.restore();
}
ctx.restore();
}
function drawCoordLabel( gx, gy, alpha, xColor, yColor, pal ) {
var pt = toCanvas( gx, gy );
var FS = 12, xStr = String( gx ), yStr = String( gy );
var p1 = '(', cm = ', ', p2 = ')';
var wP1, wX, wCm, wY, wP2, totalW, lx, ly, cx2;
ctx.save();
ctx.globalAlpha = ( alpha !== undefined ) ? alpha : 1;
ctx.font = 'bold ' + FS + 'px Montserrat, Arial, sans-serif';
wP1 = ctx.measureText( p1 ).width; wX = ctx.measureText( xStr ).width;
wCm = ctx.measureText( cm ).width; wY = ctx.measureText( yStr ).width;
wP2 = ctx.measureText( p2 ).width;
totalW = wP1 + wX + wCm + wY + wP2;
lx = pt.x + 14; ly = pt.y - 14;
if ( lx + totalW + 6 > CW - 2 ) { lx = pt.x - totalW - 14; }
if ( lx < 2 ) { lx = pt.x + 14; }
if ( ly - FS < 2 ) { ly = pt.y + 20; }
ctx.fillStyle = pal.labelBg;
ctx.beginPath(); ctx.roundRect( lx - 3, ly - FS, totalW + 6, FS + 5, 3 ); ctx.fill();
ctx.strokeStyle = pal.labelBorder; ctx.lineWidth = 0.8; ctx.stroke();
cx2 = lx;
ctx.fillStyle = pal.axis; ctx.fillText( p1, cx2, ly ); cx2 += wP1;
ctx.fillStyle = xColor; ctx.fillText( xStr, cx2, ly ); cx2 += wX;
ctx.fillStyle = pal.axis; ctx.fillText( cm, cx2, ly ); cx2 += wCm;
ctx.fillStyle = yColor; ctx.fillText( yStr, cx2, ly ); cx2 += wY;
ctx.fillStyle = pal.axis; ctx.fillText( p2, cx2, ly );
ctx.restore();
}
/* ---- Main draw ---- */
function draw() {
var i, v, x, y, originC, p, pc, tp;
var pal = palette();
ctx.clearRect( 0, 0, CW, CH );
ctx.fillStyle = pal.bg; ctx.fillRect( 0, 0, CW, CH );
originC = toCanvas( 0, 0 );
/* Grid lines */
for ( i = X_MIN; i <= X_MAX; i++ ) {
x = PAD_LEFT + ( i - X_MIN ) * CELL;
ctx.beginPath();
ctx.strokeStyle = ( i === 0 ) ? pal.axis : pal.grid;
ctx.lineWidth = ( i === 0 ) ? 1.8 : 1;
ctx.moveTo( x, PAD_TOP ); ctx.lineTo( x, PAD_TOP + Y_STEPS * CELL ); ctx.stroke();
}
for ( i = Y_MIN; i <= Y_MAX; i++ ) {
y = PAD_TOP + ( Y_MAX - i ) * CELL;
ctx.beginPath();
ctx.strokeStyle = ( i === 0 ) ? pal.axis : pal.grid;
ctx.lineWidth = ( i === 0 ) ? 1.8 : 1;
ctx.moveTo( PAD_LEFT, y ); ctx.lineTo( PAD_LEFT + X_STEPS * CELL, y ); ctx.stroke();
}
/* Axis arrows — both directions */
drawArrow( PAD_LEFT, originC.y, PAD_LEFT + X_STEPS * CELL + 12, originC.y, pal.axis, 1.8 );
drawArrow( PAD_LEFT + X_STEPS * CELL, originC.y, PAD_LEFT - 2, originC.y, pal.axis, 1.8 );
drawArrow( originC.x, PAD_TOP + Y_STEPS * CELL, originC.x, PAD_TOP - 10, pal.axis, 1.8 );
drawArrow( originC.x, PAD_TOP, originC.x, PAD_TOP + Y_STEPS * CELL + 10, pal.axis, 1.8 );
/* Axis labels */
ctx.save();
ctx.font = 'italic bold 12px Montserrat, Arial, sans-serif';
ctx.fillStyle = pal.axis; ctx.textAlign = 'center';
ctx.fillText( 'x', PAD_LEFT + X_STEPS * CELL + 20, originC.y + 4 );
ctx.fillText( 'y', originC.x, PAD_TOP - 14 );
ctx.restore();
/* Ticks and labels */
ctx.save();
ctx.font = '9px Montserrat, Arial, sans-serif'; ctx.fillStyle = pal.tickText;
for ( v = X_MIN; v <= X_MAX; v++ ) {
x = PAD_LEFT + ( v - X_MIN ) * CELL;
if ( v !== 0 && v % 5 === 0 ) {
ctx.textAlign = 'center';
ctx.fillText( v, x, originC.y + 13 );
ctx.beginPath(); ctx.strokeStyle = pal.tickMark; ctx.lineWidth = 1;
ctx.moveTo( x, originC.y - 3 ); ctx.lineTo( x, originC.y + 3 ); ctx.stroke();
} else if ( v !== 0 ) {
ctx.beginPath(); ctx.strokeStyle = pal.minorTick; ctx.lineWidth = 1;
ctx.moveTo( x, originC.y - 2 ); ctx.lineTo( x, originC.y + 2 ); ctx.stroke();
}
}
for ( v = Y_MIN; v <= Y_MAX; v++ ) {
y = PAD_TOP + ( Y_MAX - v ) * CELL;
if ( v !== 0 && v % 5 === 0 ) {
ctx.textAlign = 'right';
ctx.fillText( v, originC.x - 4, y + 3 );
ctx.beginPath(); ctx.strokeStyle = pal.tickMark; ctx.lineWidth = 1;
ctx.moveTo( originC.x - 3, y ); ctx.lineTo( originC.x + 3, y ); ctx.stroke();
} else if ( v !== 0 ) {
ctx.beginPath(); ctx.strokeStyle = pal.minorTick; ctx.lineWidth = 1;
ctx.moveTo( originC.x - 2, y ); ctx.lineTo( originC.x + 2, y ); ctx.stroke();
}
}
ctx.textAlign = 'right'; ctx.fillStyle = pal.tickText;
ctx.fillText( '0', originC.x - 4, originC.y + 12 );
ctx.restore();
/* Mode rendering */
if ( mode === 'explorer' ) {
drawProjectionLines( ptX, ptY, pal.x, pal.y );
drawDimensionBrackets( ptX, ptY, pal.x, pal.y );
p = toCanvas( ptX, ptY );
drawStar( p.x, p.y, 11, '#f1c40f', '#b7950b', 'rgba(241,196,15,0.55)', 10 );
drawCoordLabel( ptX, ptY, 1, pal.x, pal.y, pal );
spCX.style.color = pal.x; spCX.textContent = ptX;
spCY.style.color = pal.y; spCY.textContent = ptY;
}
if ( mode === 'challenge' ) {
if ( checked && !correct ) {
drawProjectionLines( targetX, targetY, pal.x, pal.y, 0.22 );
tp = toCanvas( targetX, targetY );
drawStar( tp.x, tp.y, 11, 'rgba(241,196,15,0.28)', 'rgba(183,149,11,0.35)', null, 0, 1 );
drawCoordLabel( targetX, targetY, 0.28, pal.x, pal.y, pal );
}
drawProjectionLines( ptX, ptY, pal.x, pal.y );
drawDimensionBrackets( ptX, ptY, pal.x, pal.y );
pc = toCanvas( ptX, ptY );
if ( checked && correct ) {
drawStar( pc.x, pc.y, 13, '#2ecc71', '#1a9456', 'rgba(46,204,113,0.8)', 20 );
} else {
drawStar( pc.x, pc.y, 11, '#f1c40f', '#b7950b', 'rgba(241,196,15,0.55)', 10 );
}
drawCoordLabel( ptX, ptY, 1, pal.x, pal.y, pal );
}
}
/* ---- Input handling ---- */
function getPos( e ) {
var rect = canvas.getBoundingClientRect();
var sx = canvas.width / rect.width, sy = canvas.height / rect.height;
var src = e.touches ? e.touches[ 0 ] : e;
return { x: ( src.clientX - rect.left ) * sx, y: ( src.clientY - rect.top ) * sy };
}
function nearStar( cx, cy ) {
var pt = toCanvas( ptX, ptY ), dx = cx - pt.x, dy = cy - pt.y;
return Math.sqrt( dx * dx + dy * dy ) < 45;
}
function onMove( cx, cy ) {
var g = toGrid( cx, cy );
if ( g.gx !== ptX || g.gy !== ptY ) {
ptX = g.gx; ptY = g.gy;
if ( mode === 'challenge' && checked && !correct ) {
checked = false;
feedbackEl.textContent = ''; feedbackEl.className = 'icx-feedback';
checkBtn.disabled = false;
}
draw();
}
}
canvas.addEventListener( 'mousedown', function ( e ) {
if ( mode === 'challenge' && correct ) { return; }
var pos = getPos( e );
if ( nearStar( pos.x, pos.y ) ) { dragging = true; canvas.classList.add( 'icx-dragging' ); }
} );
canvas.addEventListener( 'mousemove', function ( e ) {
if ( !dragging ) { return; }
var pos = getPos( e ); onMove( pos.x, pos.y );
} );
canvas.addEventListener( 'mouseup', function () { dragging = false; canvas.classList.remove( 'icx-dragging' ); } );
canvas.addEventListener( 'mouseleave', function () { dragging = false; canvas.classList.remove( 'icx-dragging' ); } );
canvas.addEventListener( 'touchstart', function ( e ) {
e.preventDefault();
if ( mode === 'challenge' && correct ) { return; }
var pos = getPos( e );
if ( nearStar( pos.x, pos.y ) ) { dragging = true; }
}, { passive: false } );
canvas.addEventListener( 'touchmove', function ( e ) {
e.preventDefault();
if ( !dragging ) { return; }
var pos = getPos( e ); onMove( pos.x, pos.y );
}, { passive: false } );
canvas.addEventListener( 'touchend', function () { dragging = false; } );
/* ---- Mode switching ---- */
function setMode( m ) {
mode = m;
btnExplorer.classList.toggle( 'icx-active', m === 'explorer' );
btnChallenge.classList.toggle( 'icx-active', m === 'challenge' );
explorerPanel.style.display = m === 'explorer' ? 'flex' : 'none';
challengePanel.style.display = m === 'challenge' ? 'flex' : 'none';
if ( m === 'challenge' ) {
nextChallenge();
} else {
ptX = 3; ptY = 4; draw();
}
}
btnExplorer.addEventListener( 'click', function () { setMode( 'explorer' ); } );
btnChallenge.addEventListener( 'click', function () { setMode( 'challenge' ); } );
/* ---- Challenge logic ---- */
function nextChallenge() {
do {
targetX = Math.floor( Math.random() * ( X_MAX - X_MIN + 1 ) ) + X_MIN;
targetY = Math.floor( Math.random() * ( Y_MAX - Y_MIN + 1 ) ) + Y_MIN;
} while ( targetX === 0 && targetY === 0 );
ptX = 0; ptY = 0;
checked = false; correct = false;
feedbackEl.textContent = ''; feedbackEl.className = 'icx-feedback';
checkBtn.disabled = false;
nextBtn.style.visibility = 'hidden';
promptBar.innerHTML = t('movePrompt', [
'<span class="icx-target-x">' + targetX + '</span>',
'<span class="icx-target-y">' + targetY + '</span>'
]);
draw();
}
function checkAnswer() {
checked = true; correct = ( ptX === targetX && ptY === targetY );
if ( correct ) {
feedbackEl.textContent = t('feedbackCorrect');
feedbackEl.className = 'icx-feedback icx-feedback-ok';
} else {
feedbackEl.textContent = t('feedbackWrong');
feedbackEl.className = 'icx-feedback icx-feedback-err';
}
checkBtn.disabled = true;
nextBtn.style.visibility = 'visible';
draw();
}
checkBtn.addEventListener( 'click', checkAnswer );
nextBtn.addEventListener( 'click', nextChallenge );
var observer = new MutationObserver( draw );
observer.observe( document.documentElement, { attributes: true, attributeFilter: [ 'class' ] } );
observer.observe( document.body, { attributes: true, attributeFilter: [ 'class' ] } );
if ( window.matchMedia ) {
window.matchMedia( '(prefers-color-scheme: dark)' ).addEventListener( 'change', draw );
}
/* Initial state */
explorerPanel.style.display = 'flex';
challengePanel.style.display = 'none';
draw();
}
}() );
6485e5ogv4i0rkfsy2kql2w8ghx340n
MediaWiki:Hiruwiki/modules/marble-jar.js
8
179501
781433
770228
2026-10-28T10:39:32Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781433
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
(function () {
'use strict';
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"nl": {
"_name": "Kanspot",
"colorBlue": "Blauw",
"colorGreen": "Groen",
"colorRed": "Rood",
"colorYellow": "Geel",
"impossible": "Onmogelijk",
"probTitle": "Kans om een knikker te pakken en de kleur te raden…",
"shake": "Schudden!",
"total": "Totaal",
"hint": "Schud de pot om de waarschijnlijkheid in actie te zien"
},
"fr": {
"_name": "Pot de probabilités",
"colorBlue": "Bleu",
"colorGreen": "Vert",
"colorRed": "Rouge",
"colorYellow": "Jaune",
"impossible": "Impossible",
"probTitle": "Probabilité de tirer une bille et de deviner sa couleur…",
"shake": "Secouer !",
"total": "Total",
"hint": "Secouez le récipient pour voir la probabilité en action"
},
"tr": {
"_name": "Olasılık Kavanozu",
"colorBlue": "Mavi",
"colorGreen": "Yeşil",
"colorRed": "Kırmızı",
"colorYellow": "Sarı",
"impossible": "İmkansız",
"probTitle": "Bir bilye seçip rengini tahmin etme olasılığı…",
"shake": "Salla!",
"total": "Toplam",
"hint": "Kavanozu sallayın ve olasılığın nasıl işlediğini görün"
},
"eu": {
"_name": "Probabilitate ontzia",
"colorBlue": "Urdina",
"colorGreen": "Berdea",
"colorRed": "Gorria",
"colorYellow": "Horia",
"impossible": "Ezinezkoa",
"probTitle": "Kanika bat atera eta kolorea asmatzeko probabilitatea…",
"shake": "Astindu!",
"total": "Guztira",
"hint": "Astindu ontzia probabilitatea martxan ikusteko"
},
"sk": {
"_name": "Nádoba pravdepodobnosti",
"colorBlue": "Modrá",
"colorGreen": "Zelená",
"colorRed": "Červená",
"colorYellow": "Žltá",
"impossible": "Nemožné",
"probTitle": "Pravdepodobnosť vytiahnutia guľôčky a uhádnutia jej farby…",
"shake": "Zatraste!",
"total": "Spolu",
"hint": "Zatraste nádobou a uvidíte pravdepodobnosť v praxi"
},
"skr-arab": {
"_name": "احتمال دا مرتبان",
"colorBlue": "نیلا",
"colorGreen": "ساوا",
"colorRed": "رتا",
"colorYellow": "پیلا",
"impossible": "نا ممکن",
"probTitle": "گولی چݨݨ تے اوندے رنگ دا اندازہ لاوݨ دا احتمال…",
"shake": "ہلاؤ!",
"total": "کل",
"hint": "احتمال کوں عمل وچ ݙیکھݨ کیتے مرتبان کوں ہلاؤ"
},
"lb": {
"colorBlue": "Blo",
"colorGreen": "Gréng",
"colorRed": "Rout",
"colorYellow": "Giel",
"impossible": "Onméiglech"
},
"ko": {
"_name": "확률 항아리",
"colorBlue": "파란색",
"colorGreen": "녹색",
"colorRed": "빨간색",
"colorYellow": "노란색",
"impossible": "불가능",
"probTitle": "구슬을 하나 집어 들고 그 색깔을 맞출 확률…",
"shake": "흔들기!",
"total": "합계",
"hint": "병을 흔들어 확률이 어떻게 작용하는지 확인해 보세요"
},
"en": {
"_name": "Probability Jar",
"colorBlue": "Blue",
"colorGreen": "Green",
"colorRed": "Red",
"colorYellow": "Yellow",
"impossible": "Impossible",
"probTitle": "Probability of picking a marble and guessing its color…",
"shake": "Shake!",
"total": "Total",
"hint": "Shake the jar to see the probability in action"
},
"it": {
"colorBlue": "Blu",
"colorGreen": "Verde",
"colorRed": "Rosso",
"colorYellow": "Giallo"
},
"ca": {
"total": "Total"
},
"qqq": {
"_name": "Name of the Probability Jar module",
"colorBlue": "Name of the blue marble colour",
"colorGreen": "Name of the green marble colour",
"colorRed": "Name of the red marble colour",
"colorYellow": "Name of the yellow marble colour",
"impossible": "Label shown when the probability is zero (impossible event)",
"probTitle": "Heading describing the probability scenario",
"shake": "Button label to shake the jar and randomise marbles",
"total": "Label for the total number of marbles",
"hint": "Instruction text for the probability jar interaction"
},
"ga": {
"_name": "Jar Dóchúlachta",
"colorBlue": "Gorm",
"colorGreen": "Glas",
"colorRed": "Dearg",
"colorYellow": "Buí",
"impossible": "Dodhéanta",
"probTitle": "An dóchúlacht go roghnófar marmair agus go mbuailfear a dhath…",
"shake": "Croith!",
"total": "Iomlán",
"hint": "Croith an próca chun an dóchúlacht i mbun gnímh a fheiceáil"
},
"es": {
"_name": "Frasco de probabilidades",
"colorBlue": "Azul",
"colorGreen": "Verde",
"colorRed": "Rojo",
"colorYellow": "Amarillo",
"impossible": "Imposible",
"probTitle": "Probabilidad de sacar una canica y adivinar su color…",
"shake": "¡Agitar!",
"total": "Total",
"hint": "Agita el frasco para ver la probabilidad en acción."
},
"mk": {
"_name": "Тегла на веројатност",
"colorBlue": "Сина",
"colorGreen": "Зелена",
"colorRed": "Црвена",
"colorYellow": "Жолта",
"impossible": "Невозможно",
"probTitle": "Веројатноста за избор на џамлија и погодување на нејзината боја…",
"shake": "Протреси!",
"total": "Вкупно",
"hint": "Протресете ја теглата за да ја видите веројатноста на дело"
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
var COLORS = [
{ id: 'blue', label: t('colorBlue'), hex: hiruwiki.getThemeColor('color-progressive', '#3366cc') },
{ id: 'red', label: t('colorRed'), hex: hiruwiki.getThemeColor('color-destructive', '#bf3c2c') },
{ id: 'green', label: t('colorGreen'), hex: '#177860' },
{ id: 'orange', label: t('colorYellow'), hex: '#e6a000' }
];
var TOTAL = 50;
function seededRand(seed) {
var s = seed;
return function () {
s = (s * 1664525 + 1013904223) & 0xffffffff;
return (s >>> 0) / 0xffffffff;
};
}
function shuffleWithSeed(arr, seed) {
var rand = seededRand(seed);
var a = arr.slice();
for (var i = a.length - 1; i > 0; i--) {
var j = Math.floor(rand() * (i + 1));
var tmp = a[i]; a[i] = a[j]; a[j] = tmp;
}
return a;
}
function gcd(a, b) { return b === 0 ? a : gcd(b, a % b); }
function simplify(n, d) {
if (d === 0) return null;
var g = gcd(n, d);
return [n / g, d / g];
}
function svgEl(tag, attrs) {
var el = document.createElementNS('http://www.w3.org/2000/svg', tag);
for (var k in attrs) el.setAttribute(k, attrs[k]);
return el;
}
function init(root) {
var counts = { blue: 0, red: 0, green: 0, orange: 0 };
var marbleOrder = [];
var shuffleSeed = Math.floor(Math.random() * 999999);
function rebuildOrder() {
var all = [];
COLORS.forEach(function (c) {
for (var i = 0; i < counts[c.id]; i++) all.push(c);
});
marbleOrder = shuffleWithSeed(all, shuffleSeed);
}
// --- Build DOM ---
root.innerHTML = '';
root.className = (root.className ? root.className + ' ' : '') + 'hiruwiki-marble-jar';
var wrap = document.createElement('div');
wrap.className = 'hmj-wrap';
// Jar column
var jarSection = document.createElement('div');
jarSection.className = 'hmj-jar-section';
var jarContainer = document.createElement('div');
jarContainer.className = 'hmj-jar-container';
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 260 400');
svg.setAttribute('class', 'hmj-jar-svg');
jarContainer.appendChild(svg);
var shakeBtn = document.createElement('button');
shakeBtn.className = 'hmj-shake-btn';
shakeBtn.textContent = t('shake');
jarSection.appendChild(jarContainer);
jarSection.appendChild(shakeBtn);
// Controls column
var ctrlSection = document.createElement('div');
ctrlSection.className = 'hmj-controls';
var slidersDiv = document.createElement('div');
slidersDiv.className = 'hmj-sliders';
var totalRow = document.createElement('div');
totalRow.className = 'hmj-total-row';
totalRow.innerHTML = '<span>' + t('total') + ':</span>';
var totalNum = document.createElement('span');
totalNum.className = 'hmj-total-num';
totalNum.textContent = '0';
var totalOf = document.createElement('span');
totalOf.textContent = '/ ' + TOTAL;
totalRow.appendChild(totalNum);
totalRow.appendChild(totalOf);
var probSection = document.createElement('div');
probSection.className = 'hmj-prob-section';
var probTitle = document.createElement('div');
probTitle.className = 'hmj-prob-title';
probTitle.textContent = t('probTitle');
var probsDiv = document.createElement('div');
probsDiv.className = 'hmj-probs';
probSection.appendChild(probTitle);
probSection.appendChild(probsDiv);
ctrlSection.appendChild(slidersDiv);
ctrlSection.appendChild(totalRow);
ctrlSection.appendChild(probSection);
wrap.appendChild(jarSection);
wrap.appendChild(ctrlSection);
root.appendChild(wrap);
// --- Render marbles in SVG ---
function renderMarbles() {
while (svg.firstChild) svg.removeChild(svg.firstChild);
var W = 260, jL = 35, jR = 225, jBot = 370, jTop = 100;
var jW = jR - jL, nL = 82, nR = 178, nTop = 52;
var jarPathD = 'M' + jL + ',' + jTop + ' L' + nL + ',' + nTop + ' L' + nR + ',' + nTop +
' L' + jR + ',' + jTop + ' L' + jR + ',' + jBot +
' Q' + (W / 2) + ',' + (jBot + 12) + ' ' + jL + ',' + jBot + ' Z';
var defs = svgEl('defs', {});
var clip = svgEl('clipPath', { id: 'hmj-clip-' + root.id });
var clipPath = svgEl('path', { d: jarPathD });
clip.appendChild(clipPath);
defs.appendChild(clip);
svg.appendChild(defs);
// Background fill
svg.appendChild(svgEl('rect', { x: jL, y: jTop, width: jW, height: jBot - jTop, fill: '#dff0fa', rx: 3 }));
var r = 12;
var cols = Math.floor((jW - 4) / (r * 2 + 3));
var rowH = r * 2 + 3;
var xStart = jL + 2 + (jW - 4 - cols * (r * 2 + 3)) / 2 + r;
var g = svgEl('g', { 'clip-path': 'url(#hmj-clip-' + root.id + ')' });
var totalRows = Math.ceil(marbleOrder.length / cols);
marbleOrder.forEach(function (c, i) {
var col = i % cols;
var row = Math.floor(i / cols);
var rowFromBottom = totalRows - 1 - row;
var cx = xStart + col * (r * 2 + 3);
var cy = jBot - r - 2 - rowFromBottom * rowH;
var rand = seededRand(shuffleSeed + i * 7);
var jitter = (rand() - 0.5) * 3;
g.appendChild(svgEl('circle', {
cx: cx + jitter, cy: cy, r: r,
fill: c.hex, stroke: 'rgba(0,0,0,0.18)', 'stroke-width': 1
}));
g.appendChild(svgEl('circle', {
cx: cx + jitter - 3.5, cy: cy - 3.5, r: 4,
fill: 'rgba(255,255,255,0.32)'
}));
});
svg.appendChild(g);
// Jar outline (on top of marbles)
svg.appendChild(svgEl('path', {
d: jarPathD,
fill: 'rgba(160,210,240,0.18)',
stroke: '#4a8fc4', 'stroke-width': 3
}));
// Neck lip
svg.appendChild(svgEl('rect', {
x: nL - 6, y: nTop - 9, width: nR - nL + 12, height: 11,
rx: 4, fill: '#4a8fc4'
}));
// Count label
var txt = svgEl('text', {
x: 130, y: 22, 'text-anchor': 'middle',
'font-size': 13, fill: hiruwiki.getThemeColor('color-subtle', '#666'), 'font-family': 'sans-serif'
});
txt.textContent = marbleOrder.length + ' / ' + TOTAL;
svg.appendChild(txt);
}
// --- Render probability fractions ---
function makeFrac(n, d, color) {
var span = document.createElement('span');
span.className = 'hmj-frac';
span.style.color = color === '#e6a000' ? '#a07000' : color;
var num = document.createElement('span');
num.className = 'hmj-frac-num';
num.textContent = n;
var den = document.createElement('span');
den.className = 'hmj-frac-den';
den.textContent = d;
span.appendChild(num);
span.appendChild(den);
return span;
}
function renderProbs() {
var total = COLORS.reduce(function (s, c) { return s + counts[c.id]; }, 0);
probsDiv.innerHTML = '';
COLORS.forEach(function (c) {
var n = counts[c.id];
var row = document.createElement('div');
row.className = 'hmj-prob-row';
var lbl = document.createElement('div');
lbl.className = 'hmj-prob-label';
var dot = document.createElement('span');
dot.className = 'hmj-dot';
dot.style.background = c.hex;
var name = document.createElement('span');
name.textContent = c.label;
lbl.appendChild(dot);
lbl.appendChild(name);
var wrap = document.createElement('div');
wrap.className = 'hmj-frac-wrap';
if (total === 0 || n === 0) {
var imp = document.createElement('span');
imp.className = 'hmj-impossible';
imp.textContent = t('impossible');
wrap.appendChild(imp);
} else {
wrap.appendChild(makeFrac(n, total, c.hex));
var simp = simplify(n, total);
if (simp && !(simp[0] === n && simp[1] === total)) {
var eq = document.createElement('span');
eq.className = 'hmj-eq';
eq.textContent = '=';
wrap.appendChild(eq);
wrap.appendChild(makeFrac(simp[0], simp[1], c.hex));
}
}
row.appendChild(lbl);
row.appendChild(wrap);
probsDiv.appendChild(row);
});
}
// --- Build sliders ---
function buildSliders() {
slidersDiv.innerHTML = '';
COLORS.forEach(function (c) {
var row = document.createElement('div');
row.className = 'hmj-slider-row';
var lbl = document.createElement('div');
lbl.className = 'hmj-slider-label';
var dot = document.createElement('span');
dot.className = 'hmj-dot';
dot.style.background = c.hex;
var name = document.createElement('span');
name.textContent = c.label;
lbl.appendChild(dot);
lbl.appendChild(name);
var ctrl = document.createElement('div');
ctrl.className = 'hmj-slider-ctrl';
var sl = document.createElement('input');
sl.type = 'range'; sl.min = 0; sl.max = 50; sl.value = 0; sl.step = 1;
sl.style.accentColor = c.hex;
sl.id = 'hmj-sl-' + c.id + '-' + root.id;
var badge = document.createElement('span');
badge.className = 'hmj-badge';
badge.style.background = c.hex;
badge.id = 'hmj-badge-' + c.id + '-' + root.id;
badge.textContent = '0';
ctrl.appendChild(sl);
ctrl.appendChild(badge);
row.appendChild(lbl);
row.appendChild(ctrl);
slidersDiv.appendChild(row);
sl.addEventListener('input', function () {
var desired = parseInt(this.value, 10);
var others = COLORS.reduce(function (s, cc) {
return cc.id !== c.id ? s + counts[cc.id] : s;
}, 0);
counts[c.id] = Math.min(desired, TOTAL - others);
this.value = counts[c.id];
badge.textContent = counts[c.id];
totalNum.textContent = COLORS.reduce(function (s, cc) { return s + counts[cc.id]; }, 0);
shuffleSeed = Math.floor(Math.random() * 999999);
rebuildOrder();
renderMarbles();
renderProbs();
});
});
}
// --- Shake button ---
shakeBtn.addEventListener('click', function () {
jarContainer.classList.remove('hmj-shaking');
void jarContainer.offsetWidth; // reflow to restart animation
jarContainer.classList.add('hmj-shaking');
jarContainer.addEventListener('animationend', function onEnd() {
jarContainer.removeEventListener('animationend', onEnd);
jarContainer.classList.remove('hmj-shaking');
shuffleSeed = Math.floor(Math.random() * 999999);
rebuildOrder();
renderMarbles();
}, { once: true });
});
// Give root an id if it lacks one (needed for unique clipPath id)
if (!root.id) root.id = 'hmj-' + Math.floor(Math.random() * 99999);
buildSliders();
rebuildOrder();
renderMarbles();
renderProbs();
// Footer branding
var footer = document.createElement("div");
footer.className = "hw-footer";
var fLogo = document.createElement("a");
fLogo.className = "hw-footer-icon";
fLogo.href = mw.util.getUrl('Wikipedia:Hiruwiki');
fLogo.title = 'Hiruwiki';
fLogo.innerHTML = hiruwiki.getLogoSvg(22);
var fText = document.createElement("span");
fText.innerHTML = t('hint');
footer.appendChild(fLogo);
footer.appendChild(fText);
root.appendChild(footer);
}
// --- Scan for containers and initialise ---
document.querySelectorAll('.hiruwiki[data-module="marble-jar"]').forEach(function (el) {
init(el);
});
}());
nlim38fklaej7jqh5h27018ykpn6jvh
MediaWiki:Hiruwiki/modules/monte-carlo.js
8
179503
781434
770230
2026-10-28T10:39:35Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781434
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/* monte-carlo.js — Hiruwiki module */
( function () {
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"nl": {
"_name": "Monte Carlo Pi",
"addPoints": "+ Punten toevoegen",
"approxPi": "Benadering van π",
"insideCircle": "Binnen de cirkel",
"insideRed": "Binnen (rood)",
"outsideBlue": "Buiten (blauw)",
"outsideCircle": "Buiten de cirkel",
"pointsPerStep": "Punten per stap",
"reset": "↺ Opnieuw instellen",
"totalPoints": "Totaal aantal punten",
"hint": "Voeg punten toe om π te benaderen met kansberekening"
},
"fr": {
"_name": "Monte Carlo Pi",
"addPoints": "+ Ajouter des points",
"approxPi": "Approximation de π",
"insideCircle": "Dans le cercle",
"insideRed": "Intérieur (rouge)",
"outsideBlue": "Extérieur (bleu)",
"outsideCircle": "Hors du cercle",
"pointsPerStep": "Points par étape",
"reset": "↺ Réinitialiser",
"totalPoints": "Total des points",
"hint": "Ajoutez des points pour approximer π en utilisant les probabilités"
},
"tr": {
"_name": "Monte Carlo Pi",
"addPoints": "+ Nokta Ekle",
"approxPi": "π'nin yaklaşık değeri",
"insideCircle": "İç Çember",
"insideRed": "İç (kırmızı)",
"outsideBlue": "Dış (mavi)",
"outsideCircle": "Dış Çember",
"pointsPerStep": "Adım başına nokta",
"reset": "↺ Sıfırla",
"totalPoints": "Toplam nokta",
"hint": "Olasılık kullanarak π'ye yakın bir sayı bulmak için nokta ekleyin"
},
"eu": {
"_name": "Monte Carlo Pi",
"addPoints": "+ Puntuak gehitu",
"approxPi": "π-ren hurbilketa",
"insideCircle": "Zirkulu barruan",
"insideRed": "Barruan (gorria)",
"outsideBlue": "Kanpoan (urdina)",
"outsideCircle": "Zirkulu kanpoan",
"pointsPerStep": "Puntuak urratseko",
"reset": "↺ Berrezarri",
"totalPoints": "Puntu kopurua",
"hint": "Gehitu puntuak probabilitatea erabiliz π hurbiltzeko"
},
"sk": {
"_name": "Monte Carlo π",
"addPoints": "+ Pridať body",
"approxPi": "Aproximácia π",
"insideCircle": "V kruhu",
"insideRed": "Vnútri (červené)",
"outsideBlue": "Vonku (modré)",
"outsideCircle": "Mimo kruhu",
"pointsPerStep": "Body na krok",
"reset": "↺ Vynulovať",
"totalPoints": "Body spolu",
"hint": "Pridávajte body a aproximujte π pomocou pravdepodobnosti"
},
"skr-arab": {
"_name": "مونٹے کارلو پائی",
"addPoints": "+ نقطے ودھاؤ",
"approxPi": "π دا تقریبا",
"insideCircle": "دائرے دے اندر",
"insideRed": "اندر (رتا)",
"outsideBlue": "باہر (نیلا)",
"outsideCircle": "دائرے دے باہر",
"pointsPerStep": "فی قدم نقطے",
"reset": "↺ دوبارہ",
"totalPoints": "کل نقطے",
"hint": "احتمال دی مدد نال π دا تقریبا لاوݨ کیتے نقطے ودھاؤ"
},
"ko": {
"_name": "몬테카를로 파이",
"addPoints": "+ 포인트 추가",
"approxPi": "π의 근사값",
"insideCircle": "원 안쪽",
"insideRed": "안쪽 (빨간색)",
"outsideBlue": "바깥쪽 (파란색)",
"outsideCircle": "원 바깥쪽",
"pointsPerStep": "단계당 점수",
"reset": "↺ 초기화",
"totalPoints": "총점",
"hint": "확률을 이용하여 π를 근사하기 위해 점을 추가합니다"
},
"en": {
"_name": "Monte Carlo Pi",
"addPoints": "+ Add Points",
"approxPi": "Approximation of π",
"insideCircle": "Inside Circle",
"insideRed": "Inside (red)",
"outsideBlue": "Outside (blue)",
"outsideCircle": "Outside Circle",
"pointsPerStep": "Points per step",
"reset": "↺ Reset",
"totalPoints": "Total points",
"hint": "Add points to approximate π using probability"
},
"it": {
"approxPi": "Approssimazione di π"
},
"ca": {
"totalPoints": "Punts totals"
},
"qqq": {
"_name": "Name of the Monte Carlo Pi module",
"addPoints": "Button label to add more random points to the simulation",
"approxPi": "Label for the current approximation of π",
"insideCircle": "Label for the count of points inside the circle",
"insideRed": "Legend label for points inside the circle (shown in red)",
"outsideBlue": "Legend label for points outside the circle (shown in blue)",
"outsideCircle": "Label for the count of points outside the circle",
"pointsPerStep": "Label for the number of points added per step",
"reset": "Button label to reset the simulation",
"totalPoints": "Label for the total number of points",
"hint": "Instruction text for the Monte Carlo simulation interaction"
},
"ga": {
"_name": "Pi Monte Carlo",
"addPoints": "+ Cuir Pointí leis",
"approxPi": "Measúnú ar π",
"insideCircle": "Ciorcal Taobh Istigh",
"insideRed": "Taobh istigh (dearg)",
"outsideBlue": "Lasmuigh (gorm)",
"outsideCircle": "Ciorcal Lasmuigh",
"pointsPerStep": "Pointí in aghaidh an chéime",
"reset": "↺ Athshocraigh",
"totalPoints": "Iomlán na bpointí",
"hint": "Cuir pointí leis chun π a mheasúnú ag baint úsáide as dóchúlacht"
},
"es": {
"_name": "Pi de Monte Carlo",
"addPoints": "+ Añadir puntos",
"approxPi": "Aproximación de π",
"insideCircle": "Dentro del círculo",
"insideRed": "Dentro (rojo)",
"outsideBlue": "Fuera (azul)",
"outsideCircle": "Fuera del círculo",
"pointsPerStep": "Puntos por paso",
"reset": "↺ Reiniciar",
"totalPoints": "Puntos totales",
"hint": "Añade puntos para aproximar π usando probabilidad"
},
"mk": {
"_name": "Монте Карло Пи",
"addPoints": "+ Додај точки",
"approxPi": "Приближување на π",
"insideCircle": "Во кружница",
"insideRed": "Внатре (црвено)",
"outsideBlue": "Надвор (сино)",
"outsideCircle": "Вон кружницата",
"pointsPerStep": "Точки по чекор",
"reset": "↺ Одново",
"totalPoints": "Вкупно бодови",
"hint": "Додајте точки за да се приближите на π користејќи веројатност"
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
var SIZE = 320;
function euFmt( n ) {
// Thousands separator: dot; e.g. 1.000
return n.toString().replace( /\B(?=(\d{3})+(?!\d))/g, '.' );
}
function euFmtFloat( f ) {
// Decimal separator: comma; e.g. 3,141
return f.toFixed( 3 ).replace( '.', ',' );
}
function buildWidget( container ) {
// Guard: only initialise once per element
if ( container.dataset.mcInit ) { return; }
container.dataset.mcInit = '1';
var inside = 0;
var outside = 0;
// ── DOM ──────────────────────────────────────────────────────────────
var wrap = document.createElement( 'div' );
wrap.className = 'mc-wrap';
// Left column: canvas + legend
var canvasCol = document.createElement( 'div' );
canvasCol.className = 'mc-canvas-col';
var canvas = document.createElement( 'canvas' );
canvas.className = 'mc-canvas';
canvas.width = SIZE;
canvas.height = SIZE;
var legend = document.createElement( 'div' );
legend.className = 'mc-legend';
legend.innerHTML =
'<span class="mc-legend-dot mc-legend-dot--inside"></span> ' + t('insideCircle') +
' ' +
'<span class="mc-legend-dot mc-legend-dot--outside"></span> ' + t('outsideCircle');
canvasCol.appendChild( canvas );
canvasCol.appendChild( legend );
// Right column: panel
var panel = document.createElement( 'div' );
panel.className = 'mc-panel';
// Formula box
var formulaBox = document.createElement( 'div' );
formulaBox.className = 'mc-formula-box';
formulaBox.innerHTML =
'<div class="mc-formula-title">' + t('approxPi') + '</div>' +
'<div class="mc-formula-row">' +
'π ≈ 4 ×' +
'<span class="mc-frac">' +
'<span class="mc-frac-num" id="mc-f-inside">0</span>' +
'<span class="mc-frac-den" id="mc-f-total">0</span>' +
'</span>' +
'<span>=</span>' +
'<span class="mc-approx-result" id="mc-f-result">–</span>' +
'</div>';
// Stats row
var statsRow = document.createElement( 'div' );
statsRow.className = 'mc-stats-row';
statsRow.innerHTML =
'<div class="mc-stat-card">' +
'<div class="mc-stat-label">' + t('insideRed') + '</div>' +
'<div class="mc-stat-value mc-stat-value--inside" id="mc-s-inside">0</div>' +
'</div>' +
'<div class="mc-stat-card">' +
'<div class="mc-stat-label">' + t('outsideBlue') + '</div>' +
'<div class="mc-stat-value mc-stat-value--outside" id="mc-s-outside">0</div>' +
'</div>';
var statTotal = document.createElement( 'div' );
statTotal.className = 'mc-stat-card';
statTotal.innerHTML =
'<div class="mc-stat-label">' + t('totalPoints') + '</div>' +
'<div class="mc-stat-value" id="mc-s-total">0</div>';
// Controls
var controls = document.createElement( 'div' );
controls.className = 'mc-controls';
controls.innerHTML =
'<div class="mc-ctrl-row">' +
'<span class="mc-ctrl-label">' + t('pointsPerStep') + '</span>' +
'<input type="range" class="mc-slider" id="mc-slider" min="1" max="500" value="50" step="1">' +
'<span class="mc-ctrl-val" id="mc-slider-val">50</span>' +
'</div>' +
'<button class="mc-btn" id="mc-btn-add">' + t('addPoints') + '</button>' +
'<button class="mc-btn mc-btn--reset" id="mc-btn-reset">' + t('reset') + '</button>';
panel.appendChild( controls );
panel.appendChild( formulaBox );
panel.appendChild( statsRow );
panel.appendChild( statTotal );
wrap.appendChild( canvasCol );
wrap.appendChild( panel );
container.appendChild( wrap );
// ── Canvas helpers ───────────────────────────────────────────────────
var ctx = canvas.getContext( '2d' );
function drawBase() {
ctx.clearRect( 0, 0, SIZE, SIZE );
// Codex background-color-base: #fff (light) / #101418 (dark)
ctx.fillStyle = hiruwiki.getThemeColor('background-color-base', '#ffffff');
ctx.fillRect( 0, 0, SIZE, SIZE );
// Grid lines — Codex border-color-subtle / border-color-base at low opacity
ctx.strokeStyle = hiruwiki.getThemeColor('border-color-subtle', 'rgba(128,128,128,0.18)');
ctx.lineWidth = 0.5;
var step = SIZE / 10;
for ( var i = 0; i <= SIZE; i += step ) {
ctx.beginPath(); ctx.moveTo( i, 0 ); ctx.lineTo( i, SIZE ); ctx.stroke();
ctx.beginPath(); ctx.moveTo( 0, i ); ctx.lineTo( SIZE, i ); ctx.stroke();
}
// Border — Codex border-color-base: #a2a9b1 (light) / #3d424d (dark)
ctx.strokeStyle = hiruwiki.getThemeColor('border-color-base', '#a2a9b1');
ctx.lineWidth = 0.5;
ctx.strokeRect( 0, 0, SIZE, SIZE );
// Quarter-circle arc — Codex color-base: #202122 (light) / #f8f9fa (dark)
ctx.beginPath();
ctx.arc( 0, SIZE, SIZE - 1, -Math.PI / 2, 0 );
ctx.strokeStyle = hiruwiki.getThemeColor('color-base', '#202122');
ctx.lineWidth = 3.0;
ctx.stroke();
}
function addPoints( n ) {
for ( var i = 0; i < n; i++ ) {
var px = Math.random();
var py = Math.random();
var dist = px * px + py * py;
var cx = Math.round( px * ( SIZE - 1 ) );
var cy = SIZE - 1 - Math.round( py * ( SIZE - 1 ) );
if ( dist <= 1 ) {
// Codex color-destructive: #d73333 (light) / #ff6e6e (dark) — at 70% opacity
ctx.fillStyle = hiruwiki.getThemeColor('color-destructive', '#d73333') + 'b3'; // b3 ≈ 70% opacity
inside++;
} else {
// Codex color-progressive: #36c (light) / #6e85cf (dark) — at 70% opacity
ctx.fillStyle = hiruwiki.getThemeColor('color-progressive', '#3366cc') + 'b3';
outside++;
}
ctx.beginPath();
ctx.arc( cx, cy, 3, 0, Math.PI * 2 );
ctx.fill();
}
updateStats();
}
function updateStats() {
var total = inside + outside;
wrap.querySelector( '#mc-s-inside' ).textContent = euFmt( inside );
wrap.querySelector( '#mc-s-outside' ).textContent = euFmt( outside );
wrap.querySelector( '#mc-s-total' ).textContent = euFmt( total );
wrap.querySelector( '#mc-f-inside' ).textContent = euFmt( inside );
wrap.querySelector( '#mc-f-total' ).textContent = euFmt( total );
if ( total > 0 ) {
var pi = 4 * inside / total;
wrap.querySelector( '#mc-f-result' ).textContent = euFmtFloat( pi ) + '…';
} else {
wrap.querySelector( '#mc-f-result' ).textContent = '–';
}
}
function reset() {
inside = 0;
outside = 0;
drawBase();
updateStats();
}
// ── Event listeners ──────────────────────────────────────────────────
var slider = wrap.querySelector( '#mc-slider' );
var sliderVal = wrap.querySelector( '#mc-slider-val' );
slider.addEventListener( 'input', function () {
sliderVal.textContent = slider.value;
} );
wrap.querySelector( '#mc-btn-add' ).addEventListener( 'click', function () {
addPoints( parseInt( slider.value, 10 ) );
} );
wrap.querySelector( '#mc-btn-reset' ).addEventListener( 'click', reset );
// ── Init ─────────────────────────────────────────────────────────────
drawBase();
updateStats();
// Footer branding
var footer = document.createElement("div");
footer.className = "hw-footer";
var fLogo = document.createElement("a");
fLogo.className = "hw-footer-icon";
fLogo.href = mw.util.getUrl('Wikipedia:Hiruwiki');
fLogo.title = 'Hiruwiki';
fLogo.innerHTML = hiruwiki.getLogoSvg(22);
var fText = document.createElement("span");
fText.innerHTML = t('hint');
footer.appendChild(fLogo);
footer.appendChild(fText);
container.appendChild(footer);
}
// ── Entry point: initialise all matching containers ──────────────────────
document.querySelectorAll( '.hiruwiki[data-module="monte-carlo"]' ).forEach( function ( el ) {
buildWidget( el );
} );
}() );
23xqdg0t7nh5ytwmelb7wyzmryttlr5
MediaWiki:Hiruwiki/modules/nine-point-circle.js
8
179505
781435
770232
2026-10-28T10:39:39Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781435
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/* =========================================================
* Hiruwiki module: nine-point-circle
* Visualisation of the nine-point circle of a triangle.
*
* The nine points are:
* Group 1 — side midpoints M_a, M_b, M_c (teal)
* Group 2 — altitude feet H_a, H_b, H_c (amber)
* Group 3 — Euler midpoints E_a, E_b, E_c (rose)
* midpoints of AH, BH, CH
*
* The nine-point centre N₉ is the midpoint of O and H.
* Its circumradius equals R/2.
* =========================================================
* i18n: add a new key to the `messages` object below and
* a matching entry for every language code you need.
* Fall back to 'en' when the wiki language is absent.
* ========================================================= */
( function () {
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"nl": {
"_name": "Cirkel van de negen punten",
"hint": "Sleep een hoekpunt om de cirkel van de negen punten te verkennen",
"legendM": "M_a/b/c — middens van de zijden",
"legendH": "H_a/b/c — voetpunten van de hoogtelijnen",
"legendE": "E_a/b/c — middens van AH, BH, CH"
},
"fr": {
"_name": "Cercle des neuf points",
"hint": "Faites glisser n'importe quel sommet pour explorer le cercle à neuf points",
"legendM": "M_a/b/c — milieux des côtés",
"legendH": "H_a/b/c — pieds des hauteurs",
"legendE": "E_a/b/c — milieux de AH, BH, CH"
},
"tr": {
"_name": "Dokuz Noktalı Çember",
"hint": "Herhangi bir köşeyi sürükleyin · Ölçek: 1 cm = 40 px",
"legendM": "M_a/b/c - kenarların orta noktaları",
"legendH": "H_a/b/c - yüksekliklerin fit cinsinden uzunlukları",
"legendE": "E_a/b/c — AH, BH ve CH'nin orta noktaları"
},
"eu": {
"_name": "Bederatzi puntuko zirkulua",
"hint": "Erpinak mugitu ditzakezu bederatzi puntuko zirkulua ikusteko",
"legendM": "M_a/b/c — aldeen erdipuntuak",
"legendH": "H_a/b/c — alturen oinak",
"legendE": "E_a/b/c — AH, BH, CH erdipuntuak"
},
"sk": {
"_name": "Kružnica deviatich bodov",
"hint": "Potiahnutím ľubovoľného vrcholu preskúmate kružnicu deviatich bodov",
"legendM": "M_a/b/c — stredy strán",
"legendH": "H_a/b/c — päty výšok",
"legendE": "E_a/b/c — stredy úsečiek AH, BH, CH"
},
"skr-arab": {
"_name": "نوں نکاتی دائرہ",
"hint": "نوں نکاتی دائرہ ݙیکھݨ کیتے کوئی وی سرا گھسیٹو",
"legendM": "M_a/b/c — ضلعیاں دے درمیان نقطے",
"legendH": "H_a/b/c — بلندی دے پیر",
"legendE": "E_a/b/c — AH، BH، CH دے درمیان نقطے"
},
"ko": {
"_name": "구점원",
"hint": "꼭짓점을 드래그해서 구점원을 탐구하세요",
"legendM": "M_a/b/c — 변들의 중점들",
"legendH": "M_a/b/c — 수선의 발들",
"legendE": "E_a/b/c — 각 AH, BH, CH의 중점들"
},
"en": {
"_name": "Nine-Point Circle",
"hint": "Drag any vertex to explore the nine-point circle",
"legendM": "M_a/b/c — midpoints of the sides",
"legendH": "H_a/b/c — feet of the altitudes",
"legendE": "E_a/b/c — midpoints of AH, BH, CH"
},
"qqq": {
"_name": "Name of the Nine-Point Circle module",
"hint": "Instruction text shown in the footer.",
"legendM": "Legend entry for M_a/b/c — midpoints of the sides",
"legendH": "Legend entry for H_a/b/c — feet of the altitudes",
"legendE": "Legend entry for E_a/b/c — midpoints of AH, BH, CH"
},
"ga": {
"_name": "Ciorcal Naoi bPointe",
"hint": "Tarraing aon bhuaicphointe chun an ciorcal naoi bpointe a iniúchadh",
"legendM": "M_a/b/c — lárphointí na dtaobhanna",
"legendH": "H_a/b/c — troigh na n-airde",
"legendE": "E_a/b/c — lárphointí AH, BH, CH"
},
"es": {
"_name": "Círculo de los nueve puntos",
"hint": "Arrastra un vértice para explorar el círculo de los nueve puntos",
"legendM": "M_a/b/c — puntos medios de los lados",
"legendH": "H_a/b/c — pies de las alturas",
"legendE": "E_a/b/c — puntos medios de AH, BH, CH"
},
"mk": {
"_name": "Кружница низ девет точки",
"hint": "Повлечете некое теме за да ја истражите кружницата низ девет точки",
"legendM": "M_a/b/c — средишни точки на страните",
"legendH": "H_a/b/c — подножја на висините",
"legendE": "E_a/b/c — средишни точки на AH, BH, CH"
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
/* ── Constants ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var SCALE = 40; /* pixels per centimetre */
var H_PX = 540; /* canvas height (px) */
var DPR = window.devicePixelRatio || 1;
/* ── Geometry helpers ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
function dist( a, b ) {
return Math.hypot( b.x - a.x, b.y - a.y );
}
function midpoint( A, B ) {
return { x: ( A.x + B.x ) / 2, y: ( A.y + B.y ) / 2 };
}
function footPerp( P, V1, V2 ) {
var dx = V2.x - V1.x, dy = V2.y - V1.y;
var t = ( ( P.x - V1.x ) * dx + ( P.y - V1.y ) * dy ) / ( dx * dx + dy * dy );
return { x: V1.x + t * dx, y: V1.y + t * dy };
}
function circumcenter( A, B, C ) {
var ax = A.x, ay = A.y, bx = B.x, by = B.y, cx = C.x, cy = C.y;
var D = 2 * ( ax * ( by - cy ) + bx * ( cy - ay ) + cx * ( ay - by ) );
if ( Math.abs( D ) < 1e-9 ) {
return { x: ( ax + bx + cx ) / 3, y: ( ay + by + cy ) / 3 };
}
var a2 = ax * ax + ay * ay, b2 = bx * bx + by * by, c2 = cx * cx + cy * cy;
return {
x: ( a2 * ( by - cy ) + b2 * ( cy - ay ) + c2 * ( ay - by ) ) / D,
y: ( a2 * ( cx - bx ) + b2 * ( ax - cx ) + c2 * ( bx - ax ) ) / D
};
}
function orthocentre( A, B, C ) {
var n1x = -( C.y - B.y ), n1y = C.x - B.x;
var n2x = -( C.y - A.y ), n2y = C.x - A.x;
var det = n1x * n2y - n1y * n2x;
if ( Math.abs( det ) < 1e-9 ) {
return { x: ( A.x + B.x + C.x ) / 3, y: ( A.y + B.y + C.y ) / 3 };
}
var t = ( ( B.x - A.x ) * n2y - ( B.y - A.y ) * n2x ) / det;
return { x: A.x + t * n1x, y: A.y + t * n1y };
}
function labelOutward( P, cx3, cy3, mag ) {
var dx = P.x - cx3, dy = P.y - cy3;
var l = Math.hypot( dx, dy ) || 1;
return { x: P.x + dx / l * mag, y: P.y + dy / l * mag };
}
function labelOffset( V, others, mag ) {
var dx = 0, dy = 0;
others.forEach( function ( O ) { dx += V.x - O.x; dy += V.y - O.y; } );
var l = Math.hypot( dx, dy ) || 1;
return { x: V.x + dx / l * mag, y: V.y + dy / l * mag };
}
/* ── Build widget ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
function buildWidget( container ) {
var lang = ( window.mw && mw.config.get( 'wgUserLanguage' ) ) || 'en';
lang = lang.split( '-' )[ 0 ];
if ( !messages[ lang ] ) { lang = 'en'; }
/* -- HTML skeleton -- */
container.innerHTML =
'<div class="hw-ninepoint">' +
'<canvas class="hw-canvas"></canvas>' +
'</div>';
/* -- Footer -- */
var footer = document.createElement( 'div' );
footer.className = 'hw-footer';
var fLogo = document.createElement( 'a' );
fLogo.className = 'hw-footer-icon';
fLogo.href = ( window.mw && mw.util.getUrl( 'Wikipedia:Hiruwiki' ) ) || '#';
fLogo.title = 'Hiruwiki';
if ( window.hiruwiki && window.hiruwiki.getLogoSvg ) {
fLogo.insertAdjacentHTML( 'beforeend', hiruwiki.getLogoSvg( 22 ) );
}
var fText = document.createElement( 'span' );
fText.className = 'hw-footer__text';
fText.textContent = messages[ lang ].hint;
footer.appendChild( fLogo );
footer.appendChild( fText );
container.appendChild( footer );
var canvas = container.querySelector( '.hw-canvas' );
var ctx = canvas.getContext( '2d' );
var W, H, verts, dragging = -1;
var msg = messages[ lang ];
/* -- vertex initialisation: equilateral triangle centred in canvas -- */
function initVerts() {
var cx = W / 2;
var cy = H / 2;
var r = Math.min( W / 2, H / 2 ) * 0.62;
verts = [
{ x: cx + r * Math.cos( -Math.PI / 2 ), y: cy + r * Math.sin( -Math.PI / 2 ) },
{ x: cx + r * Math.cos( -Math.PI / 2 + 2 * Math.PI / 3 ), y: cy + r * Math.sin( -Math.PI / 2 + 2 * Math.PI / 3 ) },
{ x: cx + r * Math.cos( -Math.PI / 2 + 4 * Math.PI / 3 ), y: cy + r * Math.sin( -Math.PI / 2 + 4 * Math.PI / 3 ) }
];
}
/* -- colour palette -- */
function isDark() {
var root = document.documentElement;
if ( root.classList.contains( 'skin-theme-clientpref-night' ) ||
root.classList.contains( 'client-dark-mode' ) ||
document.body.classList.contains( 'mw-dark-mode' ) ) {
return true;
}
return false;
}
function palette() {
var dark = isDark();
return {
dark: dark,
tri: dark ? '#AFA9EC' : '#534AB7',
mid: dark ? '#5DCAA5' : '#0F6E56',
foot: dark ? '#FAC775' : '#BA7517',
euler: dark ? '#F28BAD' : '#B5294E',
np: dark ? '#C084FC' : '#7C3AED',
circFill: dark ? 'rgba(192,132,252,.06)' : 'rgba(124,58,237,.05)',
circStroke: dark ? 'rgba(192,132,252,.55)' : 'rgba(124,58,237,.50)',
dashMid: dark ? 'rgba(93,202,165,.40)' : 'rgba(15,110,86,.35)',
dashFoot: dark ? 'rgba(250,199,117,.45)' : 'rgba(186,117,23,.40)',
dashEuler: dark ? 'rgba(242,139,173,.40)' : 'rgba(181,41,78,.35)',
grid: dark ? '#444441' : '#D3D1C7',
legendBg: dark ? 'rgba(30,30,28,.82)' : 'rgba(255,255,255,.82)',
legendBdr: dark ? '#444441' : '#D3D1C7',
legendText: dark ? '#B4B2A9' : '#5F5E5A',
dotBg: dark ? '#1e1e1c' : '#ffffff'
};
}
/* -- grid (major cm lines only, very faint) -- */
function drawGrid( p ) {
ctx.lineWidth = 0.5;
ctx.strokeStyle = p.grid;
ctx.globalAlpha = 0.18;
for ( var cm = 0; cm * SCALE < W + SCALE; cm++ ) {
ctx.beginPath();
ctx.moveTo( cm * SCALE, 0 );
ctx.lineTo( cm * SCALE, H );
ctx.stroke();
}
for ( var row = 0; row * SCALE < H + SCALE; row++ ) {
ctx.beginPath();
ctx.moveTo( 0, row * SCALE );
ctx.lineTo( W, row * SCALE );
ctx.stroke();
}
ctx.globalAlpha = 1.0;
}
/* -- right-angle mark at a foot point -- */
function drawRightAngle( T, fromV, V1, V2, color ) {
var sdx = V2.x - V1.x, sdy = V2.y - V1.y;
var sl = Math.hypot( sdx, sdy );
if ( sl < 1e-6 ) { return; }
var su = { x: sdx / sl, y: sdy / sl };
var rdx = fromV.x - T.x, rdy = fromV.y - T.y;
var rl = Math.hypot( rdx, rdy );
if ( rl < 1e-6 ) { return; }
var ru = { x: rdx / rl, y: rdy / rl };
var SZ = 12;
ctx.strokeStyle = color; ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo( T.x + su.x * SZ, T.y + su.y * SZ );
ctx.lineTo( T.x + su.x * SZ + ru.x * SZ, T.y + su.y * SZ + ru.y * SZ );
ctx.lineTo( T.x + ru.x * SZ, T.y + ru.y * SZ );
ctx.stroke();
}
/* -- draw a nine-point dot -- */
function drawNinePt( P, color, r, p ) {
ctx.save();
ctx.beginPath(); ctx.arc( P.x, P.y, r || 5, 0, Math.PI * 2 );
ctx.fillStyle = color; ctx.fill();
ctx.strokeStyle = p.dotBg; ctx.lineWidth = 1.5; ctx.stroke();
ctx.restore();
}
/* -- canvas legend (bottom-left corner) -- */
function drawLegend( p ) {
var items = [
{ color: p.mid, label: msg.legendM },
{ color: p.foot, label: msg.legendH },
{ color: p.euler, label: msg.legendE }
];
var PAD = 10, DOT = 8, LINE = 18, FONT = 11;
ctx.font = FONT + 'px sans-serif';
var maxW = 0;
items.forEach( function ( it ) {
var w = ctx.measureText( it.label ).width;
if ( w > maxW ) { maxW = w; }
} );
var boxW = PAD + DOT + 6 + maxW + PAD;
var boxH = PAD + items.length * LINE + PAD;
var bx = 10;
var by = H - 10 - boxH;
/* background */
ctx.save();
ctx.fillStyle = p.legendBg;
ctx.strokeStyle = p.legendBdr;
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.roundRect( bx, by, boxW, boxH, 4 );
ctx.fill(); ctx.stroke();
ctx.restore();
/* rows */
ctx.save();
ctx.textBaseline = 'middle';
ctx.textAlign = 'left';
ctx.font = FONT + 'px sans-serif';
items.forEach( function ( it, i ) {
var y = by + PAD + i * LINE + LINE / 2;
/* dot */
ctx.beginPath(); ctx.arc( bx + PAD + DOT / 2, y, DOT / 2, 0, Math.PI * 2 );
ctx.fillStyle = it.color; ctx.fill();
/* text */
ctx.fillStyle = p.legendText;
ctx.fillText( it.label, bx + PAD + DOT + 6, y );
} );
ctx.restore();
}
/* -- main draw -- */
function draw() {
if ( !verts ) { return; }
ctx.clearRect( 0, 0, W, H );
var p = palette();
drawGrid( p );
var A = verts[ 0 ], B = verts[ 1 ], C = verts[ 2 ];
/* Special points */
var O = circumcenter( A, B, C );
var Hpt = orthocentre( A, B, C );
var R = dist( O, A );
/* Nine-point centre and radius */
var N9 = midpoint( O, Hpt );
var R9 = R / 2;
/* ── The nine points ──────────────────────────────────────── */
var Ma = midpoint( B, C );
var Mb = midpoint( C, A );
var Mc = midpoint( A, B );
var Ha = footPerp( A, B, C );
var Hb = footPerp( B, C, A );
var Hc = footPerp( C, A, B );
var Ea = midpoint( A, Hpt );
var Eb = midpoint( B, Hpt );
var Ec = midpoint( C, Hpt );
var cx3 = ( A.x + B.x + C.x ) / 3;
var cy3 = ( A.y + B.y + C.y ) / 3;
/* ── 1. nine-point circle ─────────────────────────────────── */
ctx.save();
ctx.beginPath(); ctx.arc( N9.x, N9.y, R9, 0, Math.PI * 2 );
ctx.fillStyle = p.circFill; ctx.fill();
ctx.strokeStyle = p.circStroke; ctx.lineWidth = 1.8; ctx.stroke();
ctx.restore();
/* ── 2a. Construction lines — M: tick across side at midpoint ── */
ctx.save();
ctx.strokeStyle = p.dashMid; ctx.lineWidth = 1.1; ctx.setLineDash( [ 5, 4 ] );
[ [ A, B, Mc ], [ B, C, Ma ], [ C, A, Mb ] ].forEach( function ( t ) {
ctx.beginPath(); ctx.moveTo( t[0].x, t[0].y ); ctx.lineTo( t[2].x, t[2].y ); ctx.stroke();
ctx.beginPath(); ctx.moveTo( t[2].x, t[2].y ); ctx.lineTo( t[1].x, t[1].y ); ctx.stroke();
} );
ctx.setLineDash( [] ); ctx.restore();
/* ── 2b. Construction lines — H: altitude from vertex to foot ── */
ctx.save();
ctx.strokeStyle = p.dashFoot; ctx.lineWidth = 1.1; ctx.setLineDash( [ 5, 4 ] );
[ [ A, Ha ], [ B, Hb ], [ C, Hc ] ].forEach( function ( pr ) {
ctx.beginPath(); ctx.moveTo( pr[0].x, pr[0].y ); ctx.lineTo( pr[1].x, pr[1].y ); ctx.stroke();
} );
ctx.setLineDash( [] ); ctx.restore();
/* ── 2c. Construction lines — E: segment from vertex to H ───── */
ctx.save();
ctx.strokeStyle = p.dashEuler; ctx.lineWidth = 1.1; ctx.setLineDash( [ 4, 5 ] );
[ [ A, Hpt ], [ B, Hpt ], [ C, Hpt ] ].forEach( function ( pr ) {
ctx.beginPath(); ctx.moveTo( pr[0].x, pr[0].y ); ctx.lineTo( pr[1].x, pr[1].y ); ctx.stroke();
} );
ctx.setLineDash( [] ); ctx.restore();
/* ── 3. triangle fill + stroke ───────────────────────────── */
ctx.save();
ctx.beginPath();
ctx.moveTo( A.x, A.y ); ctx.lineTo( B.x, B.y ); ctx.lineTo( C.x, C.y ); ctx.closePath();
ctx.fillStyle = p.dark ? 'rgba(175,169,236,.07)' : 'rgba(83,74,183,.06)';
ctx.fill();
ctx.strokeStyle = p.tri; ctx.lineWidth = 2; ctx.stroke();
ctx.restore();
/* ── 4. Right-angle marks at altitude feet ─────────────────── */
ctx.save();
drawRightAngle( Ha, A, B, C, p.dashFoot );
drawRightAngle( Hb, B, C, A, p.dashFoot );
drawRightAngle( Hc, C, A, B, p.dashFoot );
ctx.restore();
/* ── 5. Group 1: side midpoint dots (teal) ───────────────── */
[ Ma, Mb, Mc ].forEach( function ( M ) { drawNinePt( M, p.mid, 5, p ); } );
/* ── 6. Group 2: altitude foot dots (amber) ──────────────── */
[ Ha, Hb, Hc ].forEach( function ( F ) { drawNinePt( F, p.foot, 5, p ); } );
/* ── 7. Group 3: Euler midpoint dots (rose) ──────────────── */
[ Ea, Eb, Ec ].forEach( function ( E ) { drawNinePt( E, p.euler, 5, p ); } );
/* ── 8. Nine-point centre N₉ (violet, larger) ────────────── */
ctx.save();
ctx.beginPath(); ctx.arc( N9.x, N9.y, 8, 0, Math.PI * 2 );
ctx.fillStyle = p.np; ctx.fill();
ctx.strokeStyle = p.dotBg; ctx.lineWidth = 2; ctx.stroke();
ctx.restore();
/* ── 9. Vertex dots (purple) ─────────────────────────────── */
verts.forEach( function ( V, i ) {
ctx.save();
ctx.beginPath(); ctx.arc( V.x, V.y, dragging === i ? 8 : 6.5, 0, Math.PI * 2 );
ctx.fillStyle = p.tri; ctx.fill();
ctx.strokeStyle = p.dotBg; ctx.lineWidth = 2; ctx.stroke();
ctx.restore();
} );
/* ── 10. Text labels ──────────────────────────────────────── */
ctx.save();
ctx.textBaseline = 'middle'; ctx.textAlign = 'center';
/* Vertex names A / B / C */
ctx.font = "italic 700 18px 'Latin Modern Math', 'STIX Two Math', 'Computer Modern', 'Times New Roman', serif";
[ A, B, C ].forEach( function ( V, i ) {
var lbl = labelOffset( V, verts.filter( function ( _, j ) { return j !== i; } ), 26 );
ctx.fillStyle = p.tri;
ctx.fillText( [ 'A', 'B', 'C' ][ i ], lbl.x, lbl.y );
} );
/* N₉ label */
ctx.font = "italic 700 15px 'Latin Modern Math', 'STIX Two Math', 'Computer Modern', 'Times New Roman', serif";
ctx.fillStyle = p.np;
ctx.fillText( 'N\u2089', N9.x + 14, N9.y - 14 );
/* Nine-point labels — pushed outward from centroid */
ctx.font = '500 11px sans-serif';
var OFF = 17;
function npLabel( P, lbl, color ) {
var pos = labelOutward( P, cx3, cy3, OFF );
ctx.fillStyle = color;
ctx.fillText( lbl, pos.x, pos.y );
}
npLabel( Ma, 'M_a', p.mid );
npLabel( Mb, 'M_b', p.mid );
npLabel( Mc, 'M_c', p.mid );
npLabel( Ha, 'H_a', p.foot );
npLabel( Hb, 'H_b', p.foot );
npLabel( Hc, 'H_c', p.foot );
npLabel( Ea, 'E_a', p.euler );
npLabel( Eb, 'E_b', p.euler );
npLabel( Ec, 'E_c', p.euler );
ctx.restore();
/* ── 11. Canvas legend (bottom-left) ─────────────────────── */
drawLegend( p );
}
/* -- resize -- */
function resize() {
W = canvas.offsetWidth;
H = H_PX;
canvas.width = W * DPR;
canvas.height = H * DPR;
ctx.setTransform( DPR, 0, 0, DPR, 0, 0 );
initVerts();
draw();
}
/* -- pointer helpers -- */
function ptFromEvent( e ) {
var rect = canvas.getBoundingClientRect();
var src = e.touches ? e.touches[ 0 ] : e;
return {
x: ( src.clientX - rect.left ) * ( W / rect.width ),
y: ( src.clientY - rect.top ) * ( H / rect.height )
};
}
function hitTest( pt ) {
return verts.findIndex( function ( v ) {
return Math.hypot( v.x - pt.x, v.y - pt.y ) < 16;
} );
}
function clamp( pt ) {
return {
x: Math.max( 10, Math.min( W - 10, pt.x ) ),
y: Math.max( 4, Math.min( H - 10, pt.y ) )
};
}
canvas.addEventListener( 'mousedown', function ( e ) { dragging = hitTest( ptFromEvent( e ) ); if ( dragging >= 0 ) { canvas.style.cursor = 'grabbing'; } } );
canvas.addEventListener( 'mousemove', function ( e ) {
if ( dragging < 0 ) {
canvas.style.cursor = hitTest( ptFromEvent( e ) ) >= 0 ? 'grab' : 'crosshair';
return;
}
verts[ dragging ] = clamp( ptFromEvent( e ) );
draw();
} );
canvas.addEventListener( 'mouseup', function () { dragging = -1; canvas.style.cursor = 'crosshair'; } );
canvas.addEventListener( 'mouseleave', function () { dragging = -1; } );
canvas.addEventListener( 'touchstart', function ( e ) {
e.preventDefault(); dragging = hitTest( ptFromEvent( e ) );
}, { passive: false } );
canvas.addEventListener( 'touchmove', function ( e ) {
e.preventDefault();
if ( dragging < 0 ) { return; }
verts[ dragging ] = clamp( ptFromEvent( e ) );
draw();
}, { passive: false } );
canvas.addEventListener( 'touchend', function () { dragging = -1; } );
/* dark-mode live update (MediaWiki) */
var observer = new MutationObserver( draw );
observer.observe( document.documentElement, { attributes: true, attributeFilter: [ 'class' ] } );
observer.observe( document.body, { attributes: true, attributeFilter: [ 'class' ] } );
/* kick off */
var ro = new ResizeObserver( resize );
ro.observe( canvas );
}
/* ----------------------------------------------------------
* Initialise all matching containers on the page
* ---------------------------------------------------------- */
document.querySelectorAll( '.hiruwiki[data-module="nine-point-circle"]' ).forEach( buildWidget );
}() );
0tb0i7vqlxaopaeqzl5txx5d19l7c2n
MediaWiki:Hiruwiki/modules/orthocentre.js
8
179507
781436
770234
2026-10-28T10:39:42Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781436
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/* =========================================================
* Hiruwiki module: orthocentre
* Visualisation of the orthocentre of a triangle
* =========================================================
* i18n: add a new key to the `messages` object below and
* a matching entry for every language code you need.
* Fall back to 'en' when the wiki language is absent.
* ========================================================= */
(function () {
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"nl": {
"_name": "Hoogtepunt van een driehoek",
"hint": "Sleep een hoekpunt om te zien hoe het hoogtepunt verandert"
},
"fr": {
"_name": "Orthocentre",
"hint": "Tirez n'importe quel sommet pour voir comment l' orthocentre change"
},
"tr": {
"_name": "Yükseklik merkezi",
"hint": "Herhangi bir köşeyi sürükleyerek yükseklik merkezinin nasıl değiştiğini görün (Ölçek: 1 cm = 40 px)"
},
"eu": {
"_name": "Ortozentro",
"hint": "Edozein erpin mugitu ortozentroa nola mugitzen den ikusteko"
},
"sk": {
"_name": "Ortocentrum",
"hint": "Potiahnutím ľubovoľného vrcholu uvidíte, ako sa ortocentrum mení"
},
"skr-arab": {
"_name": "قائم مرکز",
"hint": "قائم مرکز کین٘ویں بدلدے اے ݙیکھݨ کیتے کوئی وی سرا گھسیٹو"
},
"ko": {
"_name": "수심",
"hint": "꼭짓점을 드래그해서 수심이 어떻게 바뀌는지를 확인하세요"
},
"en": {
"_name": "Orthocentre",
"hint": "Drag any vertex to see how the orthocentre changes"
},
"qqq": {
"_name": "Name of the Orthocentre module",
"hint": "Instruction text shown in the footer."
},
"ga": {
"_name": "Ortalár",
"hint": "Tarraing aon bhuaicphointe chun a fheiceáil conas a athraíonn an t-ortha-lárionad"
},
"es": {
"_name": "Ortocentro",
"hint": "Arrastra un vértice para ver cómo se mueve el ortocentro"
},
"mk": {
"_name": "Ортоцентар",
"hint": "Повлечете некое теме за да видите како се менува ортоцентарот"
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
/* ── Constants ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var SCALE = 40; /* pixels per centimetre */
var RULER_H = 0; /* ruler removed */
var DPR = window.devicePixelRatio || 1;
/* ── Geometry helpers ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
function dist(a, b) {
return Math.hypot(b.x - a.x, b.y - a.y);
}
function footPerp(P, V1, V2) {
var dx = V2.x - V1.x,
dy = V2.y - V1.y;
var t = ((P.x - V1.x) * dx + (P.y - V1.y) * dy) / (dx * dx + dy * dy);
return { x: V1.x + t * dx, y: V1.y + t * dy };
}
/* Returns the projection parameter t (foot = V1 + t*(V2-V1));
* t < 0 means foot beyond V1, t > 1 means foot beyond V2. */
function footT(P, V1, V2) {
var dx = V2.x - V1.x,
dy = V2.y - V1.y;
return ((P.x - V1.x) * dx + (P.y - V1.y) * dy) / (dx * dx + dy * dy);
}
/*
* Orthocentre: intersect altitude from A (perpendicular to BC through A)
* with altitude from B (perpendicular to AC through B).
*
* n1 = direction perpendicular to BC = ( -(C.y-B.y), C.x-B.x )
* n2 = direction perpendicular to AC = ( -(C.y-A.y), C.x-A.x )
*
* Solve A + t·n1 = B + s·n2 for t:
* det = n1.x·n2.y − n1.y·n2.x
* t = ( (B.x−A.x)·n2.y − (B.y−A.y)·n2.x ) / det
*/
function orthocentre(A, B, C) {
var n1x = -(C.y - B.y),
n1y = C.x - B.x;
var n2x = -(C.y - A.y),
n2y = C.x - A.x;
var det = n1x * n2y - n1y * n2x;
if (Math.abs(det) < 1e-9) {
return { x: (A.x + B.x + C.x) / 3, y: (A.y + B.y + C.y) / 3 };
}
var t = ((B.x - A.x) * n2y - (B.y - A.y) * n2x) / det;
return { x: A.x + t * n1x, y: A.y + t * n1y };
}
function angleDeg(V, A, B) {
var ax = A.x - V.x,
ay = A.y - V.y;
var bx = B.x - V.x,
by = B.y - V.y;
var cos = (ax * bx + ay * by) / (Math.hypot(ax, ay) * Math.hypot(bx, by));
return (Math.acos(Math.max(-1, Math.min(1, cos))) * 180) / Math.PI;
}
function labelOffset(V, others, mag) {
var dx = 0,
dy = 0;
others.forEach(function (O) {
dx += V.x - O.x;
dy += V.y - O.y;
});
var l = Math.hypot(dx, dy) || 1;
return { x: V.x + (dx / l) * mag, y: V.y + (dy / l) * mag };
}
function toCm(px) {
return px / SCALE;
}
function fmt(n) {
return n.toFixed(2);
}
/* ── Build widget ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
function buildWidget(container) {
var lang = (window.mw && mw.config.get("wgUserLanguage")) || "en";
lang = lang.split("-")[0];
if (!messages[lang]) {
lang = "en";
}
var msg = messages[lang];
/* -- HTML skeleton -- */
container.innerHTML =
'<div class="hw-orthocentre">' +
'<canvas class="hw-canvas"></canvas>' +
"</div>";
/* -- Footer -- */
var footer = document.createElement("div");
footer.className = "hw-footer";
var fLogo = document.createElement("a");
fLogo.className = "hw-footer-icon";
fLogo.href = (window.mw && mw.util.getUrl("Wikipedia:Hiruwiki")) || "#";
fLogo.title = "Hiruwiki";
if (window.hiruwiki && window.hiruwiki.getLogoSvg) {
fLogo.insertAdjacentHTML("beforeend", hiruwiki.getLogoSvg(22));
}
var fText = document.createElement("span");
fText.className = "hw-footer__text";
fText.textContent = msg.hint;
footer.appendChild(fLogo);
footer.appendChild(fText);
container.appendChild(footer);
var canvas = container.querySelector(".hw-canvas");
var ctx = canvas.getContext("2d");
var W,
H,
verts,
dragging = -1;
function $(id) {
return container.querySelector("#" + id);
}
/* -- vertex initialisation -- */
function initVerts() {
var ox = W / 2 - 5.5 * SCALE;
var oy = 20;
verts = [
{ x: ox + 5.5 * SCALE, y: oy + 1.2 * SCALE },
{ x: ox + 1.2 * SCALE, y: oy + 8.0 * SCALE },
{ x: ox + 9.8 * SCALE, y: oy + 8.0 * SCALE },
];
}
/* -- colour palette -- */
function isDark() {
/* MediaWiki night-mode classes take priority; fall back to prefers-color-scheme */
var root = document.documentElement;
if (
root.classList.contains("skin-theme-clientpref-night") ||
root.classList.contains("client-dark-mode") ||
document.body.classList.contains("mw-dark-mode")
) {
return true;
}
return !!(
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches
);
}
function palette() {
var dark = isDark();
return {
dark: dark,
tri: hiruwiki.getThemeColor(
"hw-color-primary",
dark ? "#AFA9EC" : "#534AB7",
),
orth: hiruwiki.getThemeColor(
"hw-color-success",
dark ? "#5DCAA5" : "#0F6E56",
),
foot: hiruwiki.getThemeColor(
"hw-color-warning",
dark ? "#FAC775" : "#BA7517",
),
text: hiruwiki.getThemeColor(
"hw-text-base",
dark ? "#E8E6DC" : "#1a1a18",
),
ruleBg: hiruwiki.getThemeColor(
"hw-bg-subtle",
dark ? "#2C2C2A" : "#F1EFE8",
),
ruleTick: hiruwiki.getThemeColor("hw-text-placeholder", "#888780"),
ruleText: hiruwiki.getThemeColor("hw-text-muted", "#5F5E5A"),
grid: hiruwiki.getThemeColor("hw-border", dark ? "#444441" : "#D3D1C7"),
dotBg: hiruwiki.getThemeColor(
"hw-bg-base",
dark ? "#1e1e1c" : "#ffffff",
),
};
}
/* -- right-angle mark at altitude foot -- */
function drawRightAngle(T, fromV, V1, V2, color) {
var sdx = V2.x - V1.x,
sdy = V2.y - V1.y;
var sl = Math.hypot(sdx, sdy);
if (sl < 1e-6) {
return;
}
var su = { x: sdx / sl, y: sdy / sl };
var rdx = fromV.x - T.x,
rdy = fromV.y - T.y;
var rl = Math.hypot(rdx, rdy);
if (rl < 1e-6) {
return;
}
var ru = { x: rdx / rl, y: rdy / rl };
var SZ = 12;
ctx.strokeStyle = color;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(T.x + su.x * SZ, T.y + su.y * SZ);
ctx.lineTo(T.x + su.x * SZ + ru.x * SZ, T.y + su.y * SZ + ru.y * SZ);
ctx.lineTo(T.x + ru.x * SZ, T.y + ru.y * SZ);
ctx.stroke();
}
/* -- grid (major cm lines only, very faint) -- */
function drawGrid(p) {
ctx.lineWidth = 0.5;
ctx.strokeStyle = p.grid;
ctx.globalAlpha = 0.18;
/* vertical lines */
for (var cm = 0; cm * SCALE < W + SCALE; cm++) {
ctx.beginPath();
ctx.moveTo(cm * SCALE, 0);
ctx.lineTo(cm * SCALE, H);
ctx.stroke();
}
/* horizontal lines */
for (var row = 0; row * SCALE < H + SCALE; row++) {
ctx.beginPath();
ctx.moveTo(0, row * SCALE);
ctx.lineTo(W, row * SCALE);
ctx.stroke();
}
ctx.globalAlpha = 1.0;
}
/* -- main draw -- */
function draw() {
if (!verts) {
return;
}
ctx.clearRect(0, 0, W, H);
var p = palette();
drawGrid(p);
/* ruler removed */
var A = verts[0],
B = verts[1],
C = verts[2];
var a = dist(B, C),
b = dist(C, A),
c = dist(A, B);
var Hpt = orthocentre(A, B, C);
var Ha = footPerp(A, B, C);
var Hb = footPerp(B, C, A);
var Hc = footPerp(C, A, B);
var angA = angleDeg(A, B, C);
var angB = angleDeg(B, A, C);
var angC = angleDeg(C, A, B);
var isObtuse = angA > 90.05 || angB > 90.05 || angC > 90.05;
var cx3 = (A.x + B.x + C.x) / 3,
cy3 = (A.y + B.y + C.y) / 3;
var footColor = p.foot;
var footDash = p.dark
? "rgba(250,199,117,0.55)"
: "rgba(186,117,23,0.50)";
/* 1. solid altitude segments: vertex → foot */
ctx.save();
ctx.strokeStyle = footColor;
ctx.lineWidth = 1.4;
[
[A, Ha],
[B, Hb],
[C, Hc],
].forEach(function (pr) {
ctx.beginPath();
ctx.moveTo(pr[0].x, pr[0].y);
ctx.lineTo(pr[1].x, pr[1].y);
ctx.stroke();
});
ctx.restore();
/* 2. dashed extensions for obtuse triangle */
if (isObtuse) {
ctx.save();
ctx.strokeStyle = footDash;
ctx.lineWidth = 1.4;
ctx.setLineDash([5, 5]);
/* 2a. Extend triangle SIDES with dashes where a foot falls outside the segment.
* footT gives projection parameter t; outside [0,1] means foot is off the edge.
* Side a = BC (foot Ha from A), side b = CA (foot Hb from B), side c = AB (foot Hc from C). */
[
{ foot: Ha, V1: B, V2: C } /* altitude from A, foot on line BC */,
{ foot: Hb, V1: C, V2: A } /* altitude from B, foot on line CA */,
{ foot: Hc, V1: A, V2: B } /* altitude from C, foot on line AB */,
].forEach(function (s) {
var t = footT(s.foot, s.V1, s.V2);
if (t < -0.001) {
/* foot is beyond V1 — draw dashed from V1 to foot */
ctx.beginPath();
ctx.moveTo(s.V1.x, s.V1.y);
ctx.lineTo(s.foot.x, s.foot.y);
ctx.stroke();
} else if (t > 1.001) {
/* foot is beyond V2 — draw dashed from V2 to foot */
ctx.beginPath();
ctx.moveTo(s.V2.x, s.V2.y);
ctx.lineTo(s.foot.x, s.foot.y);
ctx.stroke();
}
});
/* 2b. Extend altitude lines with dashes from foot through to H.
* The solid segment already covers vertex→foot; the dashed part is foot→H. */
[
[A, Ha],
[B, Hb],
[C, Hc],
].forEach(function (pr) {
var V = pr[0],
F = pr[1];
var dx = F.x - V.x,
dy = F.y - V.y;
var len = Math.hypot(dx, dy);
if (len < 1e-6) {
return;
}
var ux = dx / len,
uy = dy / len;
/* scalar projection of (H - F) onto the altitude direction */
var toH = (Hpt.x - F.x) * ux + (Hpt.y - F.y) * uy;
if (Math.abs(toH) < 1) {
return;
} /* foot is essentially at H */
/* extend from foot toward H (+ 8 px overshoot) */
var ext = toH + 8 * Math.sign(toH);
ctx.beginPath();
ctx.moveTo(F.x, F.y);
ctx.lineTo(F.x + ux * ext, F.y + uy * ext);
ctx.stroke();
});
ctx.setLineDash([]);
ctx.restore();
}
/* 3. triangle fill + stroke */
ctx.save();
ctx.beginPath();
ctx.moveTo(A.x, A.y);
ctx.lineTo(B.x, B.y);
ctx.lineTo(C.x, C.y);
ctx.closePath();
ctx.fillStyle = p.dark ? "rgba(175,169,236,.07)" : "rgba(83,74,183,.06)";
ctx.fill();
ctx.strokeStyle = p.tri;
ctx.lineWidth = 2;
ctx.stroke();
ctx.restore();
/* 4. right-angle marks at altitude feet */
ctx.save();
drawRightAngle(Ha, A, B, C, footColor);
drawRightAngle(Hb, B, C, A, footColor);
drawRightAngle(Hc, C, A, B, footColor);
ctx.restore();
/* 5. altitude foot dots */
[Ha, Hb, Hc].forEach(function (F) {
ctx.save();
ctx.beginPath();
ctx.arc(F.x, F.y, 5, 0, Math.PI * 2);
ctx.fillStyle = footColor;
ctx.fill();
ctx.restore();
});
/* 6. orthocentre dot */
ctx.save();
ctx.beginPath();
ctx.arc(Hpt.x, Hpt.y, 8, 0, Math.PI * 2);
ctx.fillStyle = p.orth;
ctx.fill();
ctx.strokeStyle = p.dotBg;
ctx.lineWidth = 2;
ctx.stroke();
ctx.restore();
/* 7. vertex dots */
verts.forEach(function (V, i) {
ctx.save();
ctx.beginPath();
ctx.arc(V.x, V.y, dragging === i ? 8 : 6.5, 0, Math.PI * 2);
ctx.fillStyle = p.tri;
ctx.fill();
ctx.strokeStyle = p.dotBg;
ctx.lineWidth = 2;
ctx.stroke();
ctx.restore();
});
/* 8. text labels */
ctx.save();
ctx.textBaseline = "middle";
/* vertex names */
ctx.font =
"italic 700 18px 'Latin Modern Math', 'STIX Two Math', 'Computer Modern', 'Times New Roman', serif";
[A, B, C].forEach(function (V, i) {
var lbl = labelOffset(
V,
verts.filter(function (_, j) {
return j !== i;
}),
26,
);
ctx.fillStyle = p.tri;
ctx.textAlign = "center";
ctx.fillText(["A", "B", "C"][i], lbl.x, lbl.y);
});
/* H label — offset +16, −16 from dot */
ctx.fillStyle = p.orth;
ctx.font =
"italic 700 18px 'Latin Modern Math', 'STIX Two Math', 'Computer Modern', 'Times New Roman', serif";
ctx.textAlign = "center";
ctx.fillText("H", Hpt.x + 16, Hpt.y - 16);
/* side length labels removed */
ctx.restore();
}
/* -- resize -- */
function resize() {
W = canvas.offsetWidth;
H = 440;
canvas.width = W * DPR;
canvas.height = H * DPR;
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
initVerts();
draw();
}
/* -- pointer helpers -- */
function ptFromEvent(e) {
var rect = canvas.getBoundingClientRect();
var src = e.touches ? e.touches[0] : e;
return {
x: (src.clientX - rect.left) * (W / rect.width),
y: (src.clientY - rect.top) * (H / rect.height),
};
}
function hitTest(pt) {
return verts.findIndex(function (v) {
return Math.hypot(v.x - pt.x, v.y - pt.y) < 16;
});
}
function clamp(pt) {
return {
x: Math.max(10, Math.min(W - 10, pt.x)),
y: Math.max(4, Math.min(H - 10, pt.y)),
};
}
canvas.addEventListener("mousedown", function (e) {
dragging = hitTest(ptFromEvent(e));
if (dragging >= 0) {
canvas.style.cursor = "grabbing";
}
});
canvas.addEventListener("mousemove", function (e) {
if (dragging < 0) {
canvas.style.cursor =
hitTest(ptFromEvent(e)) >= 0 ? "grab" : "crosshair";
return;
}
verts[dragging] = clamp(ptFromEvent(e));
draw();
});
canvas.addEventListener("mouseup", function () {
dragging = -1;
canvas.style.cursor = "crosshair";
});
canvas.addEventListener("mouseleave", function () {
dragging = -1;
});
canvas.addEventListener(
"touchstart",
function (e) {
e.preventDefault();
dragging = hitTest(ptFromEvent(e));
},
{ passive: false },
);
canvas.addEventListener(
"touchmove",
function (e) {
e.preventDefault();
if (dragging < 0) {
return;
}
verts[dragging] = clamp(ptFromEvent(e));
draw();
},
{ passive: false },
);
canvas.addEventListener("touchend", function () {
dragging = -1;
});
/* dark-mode live update (MediaWiki) */
var observer = new MutationObserver(draw);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
observer.observe(document.body, {
attributes: true,
attributeFilter: ["class"],
});
/* kick off */
var ro = new ResizeObserver(resize);
ro.observe(canvas);
}
/* ----------------------------------------------------------
* Initialise all matching containers on the page
* ---------------------------------------------------------- */
document
.querySelectorAll('.hiruwiki[data-module="orthocentre"]')
.forEach(buildWidget);
})();
f1tbcg9b6kolgklrg8ig73iht35fnhi
MediaWiki:Hiruwiki/modules/pentagon-angles.js
8
179509
781437
770236
2026-10-28T10:39:45Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781437
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
( function () {
'use strict';
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"nl": {
"3triangles": "3 driehoeken × 180°",
"_name": "Hoeken van een vijfhoek",
"interiorAngles": "Binnenhoeken",
"reset": "↺ Opnieuw instellen",
"total": "Totaal",
"hint": "Verplaats de hoeken om de figuur te vervormen"
},
"fr": {
"3triangles": "3 triangles × 180°",
"_name": "Angles du pentagone",
"interiorAngles": "Angles intérieurs",
"reset": "↺ Réinitialiser",
"total": "Total",
"hint": "Décomposer le pentagone en triangles"
},
"tr": {
"3triangles": "3 üçgen × 180°",
"_name": "Beşgen Açıları",
"interiorAngles": "İç açılar",
"reset": "↺ Sıfırla",
"total": "Toplam",
"hint": "Beşgeni üçgenlere ayırın"
},
"eu": {
"3triangles": "3 triangelu × 180°",
"_name": "Pentagonoaren angeluak",
"interiorAngles": "Barne angeluak",
"reset": "↺ Berrezarri",
"total": "Guztira",
"hint": "Pentagonoa triangeluetan deskonposatu"
},
"sk": {
"3triangles": "3 trojuholníky × 180°",
"_name": "Uhly päťuholníka",
"interiorAngles": "Vnútorné uhly",
"reset": "↺ Vynulovať",
"total": "Spolu",
"hint": "Rozložte päťuholník na trojuholníky"
},
"skr-arab": {
"3triangles": "3 مثلث × 180°",
"_name": "پینٹاگون زاویے",
"interiorAngles": "اندرونی زاویے",
"reset": "↺ دوبارہ",
"total": "کل",
"hint": "پینٹاگون کوں مثلثاں وچ ونڈو"
},
"ko": {
"3triangles": "3개의 삼각형 × 180°",
"_name": "오각형의 각들",
"interiorAngles": "내부 각들",
"reset": "↺ 초기화",
"total": "합계",
"hint": "오각형을 삼각형으로 분해하세요"
},
"en": {
"3triangles": "3 triangles × 180°",
"_name": "Pentagon Angles",
"interiorAngles": "Interior angles",
"reset": "↺ Reset",
"total": "Total",
"hint": "Decompose the pentagon into triangles"
},
"ca": {
"3triangles": "3 triangles × 180°",
"_name": "Angles del Pentàgon",
"interiorAngles": "Angles interiors",
"total": "Total"
},
"qqq": {
"3triangles": "Decomposition label showing 3 triangles × 180°",
"_name": "Name of the Pentagon Angles module",
"interiorAngles": "Heading for the interior angles section",
"reset": "Button label to reset the visualisation",
"total": "Label for the total sum of interior angles",
"hint": "Instruction text for the pentagon angle decomposition applet"
},
"ga": {
"3triangles": "3 thriantán × 180°",
"_name": "Uillinneacha an Pheinteagáin",
"interiorAngles": "Uillinneacha inmheánacha",
"reset": "↺ Athshocraigh",
"total": "Iomlán",
"hint": "Déan an peinteagán a dhíscaoileadh ina thriantáin"
},
"es": {
"3triangles": "3 triángulos × 180°",
"_name": "Ángulos del pentágono",
"interiorAngles": "Ángulos interiores",
"reset": "↺ Reiniciar",
"total": "Total",
"hint": "Descompone el pentágono en triángulos"
},
"mk": {
"3triangles": "3 триаголници × 180°",
"_name": "Агли на петаголник",
"interiorAngles": "Внатрешни агли",
"reset": "↺ Одново",
"total": "Вкупно",
"hint": "Разложи го петаголникот на триаголници"
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
var SVG_W = 540, SVG_H = 480;
var SVG_NS = 'http://www.w3.org/2000/svg';
var KEYS = ['A', 'B', 'C', 'D', 'E'];
var COLORS = { A: 'red', B: 'green', C: 'blue', D: 'orange', E: '#9966cc' };
function initPentagonAngles( root ) {
var wrapper = document.createElement( 'div' );
wrapper.className = 'pentagon-angles';
root.parentNode.replaceChild( wrapper, root );
// ── Build SVG ─────────────────────────────────────────────────────────
var svgEl = document.createElementNS( SVG_NS, 'svg' );
svgEl.setAttribute( 'viewBox', '0 0 ' + SVG_W + ' ' + SVG_H );
svgEl.setAttribute( 'width', SVG_W );
svgEl.classList.add( 'qa-svg' );
// SVG background rect — must come before defs/grid so it sits behind everything.
// This is what makes the polygon outline and other strokes visible in dark mode:
// without it the CSS background on .qa-svg was doing the work, but SVG stroke
// attributes set via getThemeColor at init time resolve correctly against a
// known background. Keeping background: transparent on .qa-svg prevents a
// white flash before JS runs.
var bgRect = document.createElementNS( SVG_NS, 'rect' );
bgRect.setAttribute( 'width', '100%' );
bgRect.setAttribute( 'height', '100%' );
bgRect.setAttribute( 'fill', hiruwiki.getThemeColor('background-color-base', '#ffffff') );
svgEl.appendChild( bgRect );
var defs = document.createElementNS( SVG_NS, 'defs' );
var pat = document.createElementNS( SVG_NS, 'pattern' );
pat.setAttribute( 'id', 'pa-grid' );
pat.setAttribute( 'width', '20' );
pat.setAttribute( 'height', '20' );
pat.setAttribute( 'patternUnits', 'userSpaceOnUse' );
var gridPath = document.createElementNS( SVG_NS, 'path' );
gridPath.setAttribute( 'd', 'M 20 0 L 0 0 0 20' );
gridPath.setAttribute( 'fill', 'none' );
// Grid lines: border-color-subtle (very faint) — #c8ccd1 light / #2e3136 dark
gridPath.setAttribute( 'stroke', hiruwiki.getThemeColor('border-color-subtle', '#c8ccd1') );
gridPath.setAttribute( 'stroke-width', '0.5' );
pat.appendChild( gridPath );
defs.appendChild( pat );
svgEl.appendChild( defs );
var gridRect = document.createElementNS( SVG_NS, 'rect' );
gridRect.setAttribute( 'width', '100%' );
gridRect.setAttribute( 'height', '100%' );
gridRect.setAttribute( 'fill', 'url(#pa-grid)' );
svgEl.appendChild( gridRect );
// Triangle fills (3, updated when fan changes) — data colors, no token needed
var triFills = [
document.createElementNS( SVG_NS, 'polygon' ),
document.createElementNS( SVG_NS, 'polygon' ),
document.createElementNS( SVG_NS, 'polygon' )
];
var triColors = ['rgba(255,200,200,0.3)', 'rgba(200,255,200,0.3)', 'rgba(200,200,255,0.3)'];
triFills.forEach( function(tf, i) {
tf.setAttribute( 'fill', triColors[i] );
tf.setAttribute( 'stroke', 'none' );
svgEl.appendChild( tf );
});
// Pentagon outline — color-base: #202122 light / ~#f8f9fa dark
var polyEl = document.createElementNS( SVG_NS, 'polygon' );
polyEl.setAttribute( 'fill', 'none' );
polyEl.setAttribute( 'stroke', hiruwiki.getThemeColor('color-base', '#202122') );
polyEl.setAttribute( 'stroke-width', '2' );
svgEl.appendChild( polyEl );
// Fan diagonals — color-placeholder: #72777d (same light/dark)
var diag1 = document.createElementNS( SVG_NS, 'line' );
var diag2 = document.createElementNS( SVG_NS, 'line' );
[diag1, diag2].forEach( function(d) {
d.setAttribute( 'stroke', hiruwiki.getThemeColor('color-placeholder', '#72777d') );
d.setAttribute( 'stroke-width', '1' );
d.setAttribute( 'stroke-dasharray', '5,4' );
svgEl.appendChild( d );
});
// Angle arcs, labels, vertex circles
var arcs = {}, lbls = {}, circs = {};
KEYS.forEach( function(k) {
var arc = document.createElementNS( SVG_NS, 'path' );
arc.setAttribute( 'fill', COLORS[k] );
arc.setAttribute( 'opacity', '0.4' );
svgEl.appendChild( arc );
arcs[k] = arc;
var lbl = document.createElementNS( SVG_NS, 'text' );
lbl.setAttribute( 'fill', COLORS[k] );
lbl.setAttribute( 'font-weight', 'bold' );
lbl.setAttribute( 'font-size', '16' );
lbl.setAttribute( 'font-family', 'Arial, sans-serif' );
lbl.setAttribute( 'pointer-events', 'none' );
svgEl.appendChild( lbl );
lbls[k] = lbl;
var circ = document.createElementNS( SVG_NS, 'circle' );
circ.setAttribute( 'r', '8' );
circ.setAttribute( 'fill', COLORS[k] );
// Drag handle ring — background-color-base so it punches through polygon fill
circ.setAttribute( 'stroke', hiruwiki.getThemeColor('background-color-base', '#ffffff') );
circ.setAttribute( 'stroke-width', '2' );
circ.style.cursor = 'pointer';
svgEl.appendChild( circ );
circs[k] = circ;
});
// ── Build panel ───────────────────────────────────────────────────────
var panel = document.createElement( 'div' );
panel.className = 'qa-panel';
var h3 = document.createElement( 'h3' );
h3.textContent = t('interiorAngles');
panel.appendChild( h3 );
var valEls = {}, barEls = {};
KEYS.forEach( function(k) {
var row = document.createElement( 'div' );
row.className = 'qa-angle-row';
var dot = document.createElement( 'div' );
dot.className = 'qa-dot'; dot.style.background = COLORS[k];
var letter = document.createElement( 'div' );
letter.className = 'qa-letter'; letter.style.color = COLORS[k]; letter.textContent = k;
var barWrap = document.createElement( 'div' );
barWrap.className = 'qa-bar-wrap';
var bar = document.createElement( 'div' );
bar.className = 'qa-bar'; bar.style.background = COLORS[k];
barWrap.appendChild( bar ); barEls[k] = bar;
var val = document.createElement( 'div' );
val.className = 'qa-val'; val.style.color = COLORS[k]; val.textContent = '\u2014';
valEls[k] = val;
row.appendChild(dot); row.appendChild(letter); row.appendChild(barWrap); row.appendChild(val);
panel.appendChild( row );
});
// Triangle decomposition section
var sumSection = document.createElement('div');
sumSection.className = 'qa-sum-section';
var sumLabel = document.createElement('div');
sumLabel.className = 'qa-pie-label';
sumLabel.textContent = t('3triangles');
sumSection.appendChild(sumLabel);
var triRowLabels = [];
var triBarBgs = [];
var triBarSegs = [];
var triBarLbls = [];
for (var ti = 0; ti < 3; ti++) {
var rowWrap = document.createElement('div');
rowWrap.className = 'qa-tri-row';
var rowLabel = document.createElement('div');
rowLabel.className = 'qa-tri-label';
rowWrap.appendChild(rowLabel);
triRowLabels.push(rowLabel);
var barSVG = document.createElementNS(SVG_NS, 'svg');
barSVG.setAttribute('viewBox', '0 0 195 14');
barSVG.setAttribute('width', '100%');
barSVG.setAttribute('height', '14');
var bg = document.createElementNS(SVG_NS, 'rect');
bg.setAttribute('x','0'); bg.setAttribute('y','0');
bg.setAttribute('width','160'); bg.setAttribute('height','14');
// Bar track: background-color-neutral (#eaecf0 / #2a2d34)
bg.setAttribute('fill', hiruwiki.getThemeColor('background-color-neutral', '#eaecf0'));
bg.setAttribute('rx','3');
barSVG.appendChild(bg);
triBarBgs.push(bg);
var segs = [];
for (var si = 0; si < 3; si++) {
var seg = document.createElementNS(SVG_NS, 'rect');
seg.setAttribute('y','0'); seg.setAttribute('height','14');
seg.setAttribute('width','0'); seg.setAttribute('x','0');
seg.setAttribute('opacity','0.7');
barSVG.appendChild(seg);
segs.push(seg);
}
triBarSegs.push(segs);
var border = document.createElementNS(SVG_NS, 'rect');
border.setAttribute('x','0'); border.setAttribute('y','0');
border.setAttribute('width','160'); border.setAttribute('height','14');
border.setAttribute('fill','none');
// Bar outline: border-color-base (#a2a9b1 / #3d424d)
border.setAttribute('stroke', hiruwiki.getThemeColor('border-color-base', '#a2a9b1'));
border.setAttribute('stroke-width','1'); border.setAttribute('rx','3');
barSVG.appendChild(border);
var lbl180 = document.createElementNS(SVG_NS, 'text');
lbl180.setAttribute('x','164'); lbl180.setAttribute('y','11');
lbl180.setAttribute('font-size','10');
// "180°" label: color-placeholder (#72777d, same in both modes)
lbl180.setAttribute('fill', hiruwiki.getThemeColor('color-placeholder', '#72777d'));
lbl180.setAttribute('font-family','Arial,sans-serif');
lbl180.textContent = '180\u00b0';
barSVG.appendChild(lbl180);
triBarLbls.push(lbl180);
rowWrap.appendChild(barSVG);
sumSection.appendChild(rowWrap);
}
var eq = document.createElement('div');
eq.className = 'qa-equation';
eq.innerHTML = '<span style="color:red">A</span> + ' +
'<span style="color:green">B</span> + ' +
'<span style="color:blue">C</span> + ' +
'<span style="color:orange">D</span> + ' +
'<span style="color:#9966cc">E</span> = 540\u00b0';
sumSection.appendChild(eq);
panel.appendChild(sumSection);
var totalRow = document.createElement('div');
totalRow.className = 'qa-total-row';
var totalLabel = document.createElement('span');
totalLabel.className = 'qa-total-label'; totalLabel.textContent = t('total');
var totalVal = document.createElement('span');
totalVal.className = 'qa-total-val'; totalVal.textContent = '540.0\u00b0';
totalRow.appendChild(totalLabel); totalRow.appendChild(totalVal);
panel.appendChild(totalRow);
var resetBtn = document.createElement('button');
resetBtn.className = 'qa-reset-btn'; resetBtn.textContent = '\u21ba ' + t('reset');
panel.appendChild(resetBtn);
wrapper.appendChild(svgEl);
wrapper.appendChild(panel);
// ── State ─────────────────────────────────────────────────────────────
var cx = 270, cy = 220, r = 160;
var initPos = {};
KEYS.forEach( function(k, i) {
var angle = -Math.PI/2 + (2*Math.PI/5)*i;
initPos[k] = { x: cx + r*Math.cos(angle), y: cy + r*Math.sin(angle) };
});
var pos = {};
KEYS.forEach( function(k) { pos[k] = {}; });
var dragKey = null;
function applyPos() {
KEYS.forEach( function(k) {
circs[k].cx.baseVal.value = pos[k].x;
circs[k].cy.baseVal.value = pos[k].y;
});
}
function resetPos(p) {
KEYS.forEach( function(k) { pos[k] = { x: p[k].x, y: p[k].y }; });
applyPos();
}
resetPos(initPos);
// ── Geometry ──────────────────────────────────────────────────────────
function dist(a, b) { return Math.hypot(a.x-b.x, a.y-b.y); }
function segmentsIntersect(p1, p2, p3, p4) {
var d1x=p2.x-p1.x, d1y=p2.y-p1.y, d2x=p4.x-p3.x, d2y=p4.y-p3.y;
var den=d1x*d2y-d1y*d2x;
if (Math.abs(den)<1e-10) return false;
var t=((p3.x-p1.x)*d2y-(p3.y-p1.y)*d2x)/den;
var u=((p3.x-p1.x)*d1y-(p3.y-p1.y)*d1x)/den;
return t>0 && t<1 && u>0 && u<1;
}
function isSelfIntersecting(p) {
return segmentsIntersect(p.A,p.B,p.C,p.D) ||
segmentsIntersect(p.A,p.B,p.D,p.E) ||
segmentsIntersect(p.B,p.C,p.D,p.E) ||
segmentsIntersect(p.B,p.C,p.E,p.A) ||
segmentsIntersect(p.C,p.D,p.E,p.A);
}
function signedArea(p) {
var a=0;
for (var i=0;i<5;i++) {
var pi=p[KEYS[i]], pj=p[KEYS[(i+1)%5]];
a += pi.x*pj.y - pj.x*pi.y;
}
return a/2;
}
function triSignedArea(P1,P2,P3) {
return ((P2.x-P1.x)*(P3.y-P1.y)-(P3.x-P1.x)*(P2.y-P1.y))/2;
}
function triAngle(V,P,N) {
var a=dist(P,N), b=dist(V,N), c=dist(V,P);
return Math.acos(Math.max(-1,Math.min(1,(b*b+c*c-a*a)/(2*b*c))))*180/Math.PI;
}
function interiorAngle(V,P,N,area) {
var a=dist(P,N), b=dist(V,N), c=dist(V,P);
var base=Math.acos(Math.max(-1,Math.min(1,(b*b+c*c-a*a)/(2*b*c))))*180/Math.PI;
var cross=(P.x-V.x)*(N.y-V.y)-(P.y-V.y)*(N.x-V.x);
return (area>0?cross>0:cross<0)?360-base:base;
}
function drawAngleArc(center,p1,p2,radius,isReflex) {
var v1x=p1.x-center.x, v1y=p1.y-center.y;
var v2x=p2.x-center.x, v2y=p2.y-center.y;
var a1=Math.atan2(v1y,v1x), a2=Math.atan2(v2y,v2x);
var start=a1, end=a2, cross=v1x*v2y-v1y*v2x, tmp;
if (isReflex?cross>0:cross<0){tmp=start;start=end;end=tmp;}
if (end<start) end+=2*Math.PI;
var large=(end-start)>Math.PI?1:0;
var x1=center.x+radius*Math.cos(start), y1=center.y+radius*Math.sin(start);
var x2=center.x+radius*Math.cos(end), y2=center.y+radius*Math.sin(end);
return 'M'+center.x+','+center.y+' L'+x1+','+y1+
' A'+radius+','+radius+' 0 '+large+' 1 '+x2+','+y2+' Z';
}
function findFanKeys(p, polyArea) {
for (var i=0; i<5; i++) {
var idx=[];
for(var j=0;j<5;j++) idx.push(KEYS[(i+j)%5]);
var sa0=triSignedArea(p[idx[0]],p[idx[1]],p[idx[2]]);
var sa1=triSignedArea(p[idx[0]],p[idx[2]],p[idx[3]]);
var sa2=triSignedArea(p[idx[0]],p[idx[3]],p[idx[4]]);
var valid=polyArea>0?(sa0>0&&sa1>0&&sa2>0):(sa0<0&&sa1<0&&sa2<0);
if (valid) return idx;
}
return [KEYS[0],KEYS[1],KEYS[2],KEYS[3],KEYS[4]];
}
function pts2str(arr) {
return arr.map(function(pt){return pt.x+','+pt.y;}).join(' ');
}
function updatePoly() {
var p = {};
KEYS.forEach( function(k) {
p[k]={x:circs[k].cx.baseVal.value, y:circs[k].cy.baseVal.value};
});
polyEl.setAttribute('points', KEYS.map(function(k){return p[k].x+','+p[k].y;}).join(' '));
var area = signedArea(p);
var n = 5;
var verts = {};
KEYS.forEach( function(k,i) {
verts[k]={ V:p[k], P:p[KEYS[(i+n-1)%n]], N:p[KEYS[(i+1)%n]] };
});
var angles={}, isReflex={};
KEYS.forEach( function(k) {
var V=verts[k].V, P=verts[k].P, N=verts[k].N;
var cross=(P.x-V.x)*(N.y-V.y)-(P.y-V.y)*(N.x-V.x);
isReflex[k]=area>0?cross>0:cross<0;
angles[k]=interiorAngle(V,P,N,area);
arcs[k].setAttribute('d',drawAngleArc(V,P,N,36,isReflex[k]));
});
var centX=0,centY=0;
KEYS.forEach(function(k){centX+=p[k].x;centY+=p[k].y;}); centX/=5;centY/=5;
KEYS.forEach( function(k) {
var V=verts[k].V, P=verts[k].P, N=verts[k].N;
var u1x=P.x-V.x,u1y=P.y-V.y,u2x=N.x-V.x,u2y=N.y-V.y;
var l1=Math.hypot(u1x,u1y),l2=Math.hypot(u2x,u2y);
var bx=u1x/l1+u2x/l2,by=u1y/l1+u2y/l2,bm=Math.hypot(bx,by);
if(bm<1e-6){bx=centX-V.x;by=centY-V.y;}else{bx/=bm;by/=bm;}
if(bx*(centX-V.x)+by*(centY-V.y)<0){bx=-bx;by=-by;}
lbls[k].setAttribute('x',V.x+bx*55-22);
lbls[k].setAttribute('y',V.y+by*55+6);
lbls[k].textContent=angles[k].toFixed(1)+'\u00b0';
});
var fk = findFanKeys(p, area);
diag1.setAttribute('x1',p[fk[0]].x); diag1.setAttribute('y1',p[fk[0]].y);
diag1.setAttribute('x2',p[fk[2]].x); diag1.setAttribute('y2',p[fk[2]].y);
diag2.setAttribute('x1',p[fk[0]].x); diag2.setAttribute('y1',p[fk[0]].y);
diag2.setAttribute('x2',p[fk[3]].x); diag2.setAttribute('y2',p[fk[3]].y);
triFills[0].setAttribute('points',pts2str([p[fk[0]],p[fk[1]],p[fk[2]]]));
triFills[1].setAttribute('points',pts2str([p[fk[0]],p[fk[2]],p[fk[3]]]));
triFills[2].setAttribute('points',pts2str([p[fk[0]],p[fk[3]],p[fk[4]]]));
var triDefs = [
[fk[0],fk[1],fk[2]],
[fk[0],fk[2],fk[3]],
[fk[0],fk[3],fk[4]]
];
triDefs.forEach( function(tri, ti) {
triRowLabels[ti].textContent = '\u25b3 ' + tri[0]+tri[1]+tri[2];
var angAt0 = triAngle(p[tri[0]], p[tri[1]], p[tri[2]]);
var angAt1 = triAngle(p[tri[1]], p[tri[0]], p[tri[2]]);
var angAt2 = triAngle(p[tri[2]], p[tri[0]], p[tri[1]]);
var triAngles = [angAt0, angAt1, angAt2];
var BAR_W = 160;
var x = 0;
tri.forEach( function(k, si) {
var w = (triAngles[si] / 180) * BAR_W;
triBarSegs[ti][si].setAttribute('fill', COLORS[k]);
triBarSegs[ti][si].setAttribute('x', x);
triBarSegs[ti][si].setAttribute('width', w);
x += w;
});
// Bar track background re-applied each frame — keep token consistent with init
triBarBgs[ti].setAttribute('fill', hiruwiki.getThemeColor('background-color-neutral', '#eaecf0'));
// "180°" label color — color-placeholder (#72777d)
triBarLbls[ti].setAttribute('fill', hiruwiki.getThemeColor('color-placeholder', '#72777d'));
triBarLbls[ti].textContent = '180\u00b0';
});
var sum=0;
KEYS.forEach( function(k) {
sum+=angles[k];
valEls[k].textContent=angles[k].toFixed(1)+'\u00b0';
barEls[k].style.width=(angles[k]/540*100)+'%';
});
totalVal.textContent=sum.toFixed(1)+'\u00b0';
}
// ── Drag (mouse + touch) ──────────────────────────────────────────────
function tryMove(nx, ny) {
var cand={};
KEYS.forEach(function(k){cand[k]={x:pos[k].x,y:pos[k].y};});
cand[dragKey]={x:nx,y:ny};
if (!isSelfIntersecting(cand)){
pos[dragKey]={x:nx,y:ny};
applyPos();
updatePoly();
}
}
function svgPoint(clientX, clientY) {
var pt = svgEl.createSVGPoint();
pt.x = clientX;
pt.y = clientY;
return pt.matrixTransform(svgEl.getScreenCTM().inverse());
}
// Mouse
KEYS.forEach( function(k) {
circs[k].addEventListener('mousedown',function(e){dragKey=k;e.preventDefault();});
});
svgEl.addEventListener('mousemove', function(e) {
if (!dragKey) return;
var pt=svgPoint(e.clientX,e.clientY);
tryMove(pt.x,pt.y);
});
svgEl.addEventListener('mouseup', function(){dragKey=null;});
svgEl.addEventListener('mouseleave', function(){dragKey=null;});
// Touch
svgEl.addEventListener('touchstart', function(e) {
e.preventDefault();
var pt = svgPoint(e.touches[0].clientX, e.touches[0].clientY);
var best = null, bestDist = 40;
KEYS.forEach(function(k) {
var d = Math.hypot(pos[k].x - pt.x, pos[k].y - pt.y);
if (d < bestDist) { bestDist = d; best = k; }
});
if (best) dragKey = best;
}, {passive: false});
svgEl.addEventListener('touchmove', function(e) {
if (!dragKey) return;
e.preventDefault();
var pt = svgPoint(e.touches[0].clientX, e.touches[0].clientY);
tryMove(pt.x, pt.y);
}, {passive: false});
svgEl.addEventListener('touchend', function() { dragKey = null; });
svgEl.addEventListener('touchcancel', function() { dragKey = null; });
resetBtn.addEventListener('click', function(){resetPos(initPos);updatePoly();});
updatePoly();
// Footer branding
var footer = document.createElement("div");
footer.className = "hw-footer";
var fLogo = document.createElement("a");
fLogo.className = "hw-footer-icon";
fLogo.href = mw.util.getUrl('Wikipedia:Hiruwiki');
fLogo.title = 'Hiruwiki';
fLogo.innerHTML = hiruwiki.getLogoSvg(22);
var fText = document.createElement("span");
fText.innerHTML = t('hint');
footer.appendChild(fLogo);
footer.appendChild(fText);
wrapper.appendChild(footer);
}
document.querySelectorAll('.hiruwiki[data-module="pentagon-angles"]').forEach(function(el){
initPentagonAngles(el);
});
}() );
iak5cqpniplodbzii9ffns4s7gsl8w1
MediaWiki:Hiruwiki/modules/percent.js
8
179511
781438
770238
2026-10-28T10:39:49Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781438
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/**
* Hiruwiki module: percent
* Visualises how positive and negative percentage changes work,
* and shows the asymmetry between a change and its recovery.
*
* Usage: <div class="hiruwiki" data-module="percent"></div>
*
* ─── I18N ────────────────────────────────────────────────────────────────────
* To add a language, add an entry to PERCENT_I18N below, using the
* MediaWiki language code as the key (e.g. 'eu' for Basque, 'es' for Spanish).
* Every key in the 'en' entry must be present in every other entry.
*
* Interpolation tokens:
* {pct} – the applied percentage, e.g. "−30"
* {result} – the value after applying the change, e.g. "70"
* {recovery} – the recovery percentage needed, e.g. "42.9"
* ─────────────────────────────────────────────────────────────────────────────
*/
( function () {
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"nl": {
"_name": "Percentages",
"axis_neg": "−100%",
"axis_pos": "+100%",
"axis_zero": "0%",
"hint": "Sleep de schuifregelaar naar links voor verlies, naar rechts voor winst.",
"insight_full_loss": "Een verlies van <strong>100%</strong> brengt de waarde naar <strong>0</strong>. Herstel vanaf nul is <strong>onmogelijk</strong> — elk percentage van 0 is nog steeds 0.",
"insight_gain": "Een winst van <strong>+$1%</strong> brengt 100 omhoog naar <strong>$2</strong>. De groene balk bovenaan en de rode balk onderaan zijn even breed — maar de percentages verschillen. Om terug te keren naar 100 is slechts <strong>$3%</strong> nodig, niet −$1%.",
"insight_loss": "Een verlies van <strong>$1%</strong> brengt 100 terug naar <strong>$2</strong>. De rode balk bovenaan en de groene balk onderaan bedekken dezelfde fysieke afstand — maar de percentages verschillen omdat ze op verschillende bases worden toegepast. Om te herstellen is <strong>+$3%</strong> nodig, niet +$1%.",
"insight_zero": "Geen wijziging toegepast. Sleep de schuifregelaar om te verkennen.",
"label_after": "na wijziging",
"label_apply": "PAS EEN PROCENTUELE AANPASSING TOE",
"label_recovered": "hersteld",
"label_scale": "SCHAAL: VOLLEDIGE BREEDTE = 200",
"label_start": "begin"
},
"fr": {
"_name": "Pourcentages",
"axis_neg": "−100%",
"axis_pos": "+100%",
"axis_zero": "0%",
"hint": "Faites glisser le curseur vers la gauche pour une perte, vers la droite pour un gain.",
"insight_full_loss": "Une perte de <strong>100%</strong> ramène la valeur à <strong>0</strong>. La récupération à partir de zéro est <strong>impossible</strong> — n'importe quel pourcentage de 0 est toujours 0.",
"insight_gain": "Un gain de <strong>+$1%</strong> porte 100 à <strong>$2</strong>. La barre verte en haut et la barre rouge en bas ont la même largeur — mais les pourcentages diffèrent. Revenir à 100 ne prend que <strong>$3%</strong>, pas −$1%.",
"insight_loss": "Une perte de <strong>$1%</strong> ramène 100 à <strong>$2</strong>. La barre rouge en haut et la barre verte en bas couvrent la même distance physique — mais les pourcentages diffèrent car ils s'appliquent à des bases différentes. La récupération nécessite <strong>+$3%</strong>, pas +$1%.",
"insight_zero": "Aucun changement appliqué. Faites glisser le curseur pour explorer.",
"label_after": "après changement",
"label_apply": "APPLIQUER UN CHANGEMENT EN POURCENTAGE",
"label_recovered": "récupéré",
"label_scale": "ÉCHELLE : LARGEUR TOTALE DE LA BARRE = 200",
"label_start": "début"
},
"tr": {
"_name": "Yüzdeler",
"axis_neg": "−%100",
"axis_pos": "+%100",
"axis_zero": "%0",
"hint": "Kaydırıcıyı sola kaydırarak değerini azaltın, sağa kaydırarak artırın.",
"insight_zero": "Değişiklik uygulanmadı. Keşfetmek için kaydırıcıyı sürükleyin.",
"label_after": "değişiklikten sonra",
"label_apply": "YÜZDE DEĞİŞİKLİĞİNİ UYGULA",
"label_recovered": "düzelmiş",
"label_scale": "ÖLÇEK: TAM ÇUBUK GENİŞLİĞİ = 200",
"label_start": "başlangıç"
},
"eu": {
"_name": "Ehunekoak",
"axis_neg": "−% 100",
"axis_pos": "+% 100",
"axis_zero": "% 0",
"hint": "Arrastatu kontrol-barra ezkerrera galera ikusteko, eskuinera irabazia ikusteko.",
"insight_full_loss": "<strong>% 100</strong>eko galera batek balioa <strong>0</strong>ra eramaten du. Zerotik berreskuratzea <strong>ezinezkoa</strong> da — edozein ehuneko 0 izanda ere 0 izango da.",
"insight_gain": "<strong>+% $1</strong>-ko irabaziak 100etik <strong>$2</strong>-ra igotzen du. Goiko barra berdeak eta beheko barra gorriak zabalera bera dute — baina ehunekoek desberdintasuna dute. 100era itzultzeko <strong>% $3</strong> besterik ez da behar, ez -% $1.",
"insight_loss": "<strong>% $1</strong>-ko galera batek 100 <strong>$2</strong>-era jaisten du. Goiko barra gorriak eta beheko barra berdeak distantzia fisiko bera hartzen dute — baina ehunekoek desberdintasuna dute, base ezberdinei aplikatzen zaizkie eta. Berreskuratzeko <strong>+% $3</strong> behar da, ez +% $1.",
"insight_zero": "Aldaketarik aplikatu gabe. Arrastatu kontrol-barra esploratzeko.",
"label_after": "aldaketaren ostean",
"label_apply": "EHUNEKO ALDAKETA BAT EZARRI",
"label_recovered": "berrezartzeko",
"label_scale": "ESKALA: BARRA OSOA = 200",
"label_start": "hasiera"
},
"sk": {
"_name": "Percentá",
"axis_neg": "−100%",
"axis_pos": "+100%",
"axis_zero": "0%",
"hint": "Potiahnite posuvník vľavo pre stratu, vpravo pre zisk.",
"insight_full_loss": "Strata <strong>100%</strong> zníži hodnotu na <strong>0</strong>. Zotavenie z nuly je <strong>nemožné</strong> — akékoľvek percento z 0 je stále 0.",
"insight_gain": "Zisk <strong>+$1%</strong> zvýši 100 na <strong>$2</strong>. Zelený úsek nad a červený úsek pod sú rovnako široké — no percentá sa líšia. Návrat na 100 vyžaduje len <strong>$3%</strong>, nie −$1%.",
"insight_loss": "Strata <strong>$1%</strong> zníži 100 na <strong>$2</strong>. Červený úsek nad a zelený úsek pod pokrývajú rovnakú fyzickú vzdialenosť — no percentá sa líšia, pretože sa vzťahujú na odlišné základy. Zotavenie vyžaduje <strong>+$3%</strong>, nie +$1%.",
"insight_zero": "Nebola použitá žiadna zmena. Posúvaním posuvníka skúmajte.",
"label_after": "po zmene",
"label_apply": "POUŽIŤ PERCENTUÁLNU ZMENU",
"label_recovered": "zotavené",
"label_scale": "MIERKA: PLNÁ ŠÍRKA PRUHU = 200",
"label_start": "začiatok"
},
"skr-arab": {
"_name": "فیصد",
"axis_neg": "−100%",
"axis_pos": "+100%",
"axis_zero": "0%",
"hint": "نقصان کیتے سلائیڈر کوں کھبے پاسے تے نفع کیتے سڄے پاسے گھسیٹو۔",
"insight_full_loss": "<strong>100%</strong> دا نقصان قیمت کوں <strong>0</strong> تے گھن آندے۔ صفر توں بحالی <strong>نا ممکن</strong> اے — 0 دا کوئی وی فیصد اڄاں 0 ہی ہوندے۔",
"insight_gain": "<strong>+$1%</strong> دا نفع 100 کوں <strong>$2</strong> تے گھن ویندے۔ اُتے ساوی پٹی تے تھلے رتی پٹی دی چوڑائی ہکو جیہی اے — پر فیصد وکھرے ہن۔ 100 تے واپس آوݨ کیتے صرف <strong>$3%</strong> چاہیدا اے، −$1% نئیں۔",
"insight_loss": "<strong>$1%</strong> دا نقصان 100 کوں <strong>$2</strong> تے گھن آندے۔ اُتے رتی پٹی تے تھلے ساوی پٹی ہکو جیہی جسمانی دوری چڑھیندن — پر فیصد وکھرے ہن کیوں جو ایہ وکھریاں بنیاداں تے لڳدن۔ بحالی کیتے <strong>+$3%</strong> چاہیدا اے، +$1% نئیں۔",
"insight_zero": "کوئی تبدیلی نئیں لڳی۔ ڳول کرݨ کیتے سلائیڈر گھسیٹو۔",
"label_after": "تبدیلی توں بعد",
"label_apply": "فیصد دی تبدیلی لاؤ",
"label_recovered": "بحال",
"label_scale": "پیمانہ: پٹی دی پوری چوڑائی = 200",
"label_start": "شروع"
},
"lb": {
"axis_zero": "0 %"
},
"ko": {
"_name": "백분율",
"axis_neg": "−100%",
"axis_pos": "+100%",
"axis_zero": "0%",
"hint": "슬라이더를 왼쪽으로 당기면 손실, 오른쪽으로 당기면 이익이 발생합니다.",
"insight_full_loss": "<strong>100%</strong> 손실은 가치를 <strong>0</strong> 으로 만듭니다. 0에서 회복하는 것은 <strong>불가능합니다</strong>. 0의 어떤 백분율도 여전히 0입니다.",
"insight_gain": "<strong>+$1%</strong> 이익은 100을 <strong>$2</strong>(으)로 만듭니다. 위쪽의 녹색 영역과 아래쪽의 빨간색 영역의 너비는 같지만 백분율은 다릅니다. 100으로 돌아가려면 -$1%가 아니라 <strong>$3%</strong>만 필요합니다.",
"insight_loss": "<strong>$1%</strong> 손실은 100을 <strong>$2</strong>(으)로 만듭니다. 위쪽의 빨간색 영역과 아래쪽의 초록색 영역은 물리적으로 같은 거리를 나타내지만, 적용 기준이 다르기 때문에 백분율도 다릅니다. 회복에는 +$1% 가 아닌 <strong>+$3%</strong>가 필요합니다.",
"insight_zero": "변경 사항이 적용되지 않았습니다. 슬라이더를 드래그하여 변경 사항을 확인하세요.",
"label_after": "변경 이후",
"label_apply": "백분율 변화를 적용",
"label_recovered": "복원됨",
"label_scale": "축척: 전체 바 너비 = 200",
"label_start": "시작"
},
"en": {
"_name": "Percentages",
"axis_neg": "−100%",
"axis_pos": "+100%",
"axis_zero": "0%",
"hint": "Drag the slider left for a loss, right for a gain.",
"insight_full_loss": "A loss of <strong>100%</strong> brings the value to <strong>0</strong>. Recovery from zero is <strong>impossible</strong> — any percentage of 0 is still 0.",
"insight_gain": "A gain of <strong>+$1%</strong> brings 100 up to <strong>$2</strong>. The green span above and the red span below are the same width — but the percentages differ. Returning to 100 only takes <strong>$3%</strong>, not −$1%.",
"insight_loss": "A loss of <strong>$1%</strong> brings 100 down to <strong>$2</strong>. The red span above and the green span below cover the same physical distance — but the percentages differ because they apply to different bases. Recovery needs <strong>+$3%</strong>, not +$1%.",
"insight_zero": "No change applied. Drag the slider to explore.",
"label_after": "after change",
"label_apply": "APPLY A PERCENTAGE CHANGE",
"label_recovered": "recovered",
"label_scale": "SCALE: FULL BAR WIDTH = 200",
"label_start": "start"
},
"it": {
"_name": "Percentuali"
},
"ca": {
"_name": "Percentatges",
"axis_neg": "−100%",
"axis_pos": "+100%",
"axis_zero": "0%",
"label_after": "després del canvi",
"label_recovered": "recuperat",
"label_start": "inicia"
},
"qqq": {
"_name": "Name of the Percentages module",
"axis_neg": "Label at the left end of the slider axis (−100%)",
"axis_pos": "Label at the right end of the slider axis (+100%)",
"axis_zero": "Label at the centre of the slider axis (0%)",
"hint": "Instruction text explaining how to use the slider",
"insight_full_loss": "Insight text shown when the loss is exactly 100%. Uses HTML.",
"insight_gain": "Insight text shown for a percentage gain. Uses HTML. Parameters: $1 = percentage applied, $2 = value after gain, $3 = percentage needed to return to 100.",
"insight_loss": "Insight text shown for a percentage loss. Uses HTML. Parameters: $1 = percentage applied, $2 = value after loss, $3 = percentage needed to return to 100.",
"insight_zero": "Insight text shown when the slider is at zero (no change)",
"label_after": "Label on the bar visualisation for the value after the percentage change",
"label_apply": "Heading for the percentage change control section",
"label_recovered": "Label on the bar visualisation for the recovery bar",
"label_scale": "Label explaining the scale of the bar visualisation",
"label_start": "Label on the bar visualisation for the starting value"
},
"ga": {
"_name": "Céatadáin",
"axis_neg": "−100%",
"axis_pos": "+100%",
"axis_zero": "0%",
"hint": "Tarraing an sleamhnán ar chlé le haghaidh caillteanas, ar dheis le haghaidh gnóthachan.",
"insight_full_loss": "Tugann caillteanas <strong>100%</strong> an luach go <strong>0</strong>. Tá sé <strong>dodhéanta</strong> é a aisghabháil ó náid — is ionann aon chéatadán de 0 agus 0 fós.",
"insight_gain": "Tugann gnóthachan de <strong>+$1%</strong> 100 suas go <strong>$2</strong>. Tá an leithead céanna ag an réimse glas thuas agus an réimse dearg thíos — ach tá na céatadáin difriúil. Ní thógann sé ach <strong>$3%</strong> chun filleadh ar 100, ní −$1%.",
"insight_loss": "Le caillteanas de <strong>$1%</strong>, laghdaítear 100 go <strong>$2</strong>. Clúdaíonn an réimse dearg thuas agus an réimse glas thíos an t-achar fisiceach céanna — ach tá na céatadáin difriúil toisc go mbaineann siad le bunáiteanna difriúla. Teastaíonn <strong>+$3%</strong> le haghaidh aisghabhála, ní +$1%.",
"insight_zero": "Níor cuireadh aon athrú i bhfeidhm. Tarraing an sleamhnán chun iniúchadh a dhéanamh.",
"label_after": "tar éis athraithe",
"label_apply": "CUIR ATHRÚ CÉATÁNACH I bhFEIDHM",
"label_recovered": "aisghafa",
"label_scale": "SCÁLA: LEITHEAD AN BHARRA IOMLÁN = 200",
"label_start": "tús"
},
"es": {
"_name": "Porcentajes",
"axis_neg": "−100%",
"axis_pos": "+100%",
"axis_zero": "0%",
"hint": "Arrastra el control deslizante a la izquierda para una pérdida, a la derecha para una ganancia.",
"insight_full_loss": "Una pérdida del <strong>100%</strong> lleva el valor a <strong>0</strong>. La recuperación desde cero es <strong>imposible</strong> — cualquier porcentaje de 0 sigue siendo 0.",
"insight_gain": "Una ganancia del <strong>+$1%</strong> aumenta 100 a <strong>$2</strong>. La barra verde de arriba y la barra roja de abajo tienen el mismo ancho — pero los porcentajes difieren. Volver a 100 sólo requiere <strong>$3%</strong>, no −$1%.",
"insight_loss": "Una pérdida del <strong>$1%</strong> reduce 100 a <strong>$2</strong>. La barra roja de arriba y la barra verde de abajo cubren la misma distancia física, pero los porcentajes difieren porque se aplican a bases diferentes. Para recuperarse se necesita <strong>+$3%</strong>, no +$1%.",
"insight_zero": "No se ha aplicado ningún cambio. Arrastra el control para explorar.",
"label_after": "después del cambio",
"label_apply": "APLICAR UN CAMBIO PORCENTUAL",
"label_recovered": "recuperado",
"label_scale": "ESCALA: ANCHO TOTAL = 200",
"label_start": "inicio"
},
"mk": {
"_name": "Постотоци",
"axis_neg": "−100 %",
"axis_pos": "+100 %",
"axis_zero": "0 %",
"hint": "Повлечете го лизгачот лево за пад, десно за пораст.",
"insight_full_loss": "Пад од <strong>100 %</strong> ја доведува вредноста на <strong>0</strong>. Повратокот од нула е <strong>невозможен</strong> — секој постоток од 0 е сепак 0.",
"insight_gain": "Пораст од <strong>+$1 %</strong> ја доведува вредноста 100 на <strong>$2</strong>. Зелениот лак горе и црвениот лак долу имаат иста ширина — но постотоците се различни. За повраток на 100 ни треба само <strong>$3 %</strong>, а не −$1 %.",
"insight_loss": "Пад од <strong>$1 %</strong> ја намалува вредноста 100 на <strong>$2</strong>. Црвениот лак горе и зелениот лак долу го опфаќаат истото физичко растојание — но постотоците се разликуваат бидејќи се однесуваат на различни основи. За повраток ни треба <strong>+$3 %</strong>, а не +$1 %.",
"insight_zero": "Нема напраевено промена. Повлечете го лизгачот за да истражите.",
"label_after": "по промената",
"label_apply": "ПРИМЕНИ ПОСТОТНА ПРОМЕНА",
"label_recovered": "повратено",
"label_scale": "РАЗМЕР: ПОЛНА ШИРИНА НА СТОЛБОТ = 200",
"label_start": "почеток"
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
/* ── HELPERS ──────────────────────────────────────────────────────────────── */
function fmt( n ) {
// Round to 1 decimal, drop the .0 if whole
var r = Math.round( n * 10 ) / 10;
return r % 1 === 0 ? r.toFixed( 0 ) : r.toFixed( 1 );
}
function sgn( n ) { return n >= 0 ? '+' : ''; }
function P( v ) { return ( v / 200 * 100 ) + '%'; }
var MIN_PCT = 8; // minimum bar-segment width (%) to show an inline label
/* ── BUILD HTML ───────────────────────────────────────────────────────────── */
function buildHTML( root ) {
root.innerHTML =
'<div class="hw-percent">' +
/* Slider */
'<div class="hw-pct-sec-label" id="hw-pct-lbl-apply"></div>' +
'<div class="hw-pct-slider-row">' +
'<input type="range" id="hw-pct-slider" min="-100" max="100" value="0" step="1">' +
'<div class="hw-pct-badge" id="hw-pct-badge">0%</div>' +
'</div>' +
'<div class="hw-pct-axis-wrap">' +
'<div class="hw-pct-axis">' +
'<span id="hw-pct-axis-neg"></span>' +
'<span id="hw-pct-axis-zero"></span>' +
'<span id="hw-pct-axis-pos"></span>' +
'</div>' +
'</div>' +
/* Flow connector */
'<div class="hw-pct-connector">' +
'<div class="hw-pct-conn-col">' +
'<div class="hw-pct-conn-box">' +
'<div class="hw-pct-conn-val">100</div>' +
'<div class="hw-pct-conn-lbl" id="hw-pct-lbl-start"></div>' +
'</div>' +
'</div>' +
'<div class="hw-pct-conn-arrow">' +
'<div class="hw-pct-conn-pct" id="hw-pct-a1-pct">0%</div>' +
'<div class="hw-pct-conn-line" id="hw-pct-a1-line"><span class="hw-pct-tip" id="hw-pct-a1-tip"></span></div>' +
'<div class="hw-pct-conn-abs" id="hw-pct-a1-abs"></div>' +
'</div>' +
'<div class="hw-pct-conn-col">' +
'<div class="hw-pct-conn-box hw-pct-conn-box-after" id="hw-pct-c2-box">' +
'<div class="hw-pct-conn-val" id="hw-pct-c-after">100</div>' +
'<div class="hw-pct-conn-lbl" id="hw-pct-lbl-after"></div>' +
'</div>' +
'</div>' +
'<div class="hw-pct-conn-arrow">' +
'<div class="hw-pct-conn-pct" id="hw-pct-a2-pct">—</div>' +
'<div class="hw-pct-conn-line" id="hw-pct-a2-line"><span class="hw-pct-tip" id="hw-pct-a2-tip"></span></div>' +
'<div class="hw-pct-conn-abs" id="hw-pct-a2-abs"></div>' +
'</div>' +
'<div class="hw-pct-conn-col">' +
'<div class="hw-pct-conn-box">' +
'<div class="hw-pct-conn-val">100</div>' +
'<div class="hw-pct-conn-lbl" id="hw-pct-lbl-recovered"></div>' +
'</div>' +
'</div>' +
'</div>' +
/* Bar */
'<div class="hw-pct-sec-label" id="hw-pct-lbl-scale"></div>' +
'<div class="hw-pct-bar-section">' +
'<div class="hw-pct-bar-outer">' +
'<div class="hw-pct-bracket" id="hw-pct-top-line"></div>' +
'<div class="hw-pct-ann" id="hw-pct-top-txt"></div>' +
'<div class="hw-pct-tick" id="hw-pct-tick100"></div>' +
'<div class="hw-pct-bar-track" id="hw-pct-bar-track">' +
'<div class="hw-pct-seg" id="hw-pct-seg-blue"></div>' +
'<div class="hw-pct-seg" id="hw-pct-seg-delta"></div>' +
'<div class="hw-pct-seg-lbl" id="hw-pct-lbl-left"></div>' +
'<div class="hw-pct-seg-lbl" id="hw-pct-seg-lbl-right" id="hw-pct-lbl-right"></div>' +
'</div>' +
'<div class="hw-pct-bracket" id="hw-pct-bot-line"></div>' +
'<div class="hw-pct-ann" id="hw-pct-bot-txt"></div>' +
'</div>' +
'</div>' +
/* Insight */
'<div class="hw-pct-insight" id="hw-pct-insight"></div>' +
'</div>';
}
/* ── INIT STATIC TEXT ─────────────────────────────────────────────────────── */
function initText( root ) {
root.querySelector( '#hw-pct-lbl-apply' ).textContent = t( 'label_apply' );
root.querySelector( '#hw-pct-lbl-scale' ).textContent = t( 'label_scale' );
root.querySelector( '#hw-pct-lbl-start' ).textContent = t( 'label_start' );
root.querySelector( '#hw-pct-lbl-after' ).textContent = t( 'label_after' );
root.querySelector( '#hw-pct-lbl-recovered' ).textContent = t( 'label_recovered' );
root.querySelector( '#hw-pct-axis-neg' ).textContent = t( 'axis_neg' );
root.querySelector( '#hw-pct-axis-zero' ).textContent = t( 'axis_zero' );
root.querySelector( '#hw-pct-axis-pos' ).textContent = t( 'axis_pos' );
}
/* ── ARROW HELPER ─────────────────────────────────────────────────────────── */
function setArrow( root, id, text, color, absText ) {
var p = root.querySelector( '#hw-pct-' + id + '-pct' );
var l = root.querySelector( '#hw-pct-' + id + '-line' );
var tip = root.querySelector( '#hw-pct-' + id + '-tip' );
var a = root.querySelector( '#hw-pct-' + id + '-abs' );
p.textContent = text;
p.style.color = color;
l.style.background = color;
tip.style.borderLeftColor = color;
if ( a ) { a.textContent = absText || ''; }
}
/* ── UPDATE ───────────────────────────────────────────────────────────────── */
function update( root ) {
var slider = root.querySelector( '#hw-pct-slider' );
var pct = parseInt( slider.value, 10 );
var result = Math.round( 100 * ( 1 + pct / 100 ) * 10 ) / 10;
var change = Math.round( ( result - 100 ) * 10 ) / 10;
var isLoss = pct < 0;
var isGain = pct > 0;
var fullLoss = pct === -100;
// Recovery: −100% → ∞; 0% → no recovery needed
var recovery;
var recoveryStr;
if ( fullLoss ) {
recovery = Infinity;
recoveryStr = '∞';
} else if ( pct === 0 ) {
recovery = 0;
recoveryStr = '0';
} else {
recovery = Math.round( ( 100 / result - 1 ) * 1000 ) / 10;
recoveryStr = fmt( recovery );
}
var aC = isLoss ? '#C0392B' : isGain ? '#639922' : 'var(--color-text-tertiary, #888)';
var rC = isLoss ? '#639922' : isGain ? '#C0392B' : 'var(--color-text-tertiary, #888)';
var neutralC = 'var(--color-text-tertiary, #888)';
/* Badge */
var badge = root.querySelector( '#hw-pct-badge' );
badge.textContent = sgn( pct ) + pct + '%';
badge.style.color = ( isLoss || isGain ) ? aC : 'var(--color-text-primary, #222)';
/* Arrows */
setArrow( root, 'a1',
sgn( pct ) + pct + '%',
pct !== 0 ? aC : neutralC,
pct !== 0 ? sgn( change ) + fmt( change ) : null
);
if ( pct === 0 ) {
setArrow( root, 'a2', '—', neutralC, null );
root.querySelector( '#hw-pct-a2-tip' ).style.borderLeftColor = 'transparent';
} else if ( fullLoss ) {
setArrow( root, 'a2', '+∞%', rC, null );
} else {
setArrow( root, 'a2', sgn( recovery ) + recoveryStr + '%', rC, sgn( -change ) + fmt( Math.abs( change ) ) );
}
/* After-change box */
var cAfter = root.querySelector( '#hw-pct-c-after' );
cAfter.textContent = fmt( result );
cAfter.style.color = pct !== 0 ? aC : 'var(--color-text-primary, #222)';
root.querySelector( '#hw-pct-c2-box' ).style.background =
isLoss ? 'rgba(192,57,43,.08)' : isGain ? 'rgba(99,153,34,.08)' : '';
/* Bar elements */
var segBlue = root.querySelector( '#hw-pct-seg-blue' );
var segDelta = root.querySelector( '#hw-pct-seg-delta' );
var lblLeft = root.querySelector( '#hw-pct-lbl-left' );
var lblRight = root.querySelector( '#hw-pct-lbl-right' );
var tick100 = root.querySelector( '#hw-pct-tick100' );
var topLine = root.querySelector( '#hw-pct-top-line' );
var topTxt = root.querySelector( '#hw-pct-top-txt' );
var botLine = root.querySelector( '#hw-pct-bot-line' );
var botTxt = root.querySelector( '#hw-pct-bot-txt' );
tick100.style.left = P( 100 );
tick100.style.display = pct === 0 ? 'none' : 'block';
var insight = root.querySelector( '#hw-pct-insight' );
if ( pct === 0 ) {
segBlue.style.cssText = 'left:0;width:50%;background:#378ADD;border-radius:8px';
segDelta.style.width = '0';
lblLeft.textContent = '100';
lblLeft.style.left = '0';
lblRight.textContent = '';
topLine.style.width = '0'; topTxt.textContent = '';
botLine.style.width = '0'; botTxt.textContent = '';
insight.innerHTML = t( 'hint' );
return;
}
if ( isGain ) {
segBlue.style.cssText = 'left:0;width:' + P(100) + ';background:#378ADD;border-radius:8px 0 0 8px';
segDelta.style.cssText = 'left:' + P(100) + ';width:' + P(result-100) + ';background:#639922;border-radius:0 8px 8px 0';
lblLeft.style.left = '0';
lblLeft.textContent = '100';
lblRight.style.left = P( 100 );
lblRight.textContent = ( result - 100 ) / 200 * 100 > MIN_PCT ? fmt( result ) : '';
topLine.style.cssText = 'top:18px;left:' + P(100) + ';width:' + P(result-100) + ';background:#639922';
topTxt.style.cssText = 'top:2px;left:' + P(100) + ';width:' + P(result-100) + ';color:#639922';
topTxt.textContent = '+' + pct + '%';
botLine.style.cssText = 'bottom:18px;left:' + P(100) + ';width:' + P(result-100) + ';background:#C0392B';
botTxt.style.cssText = 'bottom:2px;left:' + P(100) + ';width:' + P(result-100) + ';color:#C0392B';
botTxt.textContent = recoveryStr + '%';
} else {
// Loss (including full −100%)
segBlue.style.cssText = 'left:0;width:' + P(100) + ';background:#378ADD;border-radius:8px';
if ( fullLoss ) {
segDelta.style.width = '0';
lblLeft.textContent = '';
lblRight.textContent = '';
topLine.style.cssText = 'top:18px;left:0;width:50%;background:#C0392B';
topTxt.style.cssText = 'top:2px;left:0;width:50%;color:#C0392B';
topTxt.textContent = '−100%';
botLine.style.cssText = 'bottom:18px;left:0;width:50%;background:#639922';
botTxt.style.cssText = 'bottom:2px;left:0;width:50%;color:#639922';
botTxt.textContent = '+∞%';
} else {
segDelta.style.cssText = 'left:' + P(result) + ';width:' + P(100-result) + ';background:#C0392B;border-radius:0 8px 8px 0';
lblLeft.style.left = '0';
lblLeft.textContent = result / 200 * 100 > MIN_PCT ? fmt( result ) : '';
lblRight.style.left = P( result );
lblRight.textContent = ( 100 - result ) / 200 * 100 > MIN_PCT ? '100' : '';
topLine.style.cssText = 'top:18px;left:' + P(result) + ';width:' + P(100-result) + ';background:#C0392B';
topTxt.style.cssText = 'top:2px;left:' + P(result) + ';width:' + P(100-result) + ';color:#C0392B';
topTxt.textContent = pct + '%';
botLine.style.cssText = 'bottom:18px;left:' + P(result) + ';width:' + P(100-result) + ';background:#639922';
botTxt.style.cssText = 'bottom:2px;left:' + P(result) + ';width:' + P(100-result) + ';color:#639922';
botTxt.textContent = '+' + recoveryStr + '%';
}
}
/* Insight text */
if ( fullLoss ) {
insight.innerHTML = t( 'insight_full_loss' );
} else if ( isLoss ) {
insight.innerHTML = t( 'insight_loss', [ Math.abs( pct ), fmt( result ), recoveryStr ] );
} else {
insight.innerHTML = t( 'insight_gain', [ pct, fmt( result ), recoveryStr ] );
}
}
/* ── MOUNT ────────────────────────────────────────────────────────────────── */
function mount( root ) {
buildHTML( root );
initText( root );
// Fix the duplicate id bug in lbl-right
var rightLbl = root.querySelector( '#hw-pct-seg-lbl-right' );
if ( rightLbl ) { rightLbl.id = 'hw-pct-lbl-right'; }
var slider = root.querySelector( '#hw-pct-slider' );
slider.addEventListener( 'input', function () { update( root ); } );
update( root );
// Footer branding
var footer = document.createElement("div");
footer.className = "hw-footer";
var fLogo = document.createElement("a");
fLogo.className = "hw-footer-icon";
fLogo.href = mw.util.getUrl('Wikipedia:Hiruwiki');
fLogo.title = 'Hiruwiki';
fLogo.innerHTML = hiruwiki.getLogoSvg(22);
var fText = document.createElement("span");
fText.innerHTML = t('hint');
footer.appendChild(fLogo);
footer.appendChild(fText);
root.appendChild(footer);
}
/* ── SCAN & INIT ──────────────────────────────────────────────────────────── */
function init() {
document.querySelectorAll( '.hiruwiki[data-module="percent"]' ).forEach( function ( el ) {
if ( !el.dataset.hwPctInit ) {
el.dataset.hwPctInit = '1';
mount( el );
}
} );
}
if ( document.readyState === 'loading' ) {
document.addEventListener( 'DOMContentLoaded', init );
} else {
init();
}
} )();
ccz0qhqnx62d0mkg9ehq7305nyh6y4v
MediaWiki:Hiruwiki/modules/positive-coordinates.js
8
179513
781439
770240
2026-10-28T10:39:52Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781439
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
( function () {
'use strict';
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"nl": {
"_name": "Integercoördinaten",
"btnChallenge": "🎯 Uitdaging",
"btnCheck": "Controleren ✓",
"btnExplore": "🔭 Verkennen",
"btnNext": "Volgende →",
"feedbackCorrect": "✓ Klopt!",
"feedbackWrong": "✗ Onjuist. De zwakke ster was het juiste antwoord.",
"hintChallenge": "Verplaats ★ naar de voorgestelde coördinaten en klik op Controleren",
"hintExplore": "Verplaats ★ door erop te klikken — springt naar hele getallen",
"movePrompt": "Verplaats ★ naar:<br>($1, $2)",
"pointLabel": "Punt:",
"titleExplore": "Verken het coördinatensysteem",
"hint": "Verplaats de ster om de coördinaten te verkennen"
},
"fr": {
"_name": "Coordonnées entières",
"btnChallenge": "🎯 Défi",
"btnCheck": "Vérifier ✓",
"btnExplore": "🔭 Explorer",
"btnNext": "Suivant →",
"feedbackCorrect": "✓ Correct !",
"feedbackWrong": "✗ Incorrect. L'étoile pâle était la bonne réponse.",
"hintChallenge": "Déplacez ★ vers les coordonnées proposées et cliquez sur Vérifier",
"hintExplore": "Déplacez ★ en cliquant dessus — il s'accroche aux entiers",
"movePrompt": "Déplacez ★ vers :<br>($1, $2)",
"pointLabel": "Point :",
"titleExplore": "Explorer le système de coordonnées",
"hint": "Déplacez l'étoile pour explorer les coordonnées"
},
"eu": {
"_name": "Koordenatu positiboak",
"btnChallenge": "🎯 Erronka",
"btnCheck": "Zuzendu ✓",
"btnExplore": "🔭 Esploratu",
"btnNext": "Hurrengoa →",
"feedbackCorrect": "✓ Zuzen!",
"feedbackWrong": "✗ Ez da zuzena. Izar ahula zen erantzun zuzena.",
"hintChallenge": "Mugi ezazu ★ proposatutako koordenatuetara eta klik egin Zuzendu botoian",
"hintExplore": "Mugi ezazu ★ bere gainean klik eginez — zenbaki arruntak erabiltzen ditu",
"movePrompt": "Mugitu ★ hona:<br>($1, $2)",
"pointLabel": "Puntua:",
"titleExplore": "Esploratu koordenatuen sistema",
"hint": "Mugitu izarra koordenatuak aztertzeko"
},
"sk": {
"_name": "Celočíselné súradnice",
"btnChallenge": "🎯 Výzva",
"btnCheck": "Skontrolovať ✓",
"btnExplore": "🔭 Skúmať",
"btnNext": "Ďalej →",
"feedbackCorrect": "✓ Správne!",
"feedbackWrong": "✗ Nesprávne. Správnou odpoveďou bola bledá hviezda.",
"hintChallenge": "Presuňte ★ na navrhnuté súradnice a kliknite na Skontrolovať",
"hintExplore": "Presuňte ★ kliknutím naň — prichytí sa na celé čísla",
"movePrompt": "Presuňte ★ na:<br>($1, $2)",
"pointLabel": "Bod:",
"titleExplore": "Skúmať súradnicovú sústavu",
"hint": "Presúvaním hviezdy skúmajte súradnice"
},
"skr-arab": {
"_name": "صحیح مختصات",
"btnChallenge": "🎯 چیلنج",
"btnCheck": "چیک کرو ✓",
"btnExplore": "🔭 کھوج",
"btnNext": "اڳلا ←",
"feedbackCorrect": "✓ ٹھیک!",
"feedbackWrong": "✗ غلط۔ مدھم ستارا ٹھیک جواب ہا۔",
"hintChallenge": "★ کوں مجوزہ مختصات تے گھن ونجو تے چیک دباؤ",
"hintExplore": "★ تے کلک کر کے اونکوں ہلاو — اوہ صحیح اعداد تے ٹک ویندے",
"movePrompt": "★ کوں اتھاں گھن ونجو:<br>($1, $2)",
"pointLabel": "نقطہ:",
"titleExplore": "مختصاتی نظام دی کھوج",
"hint": "ستارے کوں ہلا کے مختصات دی کھوج کرو"
},
"lb": {
"_name": "Ganzzueleg Koordinaten",
"pointLabel": "Punkt:"
},
"ko": {
"_name": "정수 좌표",
"btnChallenge": "🎯 도전",
"btnCheck": "확인 ✓",
"btnExplore": "🔭 탐색",
"btnNext": "다음 →",
"feedbackCorrect": "✓ 정답입니다!",
"feedbackWrong": "✗ 틀렸습니다. 희미한 별이 정답입니다.",
"hintChallenge": "★ 표시를 제시된 좌표로 이동한 후 확인을 클릭하세요",
"hintExplore": "★를 클릭하여 이동하세요. 정수 위치에 맞춰집니다",
"movePrompt": "★을 다음으로 이동:<br>($1, $2)",
"pointLabel": "점수:",
"titleExplore": "좌표계를 탐색하세요",
"hint": "별을 움직여 좌표를 탐색하세요"
},
"en": {
"_name": "Integer Coordinates",
"btnChallenge": "🎯 Challenge",
"btnCheck": "Check ✓",
"btnExplore": "🔭 Explore",
"btnNext": "Next →",
"feedbackCorrect": "✓ Correct!",
"feedbackWrong": "✗ Incorrect. The faint star was the correct answer.",
"hintChallenge": "Move ★ to the proposed coordinates and click Check",
"hintExplore": "Move ★ by clicking on it — it snaps to integers",
"movePrompt": "Move ★ to:<br>($1, $2)",
"pointLabel": "Point:",
"titleExplore": "Explore Coordinate System",
"hint": "Move the star to explore coordinates"
},
"it": {
"_name": "Coordinate intere",
"btnNext": "Successivo →",
"feedbackCorrect": "✓ Corretto!",
"pointLabel": "Punto:"
},
"ca": {
"btnNext": "Endavant →",
"feedbackCorrect": "✓ Correcte!"
},
"qqq": {
"_name": "Name of the Positive Coordinates module (integer grid variant)",
"btnChallenge": "Button label to switch to challenge mode",
"btnCheck": "Button label to check the player's answer",
"btnExplore": "Button label to switch to explore mode",
"btnNext": "Button label to proceed to the next challenge",
"feedbackCorrect": "Feedback shown when the player places the star correctly",
"feedbackWrong": "Feedback shown when the player places the star incorrectly",
"hintChallenge": "Instruction text shown in challenge mode",
"hintExplore": "Instruction text shown in explore mode",
"movePrompt": "Prompt telling the player where to move the star. Uses HTML. Parameters: $1 = target X coordinate, $2 = target Y coordinate.",
"pointLabel": "Label for the current point coordinates. Followed by coordinate values.",
"titleExplore": "Title shown in explore mode",
"hint": "Instruction text for the coordinate system interaction"
},
"ga": {
"_name": "Comhordanáidí Slánuimhreacha",
"btnChallenge": "🎯 Dúshlán",
"btnCheck": "Seiceáil ✓",
"btnExplore": "🔭 Taiscéal",
"btnNext": "Ar Aghaidh →",
"feedbackCorrect": "✓ Ceart!",
"feedbackWrong": "✗ Mícheart. Ba é an réalta lag an freagra ceart.",
"hintChallenge": "Bog ★ go dtí na comhordanáidí atá beartaithe agus cliceáil Seiceáil",
"hintExplore": "Bog ★ trí chliceáil air — snapálann sé chuig slánuimhreacha",
"movePrompt": "Bog ★ go:<br>($1, $2)",
"pointLabel": "Pointe:",
"titleExplore": "Iniúchadh a dhéanamh ar an gCóras Comhordanáidí",
"hint": "Bog an réalta chun comhordanáidí a iniúchadh"
},
"es": {
"_name": "Coordenadas positivas",
"btnChallenge": "🎯 Reto",
"btnCheck": "Comprobar ✓",
"btnExplore": "🔭 Explorar",
"btnNext": "Siguiente →",
"feedbackCorrect": "✓ ¡Correcto!",
"feedbackWrong": "✗ Incorrecto. La estrella tenue era la respuesta correcta.",
"hintChallenge": "Mueve ★ a las coordenadas propuestas y pulsa Comprobar",
"hintExplore": "Mueve ★ haciendo clic en él — se ajusta a enteros",
"movePrompt": "Mueve ★ a:<br>($1, $2)",
"pointLabel": "Punto:",
"titleExplore": "Explorar el sistema de coordenadas",
"hint": "Mueve la estrella para explorar las coordenadas"
},
"mk": {
"_name": "Целобројни координати",
"btnChallenge": "🎯 Предизвик",
"btnCheck": "Провери ✓",
"btnExplore": "🔭 Истражи",
"btnNext": "Следно →",
"feedbackCorrect": "✓ Точно!",
"feedbackWrong": "✗ Неточно. Слабата ѕвезда е точниот одговор.",
"hintChallenge": "Поместете го ★ на предложените координати и стиснете на Провери",
"hintExplore": "Поместете го ★ стискајќи на него — се сведува на цели броеви",
"movePrompt": "Поместете го ★ на:<br>($1, $2)",
"pointLabel": "Точка:",
"titleExplore": "Истражете го координатниот систем",
"hint": "Поместете ја ѕвездата за да истражите координати"
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
document.querySelectorAll( '.hiruwiki[data-module="positive-coordinates"]' ).forEach( function ( host ) {
host.innerHTML = [
'<div class="cex-wrapper">',
' <div class="cex-mode-bar">',
' <button class="cex-mode-btn cex-active" data-mode="explorer">' + t('btnExplore') + '</button>',
' <button class="cex-mode-btn" data-mode="challenge">' + t('btnChallenge') + '</button>',
' </div>',
' <div class="cex-body">',
' <canvas class="cex-canvas"></canvas>',
' <div class="cex-sidebar">',
' <div class="cex-explorer-panel">',
' <div class="cex-sidebar-title">' + t('titleExplore') + '</div>',
' <div class="cex-info-bar">',
' ' + t('pointLabel') + '<br>',
' (<span class="cex-cx"></span>, <span class="cex-cy"></span>)',
' </div>',
' <div class="cex-hint">' + t('hintExplore') + '</div>',
' </div>',
' <div class="cex-challenge-panel">',
' <div class="cex-challenge-prompt"></div>',
' <div class="cex-feedback"></div>',
' <div class="cex-btn-col">',
' <button class="cex-action-btn cex-check-btn">' + t('btnCheck') + '</button>',
' <button class="cex-action-btn cex-next-btn">' + t('btnNext') + '</button>',
' </div>',
' <div class="cex-hint">' + t('hintChallenge') + '</div>',
' </div>',
' </div>',
' </div>',
'</div>'
].join( '\n' );
initInstance( host );
// Footer branding
var footer = document.createElement("div");
footer.className = "hw-footer";
var fLogo = document.createElement("a");
fLogo.className = "hw-footer-icon";
fLogo.href = mw.util.getUrl('Wikipedia:Hiruwiki');
fLogo.title = 'Hiruwiki';
fLogo.innerHTML = hiruwiki.getLogoSvg(22);
var fText = document.createElement("span");
fText.innerHTML = t('hint');
footer.appendChild(fLogo);
footer.appendChild(fText);
host.appendChild(footer);
} );
function initInstance( host ) {
var canvas = host.querySelector( '.cex-canvas' );
var ctx = canvas.getContext( '2d' );
var btnExplorer = host.querySelector( '[data-mode="explorer"]' );
var btnChallenge = host.querySelector( '[data-mode="challenge"]' );
var explorerPanel = host.querySelector( '.cex-explorer-panel' );
var challengePanel = host.querySelector( '.cex-challenge-panel' );
var promptBar = host.querySelector( '.cex-challenge-prompt' );
var spCX = host.querySelector( '.cex-cx' );
var spCY = host.querySelector( '.cex-cy' );
var feedbackEl = host.querySelector( '.cex-feedback' );
var checkBtn = host.querySelector( '.cex-check-btn' );
var nextBtn = host.querySelector( '.cex-next-btn' );
var X_MAX = 15;
var Y_MAX = 10;
var COLOR_X = '#e74c3c';
var COLOR_Y = '#2980b9';
var PAD_LEFT = 52;
var PAD_BOTTOM = 52;
var PAD_TOP = 24;
var PAD_RIGHT = 20;
var CELL = 40;
var CW = PAD_LEFT + X_MAX * CELL + PAD_RIGHT;
var CH = PAD_TOP + Y_MAX * CELL + PAD_BOTTOM;
canvas.width = CW;
canvas.height = CH;
var ptX = 3, ptY = 4;
var targetX = 0, targetY = 0;
var mode = 'explorer';
var dragging = false;
var checked = false;
var correct = false;
function toCanvas( gx, gy ) {
return { x: PAD_LEFT + gx * CELL, y: CH - PAD_BOTTOM - gy * CELL };
}
function toGrid( cx, cy ) {
var gx = Math.round( ( cx - PAD_LEFT ) / CELL );
var gy = Math.round( ( CH - PAD_BOTTOM - cy ) / CELL );
return {
gx: Math.max( 0, Math.min( X_MAX, gx ) ),
gy: Math.max( 0, Math.min( Y_MAX, gy ) )
};
}
function drawArrow( x1, y1, x2, y2, color, lw ) {
var headLen = 9;
var angle = Math.atan2( y2 - y1, x2 - x1 );
ctx.save();
ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = lw;
ctx.beginPath(); ctx.moveTo( x1, y1 ); ctx.lineTo( x2, y2 ); ctx.stroke();
ctx.beginPath();
ctx.moveTo( x2, y2 );
ctx.lineTo( x2 - headLen * Math.cos( angle - Math.PI / 7 ), y2 - headLen * Math.sin( angle - Math.PI / 7 ) );
ctx.lineTo( x2 - headLen * Math.cos( angle + Math.PI / 7 ), y2 - headLen * Math.sin( angle + Math.PI / 7 ) );
ctx.closePath(); ctx.fill();
ctx.restore();
}
function drawStar( cx, cy, r, fillColor, strokeColor, glowColor, glowBlur, alpha ) {
var spikes = 5, outerR = r, innerR = r * 0.42;
var rot = ( Math.PI / 2 ) * 3, step = Math.PI / spikes, i;
ctx.save();
ctx.globalAlpha = ( alpha !== undefined ) ? alpha : 1;
ctx.beginPath();
ctx.moveTo( cx, cy - outerR );
for ( i = 0; i < spikes; i++ ) {
ctx.lineTo( cx + Math.cos( rot ) * outerR, cy + Math.sin( rot ) * outerR ); rot += step;
ctx.lineTo( cx + Math.cos( rot ) * innerR, cy + Math.sin( rot ) * innerR ); rot += step;
}
ctx.closePath();
if ( glowColor ) { ctx.shadowColor = glowColor; ctx.shadowBlur = glowBlur || 0; }
ctx.fillStyle = fillColor; ctx.fill();
ctx.shadowBlur = 0; ctx.strokeStyle = strokeColor; ctx.lineWidth = 1.5; ctx.stroke();
ctx.restore();
}
function drawProjectionLines( gx, gy, xColor, yColor, alpha ) {
var pt = toCanvas( gx, gy ), originC = toCanvas( 0, 0 );
ctx.save();
ctx.globalAlpha = ( alpha !== undefined ) ? alpha : 1;
ctx.setLineDash( [ 5, 4 ] ); ctx.lineWidth = 1.6;
ctx.strokeStyle = xColor;
ctx.beginPath(); ctx.moveTo( pt.x, pt.y ); ctx.lineTo( pt.x, originC.y ); ctx.stroke();
ctx.strokeStyle = yColor;
ctx.beginPath(); ctx.moveTo( pt.x, pt.y ); ctx.lineTo( PAD_LEFT, pt.y ); ctx.stroke();
ctx.setLineDash( [] );
ctx.beginPath(); ctx.arc( pt.x, originC.y, 4, 0, Math.PI * 2 ); ctx.fillStyle = xColor; ctx.fill();
ctx.beginPath(); ctx.arc( PAD_LEFT, pt.y, 4, 0, Math.PI * 2 ); ctx.fillStyle = yColor; ctx.fill();
ctx.restore();
}
function drawDimensionBrackets( gx, gy, xColor, yColor, alpha ) {
var pt = toCanvas( gx, gy ), originC = toCanvas( 0, 0 ), yBrk, xBrk;
ctx.save();
ctx.globalAlpha = ( alpha !== undefined ) ? alpha : 1;
if ( gx > 0 ) {
yBrk = originC.y + 26;
ctx.strokeStyle = xColor; ctx.fillStyle = xColor; ctx.lineWidth = 1.8;
ctx.beginPath(); ctx.moveTo( PAD_LEFT, yBrk ); ctx.lineTo( pt.x, yBrk ); ctx.stroke();
ctx.beginPath(); ctx.moveTo( PAD_LEFT, yBrk - 4 ); ctx.lineTo( PAD_LEFT, yBrk + 4 ); ctx.stroke();
ctx.beginPath(); ctx.moveTo( pt.x, yBrk - 4 ); ctx.lineTo( pt.x, yBrk + 4 ); ctx.stroke();
ctx.font = 'bold 11px Montserrat, Arial, sans-serif'; ctx.textAlign = 'center';
ctx.fillText( 'x = ' + gx, PAD_LEFT + ( pt.x - PAD_LEFT ) / 2, yBrk + 13 );
}
if ( gy > 0 ) {
xBrk = PAD_LEFT - 30;
ctx.strokeStyle = yColor; ctx.fillStyle = yColor; ctx.lineWidth = 1.8;
ctx.beginPath(); ctx.moveTo( xBrk, originC.y ); ctx.lineTo( xBrk, pt.y ); ctx.stroke();
ctx.beginPath(); ctx.moveTo( xBrk - 4, originC.y ); ctx.lineTo( xBrk + 4, originC.y ); ctx.stroke();
ctx.beginPath(); ctx.moveTo( xBrk - 4, pt.y ); ctx.lineTo( xBrk + 4, pt.y ); ctx.stroke();
ctx.save();
ctx.translate( xBrk - 12, originC.y - ( originC.y - pt.y ) / 2 );
ctx.rotate( -Math.PI / 2 );
ctx.font = 'bold 11px Montserrat, Arial, sans-serif'; ctx.textAlign = 'center';
ctx.fillText( 'y = ' + gy, 0, 0 );
ctx.restore();
}
ctx.restore();
}
function drawCoordLabel( gx, gy, alpha, xColor, yColor ) {
var pt = toCanvas( gx, gy );
var FS = 14, xStr = String( gx ), yStr = String( gy );
var p1 = '(', cm = ', ', p2 = ')';
var wP1, wX, wCm, wY, wP2, totalW, lx, ly, cx2;
ctx.save();
ctx.globalAlpha = ( alpha !== undefined ) ? alpha : 1;
ctx.font = 'bold ' + FS + 'px Montserrat, Arial, sans-serif';
wP1 = ctx.measureText( p1 ).width; wX = ctx.measureText( xStr ).width;
wCm = ctx.measureText( cm ).width; wY = ctx.measureText( yStr ).width;
wP2 = ctx.measureText( p2 ).width;
totalW = wP1 + wX + wCm + wY + wP2;
lx = pt.x + 18; ly = pt.y - 18;
if ( lx + totalW + 8 > CW - 4 ) { lx = pt.x - totalW - 18; }
if ( ly - FS < 4 ) { ly = pt.y + 26; }
ctx.fillStyle = hiruwiki.getThemeColor('background-color-neutral', '#ffffff');
ctx.beginPath(); ctx.roundRect( lx - 4, ly - FS, totalW + 8, FS + 6, 4 ); ctx.fill();
ctx.strokeStyle = hiruwiki.getThemeColor('border-color-base', '#bdc3c7'); ctx.lineWidth = 1; ctx.stroke();
cx2 = lx;
ctx.fillStyle = hiruwiki.getThemeColor('color-base', '#2c3e50'); ctx.fillText( p1, cx2, ly ); cx2 += wP1;
ctx.fillStyle = xColor; ctx.fillText( xStr, cx2, ly ); cx2 += wX;
ctx.fillStyle = hiruwiki.getThemeColor('color-base', '#2c3e50'); ctx.fillText( cm, cx2, ly ); cx2 += wCm;
ctx.fillStyle = yColor; ctx.fillText( yStr, cx2, ly ); cx2 += wY;
ctx.fillStyle = hiruwiki.getThemeColor('color-base', '#2c3e50'); ctx.fillText( p2, cx2, ly );
ctx.restore();
}
function draw() {
var i, j, x, y, originC, p, pc, tp;
ctx.clearRect( 0, 0, CW, CH );
ctx.fillStyle = hiruwiki.getThemeColor('background-color-neutral-subtle', '#fafcff'); ctx.fillRect( 0, 0, CW, CH );
originC = toCanvas( 0, 0 );
for ( i = 0; i <= X_MAX; i++ ) {
x = PAD_LEFT + i * CELL;
ctx.beginPath();
ctx.strokeStyle = ( i === 0 ) ? hiruwiki.getThemeColor('color-base', '#2c3e50') : hiruwiki.getThemeColor('border-color-base', '#d5e0ec');
ctx.lineWidth = ( i === 0 ) ? 2 : 1;
ctx.moveTo( x, PAD_TOP ); ctx.lineTo( x, CH - PAD_BOTTOM ); ctx.stroke();
}
for ( j = 0; j <= Y_MAX; j++ ) {
y = CH - PAD_BOTTOM - j * CELL;
ctx.beginPath();
ctx.strokeStyle = ( j === 0 ) ? hiruwiki.getThemeColor('color-base', '#2c3e50') : hiruwiki.getThemeColor('border-color-base', '#d5e0ec');
ctx.lineWidth = ( j === 0 ) ? 2 : 1;
ctx.moveTo( PAD_LEFT, y ); ctx.lineTo( PAD_LEFT + X_MAX * CELL, y ); ctx.stroke();
}
drawArrow( PAD_LEFT, originC.y, PAD_LEFT + X_MAX * CELL + 16, originC.y, hiruwiki.getThemeColor('color-base', '#2c3e50'), 2 );
drawArrow( PAD_LEFT, originC.y, PAD_LEFT, PAD_TOP - 12, hiruwiki.getThemeColor('color-base', '#2c3e50'), 2 );
ctx.save();
ctx.font = 'italic bold 14px Montserrat, Arial, sans-serif'; ctx.fillStyle = hiruwiki.getThemeColor('color-base', '#2c3e50'); ctx.textAlign = 'center';
ctx.fillText( 'x', PAD_LEFT + X_MAX * CELL + 24, originC.y + 5 );
ctx.fillText( 'y', PAD_LEFT, PAD_TOP - 18 );
ctx.restore();
ctx.save();
ctx.font = '11px Montserrat, Arial, sans-serif'; ctx.fillStyle = hiruwiki.getThemeColor('color-subtle', '#555');
for ( i = 1; i <= X_MAX; i++ ) {
ctx.textAlign = 'center';
ctx.fillText( i, PAD_LEFT + i * CELL, CH - PAD_BOTTOM + 14 );
}
for ( j = 1; j <= Y_MAX; j++ ) {
ctx.textAlign = 'right';
ctx.fillText( j, PAD_LEFT - 6, CH - PAD_BOTTOM - j * CELL + 4 );
}
ctx.textAlign = 'right';
ctx.fillText( '0', PAD_LEFT - 6, CH - PAD_BOTTOM + 13 );
ctx.restore();
if ( mode === 'explorer' ) {
drawProjectionLines( ptX, ptY, COLOR_X, COLOR_Y );
drawDimensionBrackets( ptX, ptY, COLOR_X, COLOR_Y );
p = toCanvas( ptX, ptY );
drawStar( p.x, p.y, 14, '#f1c40f', '#b7950b', 'rgba(241,196,15,0.55)', 12 );
drawCoordLabel( ptX, ptY, 1, COLOR_X, COLOR_Y );
spCX.style.color = COLOR_X; spCX.textContent = ptX;
spCY.style.color = COLOR_Y; spCY.textContent = ptY;
}
if ( mode === 'challenge' ) {
if ( checked && !correct ) {
drawProjectionLines( targetX, targetY, COLOR_X, COLOR_Y, 0.22 );
tp = toCanvas( targetX, targetY );
drawStar( tp.x, tp.y, 14, 'rgba(241,196,15,0.28)', 'rgba(183,149,11,0.35)', null, 0, 1 );
drawCoordLabel( targetX, targetY, 0.28, COLOR_X, COLOR_Y );
}
drawProjectionLines( ptX, ptY, COLOR_X, COLOR_Y );
drawDimensionBrackets( ptX, ptY, COLOR_X, COLOR_Y );
pc = toCanvas( ptX, ptY );
if ( checked && correct ) {
drawStar( pc.x, pc.y, 16, '#2ecc71', '#1a9456', 'rgba(46,204,113,0.8)', 26 );
} else {
drawStar( pc.x, pc.y, 14, '#f1c40f', '#b7950b', 'rgba(241,196,15,0.55)', 12 );
}
drawCoordLabel( ptX, ptY, 1, COLOR_X, COLOR_Y );
}
}
function getPos( e ) {
var rect = canvas.getBoundingClientRect();
var sx = canvas.width / rect.width, sy = canvas.height / rect.height;
var src = e.touches ? e.touches[ 0 ] : e;
return { x: ( src.clientX - rect.left ) * sx, y: ( src.clientY - rect.top ) * sy };
}
function nearStar( cx, cy ) {
var pt = toCanvas( ptX, ptY ), dx = cx - pt.x, dy = cy - pt.y;
return Math.sqrt( dx * dx + dy * dy ) < 45;
}
function onMove( cx, cy ) {
var g = toGrid( cx, cy );
if ( g.gx !== ptX || g.gy !== ptY ) {
ptX = g.gx; ptY = g.gy;
if ( mode === 'challenge' && checked && !correct ) {
checked = false;
feedbackEl.textContent = ''; feedbackEl.className = 'cex-feedback';
checkBtn.disabled = false;
}
draw();
}
}
canvas.addEventListener( 'mousedown', function ( e ) {
if ( mode === 'challenge' && correct ) { return; }
var pos = getPos( e );
if ( nearStar( pos.x, pos.y ) ) { dragging = true; canvas.classList.add( 'cex-dragging' ); }
} );
canvas.addEventListener( 'mousemove', function ( e ) {
if ( !dragging ) { return; }
var pos = getPos( e ); onMove( pos.x, pos.y );
} );
canvas.addEventListener( 'mouseup', function () { dragging = false; canvas.classList.remove( 'cex-dragging' ); } );
canvas.addEventListener( 'mouseleave', function () { dragging = false; canvas.classList.remove( 'cex-dragging' ); } );
canvas.addEventListener( 'touchstart', function ( e ) {
e.preventDefault();
if ( mode === 'challenge' && correct ) { return; }
var pos = getPos( e );
if ( nearStar( pos.x, pos.y ) ) { dragging = true; }
}, { passive: false } );
canvas.addEventListener( 'touchmove', function ( e ) {
e.preventDefault();
if ( !dragging ) { return; }
var pos = getPos( e ); onMove( pos.x, pos.y );
}, { passive: false } );
canvas.addEventListener( 'touchend', function () { dragging = false; } );
function setMode( m ) {
mode = m;
btnExplorer.classList.toggle( 'cex-active', m === 'explorer' );
btnChallenge.classList.toggle( 'cex-active', m === 'challenge' );
explorerPanel.style.display = m === 'explorer' ? 'flex' : 'none';
challengePanel.style.display = m === 'challenge' ? 'flex' : 'none';
if ( m === 'challenge' ) {
nextChallenge();
} else {
ptX = 3; ptY = 4; draw();
}
}
btnExplorer.addEventListener( 'click', function () { setMode( 'explorer' ); } );
btnChallenge.addEventListener( 'click', function () { setMode( 'challenge' ); } );
function nextChallenge() {
targetX = Math.floor( Math.random() * X_MAX ) + 1;
targetY = Math.floor( Math.random() * Y_MAX ) + 1;
ptX = 0; ptY = 0;
checked = false; correct = false;
feedbackEl.textContent = ''; feedbackEl.className = 'cex-feedback';
checkBtn.disabled = false;
nextBtn.style.visibility = 'hidden';
promptBar.innerHTML = t('movePrompt', [
'<span class="cex-target-x">' + targetX + '</span>',
'<span class="cex-target-y">' + targetY + '</span>'
]);
draw();
}
function checkAnswer() {
checked = true; correct = ( ptX === targetX && ptY === targetY );
if ( correct ) {
feedbackEl.textContent = t('feedbackCorrect');
feedbackEl.className = 'cex-feedback cex-feedback-ok';
} else {
feedbackEl.textContent = t('feedbackWrong');
feedbackEl.className = 'cex-feedback cex-feedback-err';
}
checkBtn.disabled = true;
nextBtn.style.visibility = 'visible';
draw();
}
checkBtn.addEventListener( 'click', checkAnswer );
nextBtn.addEventListener( 'click', nextChallenge );
/* Initial state */
explorerPanel.style.display = 'flex';
challengePanel.style.display = 'none';
draw();
}
}() );
i3hxztyyi8gkrwmnbpgcfonjv8kbmsc
MediaWiki:Hiruwiki/modules/pythagoras.js
8
179515
781440
770242
2026-10-28T10:39:55Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781440
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/* =========================================================
* Hiruwiki module: pythagoras
* Interactive visualisation of the Pythagorean theorem
* (and law of cosines for non-right triangles)
* =========================================================
* i18n: add a new key to the `messages` object below and
* a matching entry for every language code you need.
* Falls back to 'en' when the wiki language is absent.
* ========================================================= */
( function () {
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"nl": {
"_name": "Stelling van Pythagoras",
"angleDeg": "hoek =",
"lockRight": "rechte hoek",
"reset": "Opnieuw instellen",
"hint": "Sleep de hoekpunten om de stelling a² + b² = c² te verifiëren",
"controls": "Bediening",
"triangle": "Driehoek",
"sideA": "a",
"sideB": "b",
"hypot": "c (schuinezijde)",
"angOrigin": "∠ oorsprong",
"angA": "∠ A",
"angB": "∠ B",
"badgeRight": "a²+b²=c²",
"badgeCos": "c²=a²+b²−2ab·cosθ",
"sqA": "a²",
"sqB": "b²",
"sqC": "c²",
"cosTheta": "cosθ",
"cosThetaEx": "cosinus van de hoek bij de oorsprong",
"scaleUnit": "cm"
},
"fr": {
"_name": "Théorème de Pythagore",
"angleDeg": "angle =",
"lockRight": "angle droit fixe",
"reset": "Réinitialiser",
"hint": "Faites glisser les sommets pour vérifier le théorème",
"controls": "Contrôles",
"triangle": "Triangle",
"sideA": "a",
"sideB": "b",
"hypot": "c (hyp.)",
"angOrigin": "∠ origine",
"angA": "∠ A",
"angB": "∠ B",
"badgeRight": "a²+b²=c²",
"badgeCos": "c²=a²+b²−2ab·cosθ",
"sqA": "a²",
"sqB": "b²",
"sqC": "c²",
"cosTheta": "cosθ",
"cosThetaEx": "cosinus de l'angle à l'origine",
"scaleUnit": "cm"
},
"tr": {
"_name": "Pisagor Teoremi",
"angleDeg": "açı =",
"lockRight": "dik açı",
"reset": "Sıfırla",
"hint": "a² + b² = c² formülünü doğrulamak için köşeleri sürükleyin",
"controls": "Kontroller",
"triangle": "Üçgen",
"sideA": "a",
"sideB": "b",
"hypot": "c (hip.)",
"angOrigin": "∠ orijin",
"angA": "∠ A",
"angB": "∠ B",
"badgeRight": "a²+b²=c²",
"badgeCos": "c²=a²+b²−2ab·cosθ",
"sqA": "a²",
"sqB": "b²",
"sqC": "c²",
"cosTheta": "cosθ",
"cosThetaEx": "orijindeki açının kosinüsü",
"scaleUnit": "cm"
},
"eu": {
"_name": "Pitagorasen teorema",
"angleDeg": "angelua =",
"lockRight": "angelu zuzena",
"reset": "Berrezarri",
"hint": "Arrastatu erpinak a² + b² = c² egiaztatzeko",
"controls": "Kontrolak",
"triangle": "Triangelua",
"sideA": "a",
"sideB": "b",
"hypot": "c (hip.)",
"angOrigin": "∠ jatorria",
"angA": "∠ A",
"angB": "∠ B",
"badgeRight": "a²+b²=c²",
"badgeCos": "c²=a²+b²−2ab·cosθ",
"sqA": "a²",
"sqB": "b²",
"sqC": "c²",
"cosTheta": "cosθ",
"cosThetaEx": "jatorriko angeluaren kosinua",
"scaleUnit": "cm"
},
"sk": {
"_name": "Pytagorova veta",
"angleDeg": "uhol =",
"lockRight": "pravý uhol",
"reset": "Vynulovať",
"hint": "Potiahnutím vrcholov overíte a² + b² = c²",
"controls": "Ovládanie",
"triangle": "Trojuholník",
"sideA": "a",
"sideB": "b",
"hypot": "c (prepona)",
"angOrigin": "∠ počiatok",
"angA": "∠ A",
"angB": "∠ B",
"badgeRight": "a²+b²=c²",
"badgeCos": "c²=a²+b²−2ab·cosθ",
"sqA": "a²",
"sqB": "b²",
"sqC": "c²",
"cosTheta": "cosθ",
"cosThetaEx": "kosínus uhla pri počiatku",
"scaleUnit": "cm"
},
"skr-arab": {
"_name": "فیثاغورث دا قضیہ",
"angleDeg": "زاویہ =",
"lockRight": "سِدھا زاویہ",
"reset": "↺ دوبارہ",
"hint": "a² + b² = c² کوں پرکھݨ کیتے سراں کوں گھسیٹو",
"controls": "کنٹرول",
"triangle": "مثلث",
"sideA": "a",
"sideB": "b",
"hypot": "c (hyp.)",
"angOrigin": "∠ مبدا",
"angA": "∠ A",
"angB": "∠ B",
"badgeRight": "a²+b²=c²",
"badgeCos": "c²=a²+b²−2ab·cosθ",
"sqA": "a²",
"sqB": "b²",
"sqC": "c²",
"cosTheta": "cosθ",
"cosThetaEx": "مبدا تے زاویے دا کوسائن",
"scaleUnit": "cm"
},
"lb": {
"triangle": "Dräieck",
"sideA": "a",
"sideB": "b",
"badgeRight": "a² + b² = c²",
"badgeCos": "c² = a² + b² − 2ab·cosθ",
"sqA": "a²",
"sqB": "b²",
"sqC": "c²",
"scaleUnit": "cm"
},
"ko": {
"_name": "피타고라스 정리",
"angleDeg": "각도 =",
"lockRight": "직각",
"reset": "초기화",
"hint": "꼭짓점을 드래그하여 a² + b² = c²을 확인하세요",
"controls": "제어",
"triangle": "삼각형",
"sideA": "a",
"sideB": "b",
"hypot": "c (빗변)",
"angOrigin": "∠ 원점",
"angA": "∠ A",
"angB": "∠ B",
"badgeRight": "a²+b²=c²",
"badgeCos": "c²=a²+b²−2ab·cosθ",
"sqA": "a²",
"sqB": "b²",
"sqC": "c²",
"cosTheta": "cosθ",
"cosThetaEx": "원점에서의 각도의 코사인",
"scaleUnit": "cm"
},
"en": {
"_name": "Pythagorean Theorem",
"angleDeg": "angle =",
"lockRight": "right angle",
"reset": "Reset",
"hint": "Drag vertices to verify a² + b² = c²",
"controls": "Controls",
"triangle": "Triangle",
"sideA": "a",
"sideB": "b",
"hypot": "c (hyp.)",
"angOrigin": "∠ origin",
"angA": "∠ A",
"angB": "∠ B",
"badgeRight": "a²+b²=c²",
"badgeCos": "c²=a²+b²−2ab·cosθ",
"sqA": "a²",
"sqB": "b²",
"sqC": "c²",
"cosTheta": "cosθ",
"cosThetaEx": "cosine of the angle at origin",
"scaleUnit": "cm"
},
"it": {
"_name": "Teorema di Pitagora",
"angleDeg": "angolo =",
"lockRight": "angolo retto",
"reset": "Reimposta",
"hint": "Trascina i vertici per verificare a² + b² = c²",
"controls": "Controlli",
"triangle": "Triangolo",
"sideA": "a",
"sideB": "b",
"hypot": "c (ip.)",
"angOrigin": "∠ origine",
"angA": "∠ A",
"angB": "∠ B",
"badgeRight": "a²+b²=c²",
"badgeCos": "c²=a²+b²−2ab·cosθ",
"sqA": "a²",
"sqB": "b²",
"sqC": "c²",
"cosTheta": "cosθ",
"cosThetaEx": "coseno dell'angolo all'origine",
"scaleUnit": "cm"
},
"ca": {
"_name": "Teorema de Pitàgores",
"angleDeg": "angle =",
"lockRight": "angle recte",
"reset": "Reinicia",
"hint": "Arrossegueu els vèrtexs per verificar a² + b² = c²",
"controls": "Controls",
"triangle": "Triangle",
"sideA": "a",
"sideB": "b",
"hypot": "c (hip.)",
"angOrigin": "∠ origen",
"angA": "∠ A",
"angB": "∠ B",
"badgeRight": "a²+b²=c²",
"badgeCos": "c²=a²+b²−2ab·cosθ",
"sqA": "a²",
"sqB": "b²",
"sqC": "c²",
"cosTheta": "cosθ",
"cosThetaEx": "cosinus de l'angle a l'origen",
"scaleUnit": "cm"
},
"qqq": {
"_name": "Name of the Pythagorean Theorem module",
"angleDeg": "Label prefix for the angle value. Followed by a number in degrees.",
"lockRight": "checkbox to lock right angle",
"reset": "text in the reset button",
"hint": "clue for the interactive part",
"controls": "Title for the control panel",
"triangle": "Title for the triangle panel",
"sideA": "letter a",
"sideB": "letter b",
"hypot": "letter c and abbreviature for hypothenuse",
"angOrigin": "symbol of angle and the word origin",
"angA": "∠ A",
"angB": "∠ B",
"badgeRight": "formula for right angles",
"badgeCos": "formula for non right angles",
"sqA": "squared a",
"sqB": "squared b",
"sqC": "squared c",
"cosTheta": "cosinus of theta",
"cosThetaEx": "cosinus of the angle in origin",
"scaleUnit": "abbreviature of centimetrs"
},
"ga": {
"_name": "Teoirim Phíotagaráis",
"angleDeg": "uillinn =",
"lockRight": "uillinn dheis",
"reset": "Athshocraigh",
"hint": "Tarraing na rinn chun a² + b² = c² a fhíorú",
"controls": "Rialuithe",
"triangle": "Triantán",
"sideA": "a",
"sideB": "b",
"hypot": "c (hip.)",
"angOrigin": "∠ bunús",
"angA": "∠ A",
"angB": "∠ B",
"badgeRight": "a²+b²=c²",
"badgeCos": "c²=a²+b²−2ab·cosθ",
"sqA": "a²",
"sqB": "b²",
"sqC": "c²",
"cosTheta": "cosθ",
"cosThetaEx": "cóisín na huillinne ag an mbunús",
"scaleUnit": "cm"
},
"es": {
"_name": "Teorema de Pitágoras",
"angleDeg": "ángulo =",
"lockRight": "ángulo recto",
"reset": "Reiniciar",
"hint": "Arrastra los vértices para verificar a² + b² = c²",
"controls": "Controles",
"triangle": "Triángulo",
"sideA": "a",
"sideB": "b",
"hypot": "c (hip.)",
"angOrigin": "∠ origen",
"angA": "∠ A",
"angB": "∠ B",
"badgeRight": "a²+b²=c²",
"badgeCos": "c²=a²+b²−2ab·cosθ",
"sqA": "a²",
"sqB": "b²",
"sqC": "c²",
"cosTheta": "cosθ",
"cosThetaEx": "coseno del ángulo en el origen",
"scaleUnit": "cm"
},
"mk": {
"_name": "Питагорова теорема",
"angleDeg": "агол =",
"lockRight": "прав агол",
"reset": "Одново",
"hint": "Повелечете темиња за да проверите a² + b² = c²",
"controls": "Контроли",
"triangle": "Триаголник",
"sideA": "a",
"sideB": "b",
"hypot": "c (хип.)",
"angOrigin": "∠ почеток",
"angA": "∠ A",
"angB": "∠ B",
"badgeRight": "a²+b²=c²",
"badgeCos": "c²=a²+b²−2ab·cosθ",
"sqA": "a²",
"sqB": "b²",
"sqC": "c²",
"cosTheta": "cosθ",
"cosThetaEx": "косинус на агол во почетокот",
"scaleUnit": "см"
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
lang = lang.split( '-' )[ 0 ];
if ( !messages[ lang ] ) { lang = 'en'; }
var msg = messages[ lang ];
/* ── Constants ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var SC = 46; /* pixels per centimetre */
var RH = 26; /* ruler strip height (px) */
var VW = 700; /* SVG viewBox width */
var VH = 560; /* SVG viewBox height */
/* ── Geometry helpers ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
function dist( a, b ) { return Math.hypot( a.x - b.x, a.y - b.y ); }
function angAt( O, A, B ) {
var ax = A.x - O.x, ay = A.y - O.y;
var bx = B.x - O.x, by = B.y - O.y;
return Math.abs( Math.atan2( ax * by - ay * bx, ax * bx + ay * by ) * 180 / Math.PI );
}
/* 4 corners of the square built on edge A→B, on the side away from C */
function squarePts( A, B, C ) {
var vx = B.x - A.x, vy = B.y - A.y, d = Math.hypot( vx, vy );
if ( d < 2 ) { return null; }
var n1x = -vy / d, n1y = vx / d;
var mx = ( A.x + B.x ) / 2, my = ( A.y + B.y ) / 2;
var signC = Math.sign( ( B.x - A.x ) * ( C.y - A.y ) - ( B.y - A.y ) * ( C.x - A.x ) );
var t1x = mx + n1x * 10, t1y = my + n1y * 10;
var sign1 = Math.sign( ( B.x - A.x ) * ( t1y - A.y ) - ( B.y - A.y ) * ( t1x - A.x ) );
var nx, ny;
if ( sign1 !== signC ) { nx = n1x; ny = n1y; } else { nx = -n1x; ny = -n1y; }
return [ A, B, { x: B.x + nx * d, y: B.y + ny * d }, { x: A.x + nx * d, y: A.y + ny * d } ];
}
function sqCenter( ps ) {
if ( !ps ) { return { x: 0, y: 0 }; }
var xs = ps.map( function ( p ) { return p.x; } );
var ys = ps.map( function ( p ) { return p.y; } );
return {
x: ( Math.min.apply( null, xs ) + Math.max.apply( null, xs ) ) / 2,
y: ( Math.min.apply( null, ys ) + Math.max.apply( null, ys ) ) / 2
};
}
function ptsStr( ps ) {
return ps ? ps.map( function ( p ) { return p.x + ',' + p.y; } ).join( ' ' ) : '';
}
/*
* Edge label: placed beside edge A→B on the square side (away from C).
* The offset is computed in world space using the correct perpendicular,
* then the text is rotated around the label point itself — so rotation
* only affects orientation, never position.
*/
function edgeLabelHTML( A, B, C, text, fill ) {
var vx = B.x - A.x, vy = B.y - A.y, d = Math.hypot( vx, vy );
if ( d < 2 ) { return ''; }
var p1x = -vy / d, p1y = vx / d;
var p2x = vy / d, p2y = -vx / d;
var mx = ( A.x + B.x ) / 2, my = ( A.y + B.y ) / 2;
var dot1 = ( mx + p1x * 10 - C.x ) * p1x + ( my + p1y * 10 - C.y ) * p1y;
var dot2 = ( mx + p2x * 10 - C.x ) * p2x + ( my + p2y * 10 - C.y ) * p2y;
var nx = dot1 > dot2 ? p1x : p2x;
var ny = dot1 > dot2 ? p1y : p2y;
var lx = mx + nx * 22, ly = my + ny * 22;
var ang = Math.atan2( vy, vx ) * 180 / Math.PI;
if ( ang > 90 || ang < -90 ) { ang += 180; }
return '<text x="' + lx.toFixed( 1 ) + '" y="' + ly.toFixed( 1 ) + '" ' +
'text-anchor="middle" dominant-baseline="middle" ' +
'font-size="14" font-weight="bold" fill="' + fill + '" ' +
'transform="rotate(' + ang.toFixed( 1 ) + ',' + lx.toFixed( 1 ) + ',' + ly.toFixed( 1 ) + ')">' +
text + '</text>';
}
function loff( V, others, mag ) {
var dx = 0, dy = 0;
others.forEach( function ( P ) { dx += V.x - P.x; dy += V.y - P.y; } );
var l = Math.hypot( dx, dy ) || 1;
return { x: V.x + dx / l * mag, y: V.y + dy / l * mag };
}
/* ── Colour palette (light / dark) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
function isDark() {
var cl = document.documentElement.classList;
if ( cl.contains( 'skin-theme-clientpref-night' ) ||
cl.contains( 'client-dark-mode' ) ) { return true; }
if ( document.body.classList.contains( 'mw-dark-mode' ) ) { return true; }
return false;
}
function palette() {
var d = isDark();
return {
bg: d ? '#1C1C1A' : '#F9F8F4',
grid: d ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.06)',
gridCm: d ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.16)',
rBg: d ? '#2A2A28' : '#EEEADE',
rTk: d ? '#888780' : '#6A6860',
vBg: d ? '#1e1e1c' : '#fff',
vtx: d ? '#AFA9EC' : '#534AB7',
txt: d ? '#E8E6DC' : '#1a1a18',
mut: d ? '#888780' : '#5F5E5A',
sqA: d ? 'rgba(220,80,80,.20)' : 'rgba(255,140,140,.38)',
sqB: d ? 'rgba(60,140,255,.20)' : 'rgba(120,190,255,.38)',
sqC: d ? 'rgba(60,200,120,.20)' : 'rgba(120,230,160,.38)',
stA: '#E55', stB: '#49E', stC: '#4B6',
areaA: d ? '#F88' : '#900',
areaB: d ? '#9CF' : '#058',
areaC: d ? '#9F9' : '#060',
lblA: d ? '#F88' : '#c22',
lblB: d ? '#9CF' : '#07b',
lblC: d ? '#9F9' : '#060'
};
}
/* ── Default triangle positions ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
function defPts() {
var ox = 230, oy = 360;
return {
O: { x: ox, y: oy },
A: { x: ox, y: oy - 3 * SC },
B: { x: ox + 4 * SC, y: oy }
};
}
/* ── Build widget ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
function buildWidget( container ) {
var pts = defPts();
var dragging = null;
var last = null;
/* -- HTML skeleton -- */
container.innerHTML =
'<div class="hw-pythagoras">' +
'<div class="hw-pytha-layout">' +
'<svg class="hw-pytha-svg" viewBox="0 0 ' + VW + ' ' + VH + '"></svg>' +
'<div class="hw-pytha-sidebar">' +
/* Card 1: Controls */
'<div class="hw-card">' +
'<div class="hw-card-title"><span class="hw-badge hw-badge-ctrl">' + msg.controls + '</span></div>' +
'<div class="hw-pytha-ci">' +
'<label class="hw-pytha-lbl-a">' + msg.sideA + '</label>' +
'<input class="hw-pytha-inp" id="hw-pytha-ia" type="number" step="0.1" min="0.5" max="8" value="3.00">' +
'</div>' +
'<div class="hw-pytha-ci">' +
'<label class="hw-pytha-lbl-b">' + msg.sideB + '</label>' +
'<input class="hw-pytha-inp" id="hw-pytha-ib" type="number" step="0.1" min="0.5" max="8" value="4.00">' +
'</div>' +
'<div class="hw-pytha-ci">' +
'<label class="hw-pytha-lbl-ang">∠</label>' +
'<input class="hw-pytha-inp" id="hw-pytha-iang" type="number" step="1" min="5" max="175" value="90" disabled>' +
'</div>' +
'<div class="hw-pytha-ci">' +
'<input id="hw-pytha-ckr" type="checkbox" checked>' +
'<label for="hw-pytha-ckr">' + msg.lockRight + '</label>' +
'</div>' +
'<button class="hw-pytha-reset" id="hw-pytha-brst">↺ ' + msg.reset + '</button>' +
'</div>' +
/* Card 2: Triangle */
'<div class="hw-card">' +
'<div class="hw-card-title"><span class="hw-badge hw-badge-t">' + msg.triangle + '</span></div>' +
'<div class="hw-row"><span>' + msg.sideA + '</span><span id="hw-pytha-sa" class="hw-pytha-val-a">—</span></div>' +
'<div class="hw-row"><span>' + msg.sideB + '</span><span id="hw-pytha-sb" class="hw-pytha-val-b">—</span></div>' +
'<div class="hw-row"><span>' + msg.hypot + '</span><span id="hw-pytha-sc" class="hw-pytha-val-c">—</span></div>' +
'<div class="hw-row"><span>' + msg.angOrigin + '</span><span id="hw-pytha-sang">—</span></div>' +
'<div class="hw-row"><span>' + msg.angA + '</span><span id="hw-pytha-sanga">—</span></div>' +
'<div class="hw-row"><span>' + msg.angB + '</span><span id="hw-pytha-sangb">—</span></div>' +
'</div>' +
/* Card 3: Formula — badge title switches between a²+b²=c² and full cosine formula */
'<div class="hw-card">' +
'<div class="hw-card-title"><span class="hw-badge hw-badge-c" id="hw-pytha-bform">' + msg.badgeRight + '</span></div>' +
'<div class="hw-row"><span>' + msg.sqA + '</span><span id="hw-pytha-sa2" class="hw-pytha-val-a">—</span></div>' +
'<div class="hw-row"><span>' + msg.sqB + '</span><span id="hw-pytha-sb2" class="hw-pytha-val-b">—</span></div>' +
'<div class="hw-row"><span>' + msg.sqC + '</span><span id="hw-pytha-sc2" class="hw-pytha-val-c">—</span></div>' +
/* cosθ row: hidden at 90°, shown otherwise */
'<div class="hw-row hw-pytha-cos-row" id="hw-pytha-cos-row">' +
'<span>' + msg.cosTheta + ' <span class="hw-pytha-cos-ex">(' + msg.cosThetaEx + ')</span></span>' +
'<span id="hw-pytha-scos">—</span>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>';
/* -- footer (same pattern as orthocentre) -- */
var footer = document.createElement( 'div' );
footer.className = 'hw-footer';
var fLogo = document.createElement( 'a' );
fLogo.className = 'hw-footer-icon';
fLogo.href = mw.util.getUrl( 'Wikipedia:Hiruwiki' );
fLogo.title = 'Hiruwiki';
fLogo.innerHTML = hiruwiki.getLogoSvg( 22 );
var fText = document.createElement( 'span' );
fText.textContent = msg.hint;
footer.appendChild( fLogo );
footer.appendChild( fText );
container.querySelector( '.hw-pythagoras' ).appendChild( footer );
/* -- element references -- */
var svgEl = container.querySelector( '.hw-pytha-svg' );
var inpA = container.querySelector( '#hw-pytha-ia' );
var inpB = container.querySelector( '#hw-pytha-ib' );
var inpAng = container.querySelector( '#hw-pytha-iang' );
var ckr = container.querySelector( '#hw-pytha-ckr' );
var brst = container.querySelector( '#hw-pytha-brst' );
function $id( id ) { return container.querySelector( '#' + id ); }
/* -- SVG coordinate helper -- */
function svgPt( e ) {
var r = svgEl.getBoundingClientRect();
var s = e.touches ? e.touches[ 0 ] : e;
return {
x: ( s.clientX - r.left ) * VW / r.width,
y: ( s.clientY - r.top ) * VH / r.height
};
}
/* -- clamp vertices inside viewBox -- */
function clamp() {
var m = 16;
[ pts.O, pts.A, pts.B ].forEach( function ( v ) {
v.x = Math.max( m, Math.min( VW - m, v.x ) );
v.y = Math.max( RH + m, Math.min( VH - m, v.y ) );
} );
}
/* -- hit-test (returns 'O', 'A', 'B' or null) -- */
function hitTest( p ) {
var best = null, bd = 999;
[ { k: 'O', v: pts.O }, { k: 'A', v: pts.A }, { k: 'B', v: pts.B } ].forEach( function ( e ) {
var dd = dist( p, e.v );
if ( dd < 20 && dd < bd ) { bd = dd; best = e.k; }
} );
return best;
}
/* ── Main draw ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
function draw() {
var p = palette();
var O = pts.O, A = pts.A, B = pts.B;
var a = dist( O, A ) / SC, b = dist( O, B ) / SC, c = dist( A, B ) / SC;
var angO = angAt( O, A, B ), angA = angAt( A, O, B ), angB = 180 - angO - angA;
/* Use the checkbox as the canonical source of truth for right-angle mode,
not a floating-point threshold on the computed angle. */
var isRight = ckr.checked;
var cosO = Math.cos( angO * Math.PI / 180 );
var psA = squarePts( O, A, B );
var psB = squarePts( O, B, A );
var psC = squarePts( A, B, O );
var cA = sqCenter( psA ), cB = sqCenter( psB ), cC = sqCenter( psC );
var parts = [];
/* background */
parts.push( '<rect x="0" y="0" width="' + VW + '" height="' + VH + '" fill="' + p.bg + '"/>' );
/* grid — full canvas, mm subdivisions + cm lines */
var step = SC / 10;
for ( var gx = 0; gx <= VW; gx = Math.round( ( gx + step ) * 100 ) / 100 ) {
var isCmX = Math.round( gx * 10 ) % Math.round( SC * 10 ) < Math.round( step * 6 );
parts.push( '<line x1="' + gx.toFixed( 1 ) + '" y1="0" x2="' + gx.toFixed( 1 ) + '" y2="' + VH +
'" stroke="' + ( isCmX ? p.gridCm : p.grid ) + '" stroke-width="' + ( isCmX ? '0.7' : '0.35' ) + '"/>' );
}
for ( var gy = 0; gy <= VH; gy = Math.round( ( gy + step ) * 100 ) / 100 ) {
var rel = gy - RH;
var isCmY = rel >= 0 && Math.round( rel * 10 ) % Math.round( SC * 10 ) < Math.round( step * 6 );
parts.push( '<line x1="0" y1="' + gy.toFixed( 1 ) + '" x2="' + VW + '" y2="' + gy.toFixed( 1 ) +
'" stroke="' + ( isCmY ? p.gridCm : p.grid ) + '" stroke-width="' + ( isCmY ? '0.7' : '0.35' ) + '"/>' );
}
/* ruler */
parts.push( '<rect x="0" y="0" width="' + VW + '" height="' + RH + '" fill="' + p.rBg + '"/>' );
parts.push( '<line x1="0" y1="' + RH + '" x2="' + VW + '" y2="' + RH + '" stroke="' + p.rTk + '" stroke-width="0.6"/>' );
parts.push( '<text x="3" y="' + ( RH / 2 ) + '" dominant-baseline="middle" font-size="8" fill="' + p.rTk + '" font-weight="500">' + msg.scaleUnit + '</text>' );
for ( var cm = 0; cm * SC <= VW + SC; cm++ ) {
var px = cm * SC;
parts.push( '<line x1="' + px + '" y1="' + ( RH - 10 ) + '" x2="' + px + '" y2="' + RH + '" stroke="' + p.rTk + '" stroke-width="0.9"/>' );
if ( cm > 0 ) {
parts.push( '<text x="' + px + '" y="' + ( RH - 12 ) + '" text-anchor="middle" font-size="9" fill="' + p.rTk + '">' + cm + '</text>' );
}
for ( var mm = 1; mm < 10; mm++ ) {
var mpx = px + mm * SC / 10;
if ( mpx > VW ) { break; }
parts.push( '<line x1="' + mpx + '" y1="' + ( RH - ( mm === 5 ? 7 : 3 ) ) + '" x2="' + mpx + '" y2="' + RH + '" stroke="' + p.rTk + '" stroke-width="0.4"/>' );
}
}
/* squares */
parts.push( '<polygon points="' + ptsStr( psA ) + '" fill="' + p.sqA + '" stroke="' + p.stA + '" stroke-width="1.5"/>' );
parts.push( '<polygon points="' + ptsStr( psB ) + '" fill="' + p.sqB + '" stroke="' + p.stB + '" stroke-width="1.5"/>' );
parts.push( '<polygon points="' + ptsStr( psC ) + '" fill="' + p.sqC + '" stroke="' + p.stC + '" stroke-width="1.5"/>' );
/* triangle sides */
parts.push( '<line x1="' + O.x + '" y1="' + O.y + '" x2="' + A.x + '" y2="' + A.y + '" stroke="' + p.stA + '" stroke-width="3" stroke-linecap="round"/>' );
parts.push( '<line x1="' + O.x + '" y1="' + O.y + '" x2="' + B.x + '" y2="' + B.y + '" stroke="' + p.stB + '" stroke-width="3" stroke-linecap="round"/>' );
parts.push( '<line x1="' + A.x + '" y1="' + A.y + '" x2="' + B.x + '" y2="' + B.y + '" stroke="' + p.stC + '" stroke-width="3" stroke-linecap="round"/>' );
/* area labels centred in each square */
parts.push( '<text x="' + cA.x + '" y="' + cA.y + '" text-anchor="middle" dominant-baseline="middle" font-size="15" font-weight="bold" fill="' + p.areaA + '">' + ( a * a ).toFixed( 2 ) + '</text>' );
parts.push( '<text x="' + cB.x + '" y="' + cB.y + '" text-anchor="middle" dominant-baseline="middle" font-size="15" font-weight="bold" fill="' + p.areaB + '">' + ( b * b ).toFixed( 2 ) + '</text>' );
parts.push( '<text x="' + cC.x + '" y="' + cC.y + '" text-anchor="middle" dominant-baseline="middle" font-size="15" font-weight="bold" fill="' + p.areaC + '">' + ( c * c ).toFixed( 2 ) + '</text>' );
/* edge labels on the square side of each edge */
parts.push( edgeLabelHTML( O, A, B, msg.sideA + '=' + a.toFixed( 2 ), p.lblA ) );
parts.push( edgeLabelHTML( O, B, A, msg.sideB + '=' + b.toFixed( 2 ), p.lblB ) );
parts.push( edgeLabelHTML( A, B, O, 'c=' + c.toFixed( 2 ), p.lblC ) );
/* angle labels offset away from opposite side */
var loO = loff( O, [ A, B ], 22 );
var loA = loff( A, [ O, B ], 22 );
var loB = loff( B, [ O, A ], 22 );
parts.push( '<text x="' + loO.x + '" y="' + loO.y + '" text-anchor="middle" dominant-baseline="middle" font-size="12" font-weight="bold" fill="' + p.txt + '">' + angO.toFixed( 1 ) + '°</text>' );
parts.push( '<text x="' + loA.x + '" y="' + loA.y + '" text-anchor="middle" dominant-baseline="middle" font-size="12" fill="' + p.mut + '">' + angA.toFixed( 1 ) + '°</text>' );
parts.push( '<text x="' + loB.x + '" y="' + loB.y + '" text-anchor="middle" dominant-baseline="middle" font-size="12" fill="' + p.mut + '">' + angB.toFixed( 1 ) + '°</text>' );
/* right-angle mark (only when checkbox is locked) */
if ( isRight ) {
var sz = 12;
var vAx = A.x - O.x, vAy = A.y - O.y, lA2 = Math.hypot( vAx, vAy ) || 1;
var vBx = B.x - O.x, vBy = B.y - O.y, lB2 = Math.hypot( vBx, vBy ) || 1;
var uAx = vAx / lA2 * sz, uAy = vAy / lA2 * sz;
var uBx = vBx / lB2 * sz, uBy = vBy / lB2 * sz;
parts.push( '<path d="M' + ( O.x + uAx ) + ',' + ( O.y + uAy ) +
' L' + ( O.x + uAx + uBx ) + ',' + ( O.y + uAy + uBy ) +
' L' + ( O.x + uBx ) + ',' + ( O.y + uBy ) +
'" stroke="' + p.vtx + '" stroke-width="1.5" fill="none"/>' );
}
/* vertex dots (drawn last, on top) */
parts.push( '<circle cx="' + O.x + '" cy="' + O.y + '" r="9" fill="' + p.vtx + '" stroke="' + p.vBg + '" stroke-width="2" style="cursor:grab"/>' );
parts.push( '<circle cx="' + A.x + '" cy="' + A.y + '" r="9" fill="' + p.stA + '" stroke="' + p.vBg + '" stroke-width="2" style="cursor:grab"/>' );
parts.push( '<circle cx="' + B.x + '" cy="' + B.y + '" r="9" fill="' + p.stB + '" stroke="' + p.vBg + '" stroke-width="2" style="cursor:grab"/>' );
svgEl.innerHTML = parts.join( '' );
/* ── sidebar stats ── */
$id( 'hw-pytha-sa' ).textContent = a.toFixed( 2 ) + ' ' + msg.scaleUnit;
$id( 'hw-pytha-sb' ).textContent = b.toFixed( 2 ) + ' ' + msg.scaleUnit;
$id( 'hw-pytha-sc' ).textContent = c.toFixed( 2 ) + ' ' + msg.scaleUnit;
$id( 'hw-pytha-sang' ).textContent = angO.toFixed( 1 ) + '°';
$id( 'hw-pytha-sanga' ).textContent = angA.toFixed( 1 ) + '°';
$id( 'hw-pytha-sangb' ).textContent = angB.toFixed( 1 ) + '°';
$id( 'hw-pytha-sa2' ).textContent = ( a * a ).toFixed( 2 );
$id( 'hw-pytha-sb2' ).textContent = ( b * b ).toFixed( 2 );
$id( 'hw-pytha-sc2' ).textContent = ( c * c ).toFixed( 2 );
/* badge and cosθ row */
var cosRow = $id( 'hw-pytha-cos-row' );
if ( isRight ) {
$id( 'hw-pytha-bform' ).textContent = msg.badgeRight;
cosRow.style.display = 'none';
} else {
$id( 'hw-pytha-bform' ).textContent = msg.badgeCos;
cosRow.style.display = '';
$id( 'hw-pytha-scos' ).textContent = cosO.toFixed( 4 );
}
/* sync inputs */
inpA.value = a.toFixed( 2 );
inpB.value = b.toFixed( 2 );
inpAng.disabled = ckr.checked;
inpAng.style.opacity = ckr.checked ? '0.4' : '1';
if ( ckr.checked ) { inpAng.value = '90'; } else { inpAng.value = angO.toFixed( 1 ); }
}
/* ── Drag handling ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
function applyDrag( k, dx, dy ) {
if ( k === 'A' ) {
if ( ckr.checked ) { pts.A.y += dy; pts.A.x = pts.O.x; }
else { pts.A.x += dx; pts.A.y += dy; }
} else if ( k === 'B' ) {
if ( ckr.checked ) { pts.B.x += dx; pts.B.y = pts.O.y; }
else { pts.B.x += dx; pts.B.y += dy; }
} else {
pts.O.x += dx; pts.O.y += dy;
pts.A.x += dx; pts.A.y += dy;
pts.B.x += dx; pts.B.y += dy;
}
clamp();
}
svgEl.addEventListener( 'mousemove', function ( e ) {
var p = svgPt( e );
if ( !dragging ) { svgEl.style.cursor = hitTest( p ) ? 'grab' : 'crosshair'; return; }
e.preventDefault();
var dx = p.x - last.x, dy = p.y - last.y; last = p;
applyDrag( dragging, dx, dy );
svgEl.style.cursor = 'grabbing';
draw();
} );
svgEl.addEventListener( 'mousedown', function ( e ) {
var p = svgPt( e ); dragging = hitTest( p );
if ( dragging ) { last = p; svgEl.style.cursor = 'grabbing'; e.preventDefault(); }
} );
window.addEventListener( 'mouseup', function () { dragging = null; } );
svgEl.addEventListener( 'touchstart', function ( e ) {
var p = svgPt( e ); dragging = hitTest( p );
if ( dragging ) { last = p; e.preventDefault(); }
}, { passive: false } );
svgEl.addEventListener( 'touchmove', function ( e ) {
if ( !dragging ) { return; }
e.preventDefault();
var p = svgPt( e ), dx = p.x - last.x, dy = p.y - last.y; last = p;
applyDrag( dragging, dx, dy );
draw();
}, { passive: false } );
window.addEventListener( 'touchend', function () { dragging = null; } );
/* ── Input controls ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
inpA.addEventListener( 'input', function () {
var v = parseFloat( this.value ); if ( isNaN( v ) || v <= 0 ) { return; }
if ( ckr.checked ) { pts.A.x = pts.O.x; pts.A.y = pts.O.y - v * SC; }
else {
var ang = Math.atan2( pts.A.y - pts.O.y, pts.A.x - pts.O.x );
pts.A.x = pts.O.x + Math.cos( ang ) * v * SC;
pts.A.y = pts.O.y + Math.sin( ang ) * v * SC;
}
clamp(); draw();
} );
inpB.addEventListener( 'input', function () {
var v = parseFloat( this.value ); if ( isNaN( v ) || v <= 0 ) { return; }
if ( ckr.checked ) { pts.B.x = pts.O.x + v * SC; pts.B.y = pts.O.y; }
else {
var ang = Math.atan2( pts.B.y - pts.O.y, pts.B.x - pts.O.x );
pts.B.x = pts.O.x + Math.cos( ang ) * v * SC;
pts.B.y = pts.O.y + Math.sin( ang ) * v * SC;
}
clamp(); draw();
} );
inpAng.addEventListener( 'input', function () {
if ( ckr.checked ) { return; }
var deg = parseFloat( this.value ); if ( isNaN( deg ) ) { return; }
var r = dist( pts.O, pts.B );
var base = Math.atan2( pts.A.y - pts.O.y, pts.A.x - pts.O.x );
pts.B.x = pts.O.x + r * Math.cos( base + deg * Math.PI / 180 );
pts.B.y = pts.O.y + r * Math.sin( base + deg * Math.PI / 180 );
clamp(); draw();
} );
ckr.addEventListener( 'change', function () {
if ( this.checked ) {
var la = dist( pts.O, pts.A ), lb = dist( pts.O, pts.B );
pts.A.x = pts.O.x; pts.A.y = pts.O.y - la;
pts.B.x = pts.O.x + lb; pts.B.y = pts.O.y;
}
draw();
} );
brst.addEventListener( 'click', function () {
var d = defPts(); pts.O = d.O; pts.A = d.A; pts.B = d.B;
ckr.checked = true; draw();
} );
/* dark-mode live update */
if ( window.matchMedia ) {
window.matchMedia( '(prefers-color-scheme:dark)' ).addEventListener( 'change', draw );
}
var observer = new MutationObserver( draw );
observer.observe( document.documentElement, { attributes: true, attributeFilter: [ 'class' ] } );
observer.observe( document.body, { attributes: true, attributeFilter: [ 'class' ] } );
draw();
}
/* ── Initialise all matching containers on the page ━━━━━━━━━━━━━━━━━━━━━━━━ ── */
function init() {
document.querySelectorAll( '.hiruwiki[data-module="pythagoras"]' ).forEach( buildWidget );
}
if ( document.readyState === 'loading' ) {
document.addEventListener( 'DOMContentLoaded', init );
} else {
init();
}
}() );
sm1dq9od1p5vkuxz1w3wym066n0p13o
MediaWiki:Hiruwiki/modules/quadrangle-angles.js
8
179517
781441
770244
2026-10-28T10:39:58Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781441
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
( function () {
'use strict';
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"nl": {
"_name": "Hoeken van een vierhoek",
"interiorAngles": "Binnenhoeken",
"reset": "Opnieuw instellen",
"sum": "Som",
"total": "Totaal",
"hint": "Sleep de hoekpunten om de som van de binnenhoeken te zien"
},
"fr": {
"_name": "Angles du quadrilatère",
"interiorAngles": "Angles intérieurs",
"reset": "Réinitialiser",
"sum": "Somme",
"total": "Total",
"hint": "Faites glisser les sommets pour voir la somme des angles intérieurs."
},
"tr": {
"_name": "Dörtgen Açıları",
"interiorAngles": "İç açılar",
"reset": "Sıfırla",
"sum": "Toplam",
"total": "Toplam",
"hint": "Köşeleri sürükleyerek iç açıların toplamını görün."
},
"eu": {
"_name": "Koadrilateroaren angeluak",
"interiorAngles": "Barne angeluak",
"reset": "Berrezarri",
"sum": "Batuketa",
"total": "Guztira",
"hint": "Arrastatu erpinak barne-angeluen batura ikusteko"
},
"sk": {
"_name": "Uhly štvoruholníka",
"interiorAngles": "Vnútorné uhly",
"reset": "Vynulovať",
"sum": "Súčet",
"total": "Spolu",
"hint": "Potiahnutím vrcholov uvidíte súčet vnútorných uhlov"
},
"skr-arab": {
"_name": "چوکور زاویے",
"interiorAngles": "اندرونی زاوے",
"reset": "↺ دوبارہ",
"sum": "مجموعہ",
"total": "کل",
"hint": "اندرونی زاویاں دا مجموعہ ویکھݨ کیتے سراں کوں گھسیٹو"
},
"ko": {
"_name": "사각형의 각도",
"interiorAngles": "내부 각들",
"reset": "초기화",
"sum": "총합",
"total": "합계",
"hint": "꼭짓점을 드래그하여 내각의 합을 확인하세요."
},
"en": {
"_name": "Quadrangle Angles",
"interiorAngles": "Interior angles",
"reset": "Reset",
"sum": "Sum",
"total": "Total",
"hint": "Drag vertices to see the sum of interior angles"
},
"ca": {
"interiorAngles": "Angles interiors",
"reset": "Reinicia",
"sum": "Suma",
"total": "Total"
},
"qqq": {
"_name": "Name of the Quadrangle Angles module",
"interiorAngles": "Heading for the interior angles section",
"reset": "Button label to reset the visualisation",
"sum": "Label for the sum of angles",
"total": "Label for the total sum of interior angles",
"hint": "Instruction text for the quadrangle angle interaction"
},
"ga": {
"_name": "Uillinneacha Ceathairshleasacha",
"interiorAngles": "Uillinneacha inmheánacha",
"reset": "Athshocraigh",
"sum": "Suim",
"total": "Iomlán",
"hint": "Tarraing buaicphointí chun suim na n-uillinneacha inmheánacha a fheiceáil"
},
"es": {
"_name": "Ángulos del cuadrilátero",
"interiorAngles": "Ángulos interiores",
"reset": "Reiniciar",
"sum": "Suma",
"total": "Total",
"hint": "Arrastra los vértices para ver la suma de los ángulos interiores"
},
"mk": {
"_name": "Агли на четириаголник",
"interiorAngles": "Внатрешни агли",
"reset": "Одново",
"sum": "Збир",
"total": "Вкупно",
"hint": "Повлечете ги темињата за да го видите збирот на внатрешните агли"
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
var SVG_W = 540, SVG_H = 480;
var SVG_NS = 'http://www.w3.org/2000/svg';
var KEYS = ['A', 'B', 'C', 'D'];
var COLORS = { A: 'red', B: 'green', C: 'blue', D: 'orange' };
function initQuadrangleAngles( root ) {
var wrapper = document.createElement( 'div' );
wrapper.className = 'quadrangle-angles';
root.parentNode.replaceChild( wrapper, root );
// ── Build SVG ─────────────────────────────────────────────────────────
var svgEl = document.createElementNS( SVG_NS, 'svg' );
svgEl.setAttribute( 'viewBox', '0 0 ' + SVG_W + ' ' + SVG_H );
svgEl.setAttribute( 'width', SVG_W );
svgEl.classList.add( 'qa-svg' );
// SVG background rect — background-color-base: #ffffff light / #101418 dark.
// Makes polygon stroke visible in dark mode and avoids white flash from CSS.
var bgRect = document.createElementNS( SVG_NS, 'rect' );
bgRect.setAttribute( 'width', '100%' );
bgRect.setAttribute( 'height', '100%' );
bgRect.setAttribute( 'fill', hiruwiki.getThemeColor('background-color-base', '#ffffff') );
svgEl.appendChild( bgRect );
var defs = document.createElementNS( SVG_NS, 'defs' );
var pat = document.createElementNS( SVG_NS, 'pattern' );
pat.setAttribute( 'id', 'qa-grid' );
pat.setAttribute( 'width', '20' );
pat.setAttribute( 'height', '20' );
pat.setAttribute( 'patternUnits', 'userSpaceOnUse' );
var gridPath = document.createElementNS( SVG_NS, 'path' );
gridPath.setAttribute( 'd', 'M 20 0 L 0 0 0 20' );
gridPath.setAttribute( 'fill', 'none' );
// Grid lines: border-color-subtle (very faint) — #c8ccd1 light / #2e3136 dark
gridPath.setAttribute( 'stroke', hiruwiki.getThemeColor('border-color-subtle', '#c8ccd1') );
gridPath.setAttribute( 'stroke-width', '0.5' );
pat.appendChild( gridPath );
defs.appendChild( pat );
svgEl.appendChild( defs );
var gridRect = document.createElementNS( SVG_NS, 'rect' );
gridRect.setAttribute( 'width', '100%' );
gridRect.setAttribute( 'height', '100%' );
gridRect.setAttribute( 'fill', 'url(#qa-grid)' );
svgEl.appendChild( gridRect );
// Quadrangle outline — color-base: #202122 light / ~#f8f9fa dark
var quadEl = document.createElementNS( SVG_NS, 'polygon' );
quadEl.setAttribute( 'fill', 'none' );
quadEl.setAttribute( 'stroke', hiruwiki.getThemeColor('color-base', '#202122') );
quadEl.setAttribute( 'stroke-width', '2' );
svgEl.appendChild( quadEl );
var arcs = {}, lbls = {}, circs = {};
KEYS.forEach( function ( k ) {
var arc = document.createElementNS( SVG_NS, 'path' );
arc.setAttribute( 'fill', COLORS[k] );
arc.setAttribute( 'opacity', '0.4' );
svgEl.appendChild( arc );
arcs[k] = arc;
var lbl = document.createElementNS( SVG_NS, 'text' );
lbl.setAttribute( 'fill', COLORS[k] );
lbl.setAttribute( 'font-weight', 'bold' );
lbl.setAttribute( 'font-size', '16' );
lbl.setAttribute( 'font-family', 'Arial, sans-serif' );
lbl.setAttribute( 'pointer-events', 'none' );
svgEl.appendChild( lbl );
lbls[k] = lbl;
var circ = document.createElementNS( SVG_NS, 'circle' );
circ.setAttribute( 'r', '8' );
circ.setAttribute( 'fill', COLORS[k] );
// Drag handle ring — background-color-base so it punches through polygon fill
circ.setAttribute( 'stroke', hiruwiki.getThemeColor('background-color-base', '#ffffff') );
circ.setAttribute( 'stroke-width', '2' );
circ.style.cursor = 'pointer';
svgEl.appendChild( circ );
circs[k] = circ;
} );
// ── Build panel ───────────────────────────────────────────────────────
var panel = document.createElement( 'div' );
panel.className = 'qa-panel';
var h3 = document.createElement( 'h3' );
h3.textContent = t('interiorAngles');
panel.appendChild( h3 );
var valEls = {}, barEls = {};
KEYS.forEach( function ( k ) {
var row = document.createElement( 'div' );
row.className = 'qa-angle-row';
var dot = document.createElement( 'div' );
dot.className = 'qa-dot';
dot.style.background = COLORS[k];
var letter = document.createElement( 'div' );
letter.className = 'qa-letter';
letter.style.color = COLORS[k];
letter.textContent = k;
var barWrap = document.createElement( 'div' );
barWrap.className = 'qa-bar-wrap';
var bar = document.createElement( 'div' );
bar.className = 'qa-bar';
bar.style.background = COLORS[k];
barWrap.appendChild( bar );
barEls[k] = bar;
var val = document.createElement( 'div' );
val.className = 'qa-val';
val.style.color = COLORS[k];
val.textContent = '\u2014';
valEls[k] = val;
row.appendChild( dot );
row.appendChild( letter );
row.appendChild( barWrap );
row.appendChild( val );
panel.appendChild( row );
} );
var sumSection = document.createElement( 'div' );
sumSection.className = 'qa-sum-section';
var pieLabel = document.createElement( 'div' );
pieLabel.className = 'qa-pie-label';
pieLabel.textContent = t('sum');
sumSection.appendChild( pieLabel );
var pieSVG = document.createElementNS( SVG_NS, 'svg' );
pieSVG.setAttribute( 'width', '100' );
pieSVG.setAttribute( 'height', '100' );
pieSVG.setAttribute( 'viewBox', '-50 -50 100 100' );
sumSection.appendChild( pieSVG );
var eq = document.createElement( 'div' );
eq.className = 'qa-equation';
eq.innerHTML = '<span style="color:red">A</span> + ' +
'<span style="color:green">B</span> + ' +
'<span style="color:blue">C</span> + ' +
'<span style="color:orange">D</span> = 360\u00b0';
sumSection.appendChild( eq );
panel.appendChild( sumSection );
var totalRow = document.createElement( 'div' );
totalRow.className = 'qa-total-row';
var totalLabel = document.createElement( 'span' );
totalLabel.className = 'qa-total-label';
totalLabel.textContent = t('total');
var totalVal = document.createElement( 'span' );
totalVal.className = 'qa-total-val';
totalVal.textContent = '360.0\u00b0';
totalRow.appendChild( totalLabel );
totalRow.appendChild( totalVal );
panel.appendChild( totalRow );
var resetBtn = document.createElement( 'button' );
resetBtn.className = 'qa-reset-btn';
resetBtn.textContent = '\u21ba ' + t('reset');
panel.appendChild( resetBtn );
wrapper.appendChild( svgEl );
wrapper.appendChild( panel );
// ── State ─────────────────────────────────────────────────────────────
var s = 150, cx = 270, cy = 240;
var initPos = {
A: { x: cx - s, y: cy - s },
B: { x: cx + s, y: cy - s },
C: { x: cx + s, y: cy + s },
D: { x: cx - s, y: cy + s }
};
var pos = { A: {}, B: {}, C: {}, D: {} };
var dragKey = null;
function applyPos() {
KEYS.forEach( function ( k ) {
circs[k].cx.baseVal.value = pos[k].x;
circs[k].cy.baseVal.value = pos[k].y;
} );
}
function resetPos( p ) {
KEYS.forEach( function ( k ) { pos[k] = { x: p[k].x, y: p[k].y }; } );
applyPos();
}
resetPos( initPos );
// ── Geometry ──────────────────────────────────────────────────────────
function dist( a, b ) { return Math.hypot( a.x - b.x, a.y - b.y ); }
function segmentsIntersect( p1, p2, p3, p4 ) {
var d1x = p2.x-p1.x, d1y = p2.y-p1.y, d2x = p4.x-p3.x, d2y = p4.y-p3.y;
var den = d1x*d2y - d1y*d2x;
if ( Math.abs( den ) < 1e-10 ) return false;
var t = ( (p3.x-p1.x)*d2y - (p3.y-p1.y)*d2x ) / den;
var u = ( (p3.x-p1.x)*d1y - (p3.y-p1.y)*d1x ) / den;
return t > 0 && t < 1 && u > 0 && u < 1;
}
function isSelfIntersecting( p ) {
return segmentsIntersect( p.A, p.B, p.C, p.D ) ||
segmentsIntersect( p.B, p.C, p.D, p.A );
}
function signedArea( p ) {
var a = 0;
for ( var i = 0; i < 4; i++ ) {
var pi = p[KEYS[i]], pj = p[KEYS[(i+1)%4]];
a += pi.x*pj.y - pj.x*pi.y;
}
return a / 2;
}
function interiorAngle( V, P, N, area ) {
var a = dist(P,N), b = dist(V,N), c = dist(V,P);
var base = Math.acos( Math.max(-1, Math.min(1, (b*b+c*c-a*a)/(2*b*c))) ) * 180/Math.PI;
var cross = (P.x-V.x)*(N.y-V.y) - (P.y-V.y)*(N.x-V.x);
return ( area > 0 ? cross > 0 : cross < 0 ) ? 360 - base : base;
}
function drawAngleArc( center, p1, p2, radius, isReflex ) {
var v1x = p1.x-center.x, v1y = p1.y-center.y;
var v2x = p2.x-center.x, v2y = p2.y-center.y;
var a1 = Math.atan2(v1y,v1x), a2 = Math.atan2(v2y,v2x);
var start = a1, end = a2, cross = v1x*v2y - v1y*v2x, tmp;
if ( isReflex ? cross > 0 : cross < 0 ) { tmp=start; start=end; end=tmp; }
if ( end < start ) end += 2*Math.PI;
var large = (end-start) > Math.PI ? 1 : 0;
var x1 = center.x+radius*Math.cos(start), y1 = center.y+radius*Math.sin(start);
var x2 = center.x+radius*Math.cos(end), y2 = center.y+radius*Math.sin(end);
return 'M'+center.x+','+center.y+' L'+x1+','+y1+
' A'+radius+','+radius+' 0 '+large+' 1 '+x2+','+y2+' Z';
}
function updatePie( angles ) {
while ( pieSVG.firstChild ) pieSVG.removeChild( pieSVG.firstChild );
var r = 44, start = -Math.PI/2;
KEYS.forEach( function ( k ) {
var span = angles[k]*Math.PI/180, end = start+span, large = span > Math.PI ? 1 : 0;
var x1 = r*Math.cos(start), y1 = r*Math.sin(start);
var x2 = r*Math.cos(end), y2 = r*Math.sin(end);
var path = document.createElementNS( SVG_NS, 'path' );
path.setAttribute( 'd', 'M0,0 L'+x1+','+y1+' A'+r+','+r+' 0 '+large+' 1 '+x2+','+y2+' Z' );
path.setAttribute( 'fill', COLORS[k] );
path.setAttribute( 'opacity', '0.6' );
pieSVG.appendChild( path );
start = end;
} );
// Pie centre hole — background-color-base: #ffffff light / #101418 dark
var c = document.createElementNS( SVG_NS, 'circle' );
c.setAttribute( 'r', '14' );
c.setAttribute( 'fill', hiruwiki.getThemeColor('background-color-base', '#ffffff') );
pieSVG.appendChild( c );
// "360°" label — color-subtle: #54595d light / ~#a2a9b1 dark
var tEl = document.createElementNS( SVG_NS, 'text' );
tEl.setAttribute( 'text-anchor', 'middle' );
tEl.setAttribute( 'dominant-baseline', 'central' );
tEl.setAttribute( 'font-family', 'Arial, sans-serif' );
tEl.setAttribute( 'font-size', '8' );
tEl.setAttribute( 'font-weight', 'bold' );
tEl.setAttribute( 'fill', hiruwiki.getThemeColor('color-subtle', '#54595d') );
tEl.textContent = '360\u00b0';
pieSVG.appendChild( tEl );
}
function updateQuad() {
var p = {
A: { x: circs.A.cx.baseVal.value, y: circs.A.cy.baseVal.value },
B: { x: circs.B.cx.baseVal.value, y: circs.B.cy.baseVal.value },
C: { x: circs.C.cx.baseVal.value, y: circs.C.cy.baseVal.value },
D: { x: circs.D.cx.baseVal.value, y: circs.D.cy.baseVal.value }
};
quadEl.setAttribute( 'points',
p.A.x+','+p.A.y+' '+p.B.x+','+p.B.y+' '+p.C.x+','+p.C.y+' '+p.D.x+','+p.D.y );
var area = signedArea( p );
var verts = {
A: { V:p.A, P:p.D, N:p.B }, B: { V:p.B, P:p.A, N:p.C },
C: { V:p.C, P:p.B, N:p.D }, D: { V:p.D, P:p.C, N:p.A }
};
var angles = {}, isReflex = {};
KEYS.forEach( function ( k ) {
var V=verts[k].V, P=verts[k].P, N=verts[k].N;
var cross = (P.x-V.x)*(N.y-V.y) - (P.y-V.y)*(N.x-V.x);
isReflex[k] = area > 0 ? cross > 0 : cross < 0;
angles[k] = interiorAngle( V, P, N, area );
} );
KEYS.forEach( function ( k ) {
arcs[k].setAttribute( 'd', drawAngleArc( verts[k].V, verts[k].P, verts[k].N, 40, isReflex[k] ) );
} );
var centX = (p.A.x+p.B.x+p.C.x+p.D.x)/4;
var centY = (p.A.y+p.B.y+p.C.y+p.D.y)/4;
KEYS.forEach( function ( k ) {
var V=verts[k].V, P=verts[k].P, N=verts[k].N;
var u1x=P.x-V.x, u1y=P.y-V.y, u2x=N.x-V.x, u2y=N.y-V.y;
var l1=Math.hypot(u1x,u1y), l2=Math.hypot(u2x,u2y);
var bx=u1x/l1+u2x/l2, by=u1y/l1+u2y/l2, bm=Math.hypot(bx,by);
if ( bm < 1e-6 ) { bx=centX-V.x; by=centY-V.y; }
else { bx/=bm; by/=bm; }
if ( bx*(centX-V.x)+by*(centY-V.y) < 0 ) { bx=-bx; by=-by; }
lbls[k].setAttribute( 'x', V.x+bx*60-22 );
lbls[k].setAttribute( 'y', V.y+by*60+6 );
lbls[k].textContent = angles[k].toFixed(1)+'\u00b0';
} );
var sum = angles.A+angles.B+angles.C+angles.D;
KEYS.forEach( function ( k ) {
valEls[k].textContent = angles[k].toFixed(1)+'\u00b0';
barEls[k].style.width = (angles[k]/360*100)+'%';
} );
totalVal.textContent = sum.toFixed(1)+'\u00b0';
updatePie( angles );
}
// ── Drag (mouse + touch) ──────────────────────────────────────────────
function tryMove(nx,ny) {
var cand = { A:{x:pos.A.x,y:pos.A.y}, B:{x:pos.B.x,y:pos.B.y},
C:{x:pos.C.x,y:pos.C.y}, D:{x:pos.D.x,y:pos.D.y} };
cand[dragKey] = { x:nx, y:ny };
if ( !isSelfIntersecting(cand) ) { pos[dragKey]={x:nx,y:ny}; applyPos(); updateQuad(); }
}
function svgPoint(clientX, clientY) {
var pt = svgEl.createSVGPoint();
pt.x = clientX;
pt.y = clientY;
return pt.matrixTransform(svgEl.getScreenCTM().inverse());
}
KEYS.forEach( function ( k ) {
circs[k].addEventListener( 'mousedown', function ( e ) { dragKey=k; e.preventDefault(); } );
} );
svgEl.addEventListener( 'mousemove', function ( e ) {
if ( !dragKey ) return;
var pt=svgPoint(e.clientX,e.clientY); tryMove(pt.x,pt.y);
} );
svgEl.addEventListener( 'mouseup', function () { dragKey=null; } );
svgEl.addEventListener( 'mouseleave', function () { dragKey=null; } );
// Touch
svgEl.addEventListener('touchstart', function(e) {
e.preventDefault();
var pt = svgPoint(e.touches[0].clientX, e.touches[0].clientY);
var best = null, bestDist = 40;
KEYS.forEach(function(k) {
var d = Math.hypot(pos[k].x - pt.x, pos[k].y - pt.y);
if (d < bestDist) { bestDist = d; best = k; }
});
if (best) dragKey = best;
}, {passive: false});
svgEl.addEventListener('touchmove', function(e) {
if (!dragKey) return;
e.preventDefault();
var pt = svgPoint(e.touches[0].clientX, e.touches[0].clientY);
tryMove(pt.x, pt.y);
}, {passive: false});
svgEl.addEventListener('touchend', function() { dragKey = null; });
svgEl.addEventListener('touchcancel', function() { dragKey = null; });
resetBtn.addEventListener( 'click', function () { resetPos(initPos); updateQuad(); } );
updateQuad();
// Footer branding
var footer = document.createElement("div");
footer.className = "hw-footer";
var fLogo = document.createElement("a");
fLogo.className = "hw-footer-icon";
fLogo.href = mw.util.getUrl('Wikipedia:Hiruwiki');
fLogo.title = 'Hiruwiki';
fLogo.innerHTML = hiruwiki.getLogoSvg(22);
var fText = document.createElement("span");
fText.innerHTML = t('hint');
footer.appendChild(fLogo);
footer.appendChild(fText);
wrapper.appendChild(footer);
}
document.querySelectorAll( '.hiruwiki[data-module="quadrangle-angles"]' ).forEach( function ( el ) {
initQuadrangleAngles( el );
} );
}() );
b6htqlacsxn5krddwz48qvm9tjst7c7
MediaWiki:Hiruwiki/modules/quadrangles.js
8
179519
781442
770246
2026-10-28T10:40:02Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781442
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
(function () {
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"nl": {
"_name": "Vierhoeken",
"areaDiagHalf": "Formule: A = (d₁ × d₂) / 2 d₁ (AC) = $1 cm d₂ (BD) = $2 cm = ($1 × $2) / 2",
"areaGaussHeader": "Gauss formule (schoenvetermethode): A = ½ |Σ(xᵢ·yᵢ₊₁ − xᵢ₊₁·yᵢ)|",
"areaGaussSum": "Σ = $1\nA = ½ × |$1|",
"areaGaussTerm": "(x$1·y$2 − x$2·y$1) = $3",
"areaParallelogram": "Formule: A = basis × hoogte basis (AB) = $1 cm hoogte = $2 cm = $1 × $2",
"areaRectangle": "Formule: A = breedte × hoogte = $1 × $2",
"areaSquare": "Formule: A = zijde² = $1²",
"areaTrapezoid": "Formule: A = (b₁ + b₂) / 2 × h b₁ (AB) = $1 cm b₂ (CD) = $2 cm hoogte = $3 cm = ($1 + $2) / 2 × $3",
"headingArea": "Oppervlakte",
"headingClassification": "Classificatie",
"headingSides": "Zijden en diagonalen",
"labelDiag1": "d₁ (AC)",
"labelDiag2": "d₂ (BD)",
"labelPerimeter": "Omtrek",
"presetKite": "Vlieger",
"presetParallelogram": "Parallellogram",
"presetRectangle": "Rechthoek",
"presetSquare": "Vierkant",
"presetTrapezoid": "Trapezium",
"shapeCrossed": "gekruiste vierhoek",
"shapeGeneral": "algemene vierhoek",
"shapeKite": "vlieger",
"shapeParallelogram": "parallellogram",
"shapeRectangle": "rechthoek",
"shapeRhombus": "ruit",
"shapeSquare": "vierkant",
"shapeTrapezoid": "trapezium",
"unitCm": "cm",
"unitCm2": "cm²",
"hint": "Sleep de hoekpunten om de vierhoek te classificeren"
},
"fr": {
"_name": "Quadrilatères",
"areaDiagHalf": "Formule : A = (d₁ × d₂) / 2\nd₁ (AC) = $1 cm\nd₂ (BD) = $2 cm\n= ($1 × $2) / 2",
"areaGaussHeader": "Formule de Gauss (shoelace) :\nA = ½ |Σ(xᵢ·yᵢ₊₁ − xᵢ₊₁·yᵢ)|",
"areaGaussSum": "Σ = $1\nA = ½ × |$1|",
"areaGaussTerm": "(x$1·y$2 − x$2·y$1) = $3",
"areaParallelogram": "Formule : A = base × hauteur\nbase (AB) = $1 cm\nhauteur = $2 cm\n= $1 × $2",
"areaRectangle": "Formule : A = largeur × hauteur\n= $1 × $2",
"areaSquare": "Formule : A = côté²\n= $1²",
"areaTrapezoid": "Formule : A = (b₁ + b₂) / 2 × h\nb₁ (AB) = $1 cm\nb₂ (CD) = $2 cm\nhauteur = $3 cm\n= ($1 + $2) / 2 × $3",
"headingArea": "Aire",
"headingClassification": "Classification",
"headingSides": "Côtés et diagonales",
"labelDiag1": "d₁ (AC)",
"labelDiag2": "d₂ (BD)",
"labelPerimeter": "Périmètre",
"presetKite": "Cerf-volant",
"presetParallelogram": "Parallélogramme",
"presetRectangle": "Rectangle",
"presetSquare": "Carré",
"presetTrapezoid": "Trapèze",
"shapeCrossed": "quadrilatère croisé",
"shapeGeneral": "quadrilatère quelconque",
"shapeKite": "cerf-volant",
"shapeParallelogram": "parallélogramme",
"shapeRectangle": "rectangle",
"shapeRhombus": "losange",
"shapeSquare": "carré",
"shapeTrapezoid": "trapèze",
"unitCm": "cm",
"unitCm2": "cm²",
"hint": "Faites glisser les sommets pour classer le quadrilatère"
},
"tr": {
"_name": "Dörtgenler",
"areaDiagHalf": "Formül: A = (d₁ × d₂) / 2 d₁ (AC) = $1 cm d₂ (BD) = $2 cm = ($1 × $2) / 2",
"areaGaussHeader": "Gauss formülü (ayakkabı bağı): A = ½ |Σ(xᵢ·yᵢ₊₁ − xᵢ₊₁·yᵢ)|",
"areaGaussSum": "Σ = $1 A = ½ × |$1|",
"areaGaussTerm": "(x$1·y$2 − x$2·y$1) = $3",
"areaParallelogram": "Formül: A = taban × taban yüksekliği (AB) = $1 cm yükseklik = $2 cm = $1 × $2",
"areaRectangle": "Formül: A = genişlik × yükseklik = $1 × $2",
"areaSquare": "Formül: A = kenar² = $1²",
"areaTrapezoid": "Formül: A = (b₁ + b₂) / 2 × h b₁ (AB) = $1 cm b₂ (CD) = $2 cm yükseklik = $3 cm = ($1 + $2) / 2 × $3",
"headingArea": "Alan",
"headingClassification": "Sınıflandırma",
"headingSides": "Kenarlar ve köşegenler",
"labelDiag1": "d₁ (AC)",
"labelDiag2": "d₂ (BD)",
"labelPerimeter": "Çevre",
"presetKite": "Uçurtma",
"presetParallelogram": "Paralelkenar",
"presetRectangle": "Dikdörtgen",
"presetSquare": "Kare",
"presetTrapezoid": "Yamuk",
"shapeCrossed": "çapraz dörtgen",
"shapeGeneral": "genel dörtgen",
"shapeKite": "uçurtma",
"shapeParallelogram": "paralelkenar",
"shapeRectangle": "dikdörtgen",
"shapeRhombus": "eşkenar dörtgen",
"shapeSquare": "kare",
"shapeTrapezoid": "yamuk",
"unitCm": "cm",
"unitCm2": "cm²",
"hint": "Dörtgeni sınıflandırmak için köşeleri sürükleyin."
},
"eu": {
"_name": "Koadrilateroak",
"areaDiagHalf": "Formula: A = (d₁ × d₂) / 2\nd₁ (AC) = $1 cm\nd₂ (BD) = $2 cm\n= ($1 × $2) / 2",
"areaGaussHeader": "Gaussen formula:\nA = ½ |Σ(xᵢ·yᵢ₊₁ − xᵢ₊₁·yᵢ)|",
"areaGaussSum": "Σ = $1 A = ½ × |$1|",
"areaGaussTerm": "(x$1·y$2 − x$2·y$1) = $3",
"areaParallelogram": "Formula: A = oinarria × altuera\noinarria (AB) = $1 cm\naltuera = $2 cm\n= $1 × $2",
"areaRectangle": "Formula: A = zabalera × altuera\n= $1 × $2",
"areaSquare": "Formula: A = aldea²\n= $1²",
"areaTrapezoid": "Formula: A = (b₁ + b₂) / 2 × h\nb₁ (AB) = $1 cm\nb₂ (CD) = $2 cm\naltuera = $3 cm\n= ($1 + $2) / 2 × $3",
"headingArea": "Azalera",
"headingClassification": "Sailkapena",
"headingSides": "Aldeak eta diagonalak",
"labelDiag1": "d₁ (AC)",
"labelDiag2": "d₂ (BD)",
"labelPerimeter": "Perimetroa",
"presetKite": "Kometa",
"presetParallelogram": "Paralelogramoa",
"presetRectangle": "Laukizuzena",
"presetSquare": "Karratua",
"presetTrapezoid": "Trapezioa",
"shapeCrossed": "konplexua",
"shapeGeneral": "trapezoidea",
"shapeKite": "kometa",
"shapeParallelogram": "paralelogramoa",
"shapeRectangle": "laukizuzena",
"shapeRhombus": "erronboa",
"shapeSquare": "karratua",
"shapeTrapezoid": "trapezioa",
"unitCm": "cm",
"unitCm2": "cm²",
"hint": "Arrastatu erpinak laukizuzena sailkatzeko"
},
"sk": {
"_name": "Štvoruholníky",
"areaDiagHalf": "Vzorec: A = (d₁ × d₂) / 2 d₁ (AC) = $1 cm d₂ (BD) = $2 cm = ($1 × $2) / 2",
"areaGaussHeader": "Gaussov vzorec (šnurovací): A = ½ |Σ(xᵢ·yᵢ₊₁ − xᵢ₊₁·yᵢ)|",
"areaGaussSum": "Σ = $1 A = ½ × |$1|",
"areaGaussTerm": "(x$1·y$2 − x$2·y$1) = $3",
"areaParallelogram": "Vzorec: A = základňa × výška základňa (AB) = $1 cm výška = $2 cm = $1 × $2",
"areaRectangle": "Vzorec: A = šírka × výška = $1 × $2",
"areaSquare": "Vzorec: A = strana² = $1²",
"areaTrapezoid": "Vzorec: A = (b₁ + b₂) / 2 × h b₁ (AB) = $1 cm b₂ (CD) = $2 cm výška = $3 cm = ($1 + $2) / 2 × $3",
"headingArea": "Obsah",
"headingClassification": "Klasifikácia",
"headingSides": "Strany a uhlopriečky",
"labelDiag1": "d₁ (AC)",
"labelDiag2": "d₂ (BD)",
"labelPerimeter": "Obvod",
"presetKite": "Deltoid",
"presetParallelogram": "Rovnobežník",
"presetRectangle": "Obdĺžnik",
"presetSquare": "Štvorec",
"presetTrapezoid": "Lichobežník",
"shapeCrossed": "skrížený štvoruholník",
"shapeGeneral": "všeobecný štvoruholník",
"shapeKite": "deltoid",
"shapeParallelogram": "rovnobežník",
"shapeRectangle": "obdĺžnik",
"shapeRhombus": "kosoštvorec",
"shapeSquare": "štvorec",
"shapeTrapezoid": "lichobežník",
"unitCm": "cm",
"unitCm2": "cm²",
"hint": "Potiahnutím vrcholov klasifikujete štvoruholník"
},
"skr-arab": {
"_name": "چوکوراں",
"areaDiagHalf": "فارمولا: A = (d₁ × d₂) / 2 d₁ (AC) = $1 cm d₂ (BD) = $2 cm = ($1 × $2) / 2",
"areaGaussHeader": "گاؤس فارمولا (شو لیس): A = ½ |Σ(xᵢ·yᵢ₊₁ − xᵢ₊₁·yᵢ)|",
"areaGaussSum": "Σ = $1 A = ½ × |$1|",
"areaGaussTerm": "(x$1·y$2 − x$2·y$1) = $3",
"areaParallelogram": "فارمولا: A = قاعدہ × اُچائی قاعدہ (AB) = $1 cm اُچائی = $2 cm = $1 × $2",
"areaRectangle": "فارمولا: A = چوڑائی × اُچائی = $1 × $2",
"areaSquare": "فارمولا: A = ضلع² = $1²",
"areaTrapezoid": "فارمولا: A = (b₁ + b₂) / 2 × h b₁ (AB) = $1 cm b₂ (CD) = $2 cm اُچائی = $3 cm = ($1 + $2) / 2 × $3",
"headingArea": "رقبہ",
"headingClassification": "درجہ بندی",
"headingSides": "ضلعے تے قطر",
"labelDiag1": "d₁ (AC)",
"labelDiag2": "d₂ (BD)",
"labelPerimeter": "گھیر",
"presetKite": "پتنگ",
"presetParallelogram": "متوازی الاضلاع",
"presetRectangle": "مستطیل",
"presetSquare": "مربع",
"presetTrapezoid": "منحرف",
"shapeCrossed": "کراس چوکور",
"shapeGeneral": "عام چوکور",
"shapeKite": "پتنگ",
"shapeParallelogram": "متوازی الاضلاع",
"shapeRectangle": "مستطیل",
"shapeRhombus": "معین",
"shapeSquare": "مربع",
"shapeTrapezoid": "منحرف",
"unitCm": "سم",
"unitCm2": "سم²",
"hint": "چوکور دی درجہ بندی کیتے سر گھسیٹو"
},
"lb": {
"areaRectangle": "Formel: A = Breet × Héicht = $1 × $2",
"areaSquare": "Formel: A = Säit² = $1²",
"headingArea": "Fläch",
"headingClassification": "Klassifikatioun",
"presetRectangle": "Rechteck",
"presetSquare": "Quadrat",
"shapeRectangle": "Rechteck",
"unitCm": "cm",
"unitCm2": "cm²"
},
"ko": {
"_name": "사각형",
"areaDiagHalf": "공식: A = (d₁ × d₂) / 2\nd₁ (AC) = $1 cm\nd₂ (BD) = $2 cm\n= ($1 × $2) / 2",
"areaGaussHeader": "가우스 공식 (신발끈): A = ½ |Σ(xᵢ·yᵢ₊₁ − xᵢ₊₁·yᵢ)|",
"areaGaussSum": "Σ = $1 A = ½ × |$1|",
"areaGaussTerm": "(x$1·y$2 − x$2·y$1) = $3",
"areaParallelogram": "공식: A = 밑변 × 높이 밑변 (AB) = $1 cm 높이 = $2 cm = $1 × $2",
"areaRectangle": "공식: A = 너비 × 높이\n= $1 × $2",
"areaSquare": "공식: A = 한 변² = $1²",
"areaTrapezoid": "공식: A = (b₁ + b₂) / 2 × h\nb₁ (AB) = $1 cm\nb₂ (CD) = $2 cm\n높이 = $3 cm\n= ($1 + $2) / 2 × $3",
"headingArea": "면적",
"headingClassification": "분류",
"headingSides": "변과 대각선",
"labelDiag1": "d₁ (AC)",
"labelDiag2": "d₂ (BD)",
"labelPerimeter": "둘레",
"presetKite": "연꼴",
"presetParallelogram": "평행사변형",
"presetRectangle": "직사각형",
"presetSquare": "정사각형",
"presetTrapezoid": "사다리꼴",
"shapeCrossed": "교차 사각형",
"shapeGeneral": "일반 사각형",
"shapeKite": "연꼴",
"shapeParallelogram": "평행사변형",
"shapeRectangle": "직사각형",
"shapeRhombus": "마름모",
"shapeSquare": "정사각형",
"shapeTrapezoid": "사다리꼴",
"unitCm": "cm",
"unitCm2": "cm²",
"hint": "꼭짓점을 드래그하여 사각형을 분류하세요."
},
"en": {
"_name": "Quadrilaterals",
"areaDiagHalf": "Formula: A = (d₁ × d₂) / 2 d₁ (AC) = $1 cm d₂ (BD) = $2 cm = ($1 × $2) / 2",
"areaGaussHeader": "Gauss formula (shoelace): A = ½ |Σ(xᵢ·yᵢ₊₁ − xᵢ₊₁·yᵢ)|",
"areaGaussSum": "Σ = $1 A = ½ × |$1|",
"areaGaussTerm": "(x$1·y$2 − x$2·y$1) = $3",
"areaParallelogram": "Formula: A = base × height base (AB) = $1 cm height = $2 cm = $1 × $2",
"areaRectangle": "Formula: A = width × height = $1 × $2",
"areaSquare": "Formula: A = side² = $1²",
"areaTrapezoid": "Formula: A = (b₁ + b₂) / 2 × h b₁ (AB) = $1 cm b₂ (CD) = $2 cm height = $3 cm = ($1 + $2) / 2 × $3",
"headingArea": "Area",
"headingClassification": "Classification",
"headingSides": "Sides and diagonals",
"labelDiag1": "d₁ (AC)",
"labelDiag2": "d₂ (BD)",
"labelPerimeter": "Perimeter",
"presetKite": "Kite",
"presetParallelogram": "Parallelogram",
"presetRectangle": "Rectangle",
"presetSquare": "Square",
"presetTrapezoid": "Trapezoid",
"shapeCrossed": "crossed quadrilateral",
"shapeGeneral": "general quadrilateral",
"shapeKite": "kite",
"shapeParallelogram": "parallelogram",
"shapeRectangle": "rectangle",
"shapeRhombus": "rhombus",
"shapeSquare": "square",
"shapeTrapezoid": "trapezoid",
"unitCm": "cm",
"unitCm2": "cm²",
"hint": "Drag vertices to classify the quadrilateral"
},
"it": {
"headingSides": "Lati e diagonali",
"presetRectangle": "Rettangolo",
"shapeRectangle": "rettangolo"
},
"ca": {
"_name": "Quadrilàters",
"areaDiagHalf": "Fórmula: A = (d₁ × d₂) / 2\nd₁ (AC) = $1 cm\nd₂ (BD) = $2 cm\n= ($1 × $2) / 2",
"areaGaussHeader": "Fórmula de Gauss:\nA = ½ |Σ(xᵢ·yᵢ₊₁ − xᵢ₊₁·yᵢ)|",
"areaGaussSum": "Σ = $1\nA = ½ × |$1|",
"areaGaussTerm": "(x$1·y$2 − x$2·y$1) = $3",
"headingArea": "Àrea",
"labelDiag1": "d₁ (AC)",
"labelDiag2": "d₂ (BD)",
"labelPerimeter": "Perímetre",
"presetParallelogram": "Paral·lelogram",
"presetRectangle": "Rectangle",
"presetSquare": "Quadrat",
"presetTrapezoid": "Trapezoide",
"shapeParallelogram": "paral·lelogram",
"shapeRectangle": "rectangle",
"shapeRhombus": "rombe",
"shapeSquare": "quadrat",
"shapeTrapezoid": "trapezoide",
"unitCm": "cm",
"unitCm2": "cm²"
},
"qqq": {
"_name": "Name of the Quadrilaterals module",
"areaDiagHalf": "Area formula for a kite using diagonals. Parameters: $1 = diagonal d₁ length, $2 = diagonal d₂ length.",
"areaGaussHeader": "Header text for the Gauss (shoelace) formula section",
"areaGaussSum": "Sum line of the Gauss formula. Parameters: $1 = the computed sum Σ.",
"areaGaussTerm": "Individual term of the Gauss formula. Parameters: $1 = first vertex index, $2 = second vertex index, $3 = computed term value.",
"areaParallelogram": "Area formula for a parallelogram. Parameters: $1 = base length, $2 = height.",
"areaRectangle": "Area formula for a rectangle. Parameters: $1 = width, $2 = height.",
"areaSquare": "Area formula for a square. Parameters: $1 = side length.",
"areaTrapezoid": "Area formula for a trapezoid. Parameters: $1 = base b₁ length, $2 = base b₂ length, $3 = height.",
"headingArea": "Section heading for the area calculation",
"headingClassification": "Section heading for the shape classification",
"headingSides": "Section heading for sides and diagonals measurements",
"labelDiag1": "Label for diagonal d₁ (AC)",
"labelDiag2": "Label for diagonal d₂ (BD)",
"labelPerimeter": "Label for the perimeter value",
"presetKite": "Preset button label for a kite shape",
"presetParallelogram": "Preset button label for a parallelogram shape",
"presetRectangle": "Preset button label for a rectangle shape",
"presetSquare": "Preset button label for a square shape",
"presetTrapezoid": "Preset button label for a trapezoid shape",
"shapeCrossed": "Classification name for a crossed (self-intersecting) quadrilateral",
"shapeGeneral": "Classification name for a general quadrilateral",
"shapeKite": "Classification name for a kite",
"shapeParallelogram": "Classification name for a parallelogram",
"shapeRectangle": "Classification name for a rectangle",
"shapeRhombus": "Classification name for a rhombus",
"shapeSquare": "Classification name for a square",
"shapeTrapezoid": "Classification name for a trapezoid",
"unitCm": "Unit abbreviation for centimetres",
"unitCm2": "Unit abbreviation for square centimetres",
"hint": "Instruction text for the quadrilateral classification applet"
},
"ga": {
"_name": "Ceathairshleasáin",
"areaDiagHalf": "Foirmle: A = (d₁ × d₂) / 2 d₁ (AC) = $1 cm d₂ (BD) = $2 cm = ($1 × $2) / 2",
"areaGaussHeader": "Foirmle Gauss (lása bróg): A = ½ |Σ(xᵢ·yᵢ₊₁ − xᵢ₊₁·yᵢ)|",
"areaGaussSum": "Σ = $1 A = ½ × |$1|",
"areaGaussTerm": "(x$1·y$2 − x$2·y$1) = $3",
"areaParallelogram": "Foirmle: A = bonn × airde bonn (AB) = $1 cm airde = $2 cm = $1 × $2",
"areaRectangle": "Foirmle: A = leithead × airde = $1 × $2",
"areaSquare": "Foirmle: A = taobh² = $1²",
"areaTrapezoid": "Foirmle: A = (b₁ + b₂) / 2 × h\nb₁ (AB) = $1 cm\nb₂ (CD) = $2 cm\nairde = $3 cm\n= ($1 + $2) / 2 × $3",
"headingArea": "Limistéar",
"headingClassification": "Aicmiú",
"headingSides": "Taobhanna agus trasnáin",
"labelDiag1": "d₁ (AC)",
"labelDiag2": "d₂ (BD)",
"labelPerimeter": "Imlíne",
"presetKite": "Eitleog",
"presetParallelogram": "Paraileagram",
"presetRectangle": "Dronuilleog",
"presetSquare": "Cearnóg",
"presetTrapezoid": "Traipéisóideach",
"shapeCrossed": "ceathairshleasán trasnaithe",
"shapeGeneral": "ceathairshleasán ginearálta",
"shapeKite": "eitleog",
"shapeParallelogram": "comhthreomharán",
"shapeRectangle": "dronuilleog",
"shapeRhombus": "rombas",
"shapeSquare": "cearnóg",
"shapeTrapezoid": "traipéisóideach",
"unitCm": "cm",
"unitCm2": "cm²",
"hint": "Tarraing buaicphointí chun an ceathairshleasán a aicmiú"
},
"es": {
"_name": "Cuadriláteros",
"areaDiagHalf": "Fórmula: A = (d₁ × d₂) / 2 d₁ (AC) = $1 cm d₂ (BD) = $2 cm = ($1 × $2) / 2",
"areaGaussHeader": "Fórmula de Gauss: A = ½ |Σ(xᵢ·yᵢ₊₁ − xᵢ₊₁·yᵢ)|",
"areaGaussSum": "Σ = $1 A = ½ × |$1|",
"areaGaussTerm": "(x$1·y$2 − x$2·y$1) = $3",
"areaParallelogram": "Fórmula: A = base × altura\nbase (AB) = $1 cm\naltura = $2 cm\n= $1 × $2",
"areaRectangle": "Fórmula: A = base × altura = $1 × $2",
"areaSquare": "Fórmula: A = lado² = $1²",
"areaTrapezoid": "Fórmula: A = (b₁ + b₂) / 2 × h\nb₁ (AB) = $1 cm\nb₂ (CD) = $2 cm\naltura = $3 cm\n= ($1 + $2) / 2 × $3",
"headingArea": "Área",
"headingClassification": "Clasificación",
"headingSides": "Lados y diagonales",
"labelDiag1": "d₁ (AC)",
"labelDiag2": "d₂ (BD)",
"labelPerimeter": "Perímetro",
"presetKite": "Cometa",
"presetParallelogram": "Paralelogramo",
"presetRectangle": "Rectángulo",
"presetSquare": "Cuadrado",
"presetTrapezoid": "Trapecio",
"shapeCrossed": "complejo",
"shapeGeneral": "trapezoide",
"shapeKite": "cometa",
"shapeParallelogram": "paralelogramo",
"shapeRectangle": "rectángulo",
"shapeRhombus": "rombo",
"shapeSquare": "cuadrado",
"shapeTrapezoid": "trapecio",
"unitCm": "cm",
"unitCm2": "cm²",
"hint": "Arrastra los vértices para clasificar el cuadrilátero"
},
"mk": {
"_name": "Четириаголници",
"areaDiagHalf": "Формула: A = (d₁ × d₂) / 2 d₁ (AC) = $1 см d₂ (BD) = $2 см = ($1 × $2) / 2",
"areaGaussHeader": "Гаусова формула (накрсно): A = ½ |Σ(xᵢ·yᵢ₊₁ − xᵢ₊₁·yᵢ)|",
"areaGaussSum": "Σ = $1 A = ½ × |$1|",
"areaGaussTerm": "(x$1·y$2 − x$2·y$1) = $3",
"areaParallelogram": "Формула: A = основа × висина основа (AB) = $1 см висина = $2 см = $1 × $2",
"areaRectangle": "Формула: A = ширина × висина = $1 × $2",
"areaSquare": "Формула: A = страна² = $1²",
"areaTrapezoid": "Формула: A = (b₁ + b₂) / 2 × h b₁ (AB) = $1 см b₂ (CD) = $2 см висина = $3 см = ($1 + $2) / 2 × $3",
"headingArea": "Плоштина",
"headingClassification": "Класификација",
"headingSides": "Страни и дијагонали",
"labelDiag1": "d₁ (AC)",
"labelDiag2": "d₂ (BD)",
"labelPerimeter": "Обем",
"presetKite": "Делтоид",
"presetParallelogram": "Паралелограм",
"presetRectangle": "Правоаголник",
"presetSquare": "Квадрат",
"presetTrapezoid": "Трапезоид",
"shapeCrossed": "самопресечен четириаголник",
"shapeGeneral": "општ четириаголник",
"shapeKite": "делтоид",
"shapeParallelogram": "паралелограм",
"shapeRectangle": "правоаголник",
"shapeRhombus": "ромб",
"shapeSquare": "квадрат",
"shapeTrapezoid": "трапезоид",
"unitCm": "см",
"unitCm2": "см²",
"hint": "Повлечете темиња за да го класификувате четириаголникот"
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
// ── Widget ───────────────────────────────────────────────────────────────────
function initQuadWidget(container) {
var boardWidth = 20;
var boardHeight = 12;
var names = ["A", "B", "C", "D"];
// ── DOM structure ─────────────────────────────────────────────────────────────
var wrap = document.createElement("div");
wrap.className = "quad-wrap";
container.appendChild(wrap);
var canvasCol = document.createElement("div");
canvasCol.className = "quad-canvas-col";
wrap.appendChild(canvasCol);
var controls = document.createElement("div");
controls.className = "quad-controls";
canvasCol.appendChild(controls);
var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
canvasCol.appendChild(svg);
var side = document.createElement("div");
side.className = "quad-side";
wrap.appendChild(side);
function makeSection() {
var s = document.createElement("div");
s.className = "quad-side-section";
side.appendChild(s);
return s;
}
function makeHeading(key, parent) {
var d = document.createElement("div");
d.className = "quad-side-label";
d.textContent = t(key);
parent.appendChild(d);
}
function makeStatRow(labelText, keyExtraClass, valExtraClass, parent) {
var row = document.createElement("div");
row.className = "quad-stat-row";
var k = document.createElement("span");
k.className = "quad-stat-key" + (keyExtraClass ? " " + keyExtraClass : "");
k.textContent = labelText;
var v = document.createElement("span");
v.className = "quad-stat-val" + (valExtraClass ? " " + valExtraClass : "");
row.appendChild(k);
row.appendChild(v);
parent.appendChild(row);
return v;
}
function makeDividerRow(parent) {
var row = document.createElement("div");
row.className = "quad-stat-row quad-stat-divider";
parent.appendChild(row);
return row;
}
// Section 1 – classification
var sec1 = makeSection();
makeHeading("headingClassification", sec1);
var elClass = document.createElement("div");
elClass.className = "quad-badge";
sec1.appendChild(elClass);
// Section 2 – sides & diagonals
var sec2 = makeSection();
makeHeading("headingSides", sec2);
var elAB = makeStatRow("AB", "", "", sec2);
var elBC = makeStatRow("BC", "", "", sec2);
var elCD = makeStatRow("CD", "", "", sec2);
var elDA = makeStatRow("DA", "", "", sec2);
var divDiag = makeDividerRow(sec2);
var elD1Key = document.createElement("span");
elD1Key.className = "quad-stat-key quad-diag-key";
elD1Key.textContent = t("labelDiag1");
var elD1 = document.createElement("span");
elD1.className = "quad-stat-val quad-diag-val";
divDiag.appendChild(elD1Key);
divDiag.appendChild(elD1);
var rowD2 = document.createElement("div");
rowD2.className = "quad-stat-row";
var elD2Key = document.createElement("span");
elD2Key.className = "quad-stat-key quad-diag-key";
elD2Key.textContent = t("labelDiag2");
var elD2 = document.createElement("span");
elD2.className = "quad-stat-val quad-diag-val";
rowD2.appendChild(elD2Key);
rowD2.appendChild(elD2);
sec2.appendChild(rowD2);
var divPerim = makeDividerRow(sec2);
var elPerimKey = document.createElement("span");
elPerimKey.className = "quad-stat-key quad-perim-key";
elPerimKey.textContent = t("labelPerimeter");
var elPerim = document.createElement("span");
elPerim.className = "quad-stat-val";
divPerim.appendChild(elPerimKey);
divPerim.appendChild(elPerim);
// Section 3 – area
var sec3 = makeSection();
makeHeading("headingArea", sec3);
var areaBox = document.createElement("div");
areaBox.className = "quad-area-box";
sec3.appendChild(areaBox);
var elSteps = document.createElement("div");
elSteps.className = "quad-area-steps";
areaBox.appendChild(elSteps);
var elAreaResult = document.createElement("div");
elAreaResult.className = "quad-area-result";
areaBox.appendChild(elAreaResult);
// ── Presets ───────────────────────────────────────────────────────────────────
var presets = [
{ labelKey: "presetSquare", fn: function () { return [{ x: 7, y: 3 }, { x: 13, y: 3 }, { x: 13, y: 9 }, { x: 7, y: 9 }]; } },
{ labelKey: "presetRectangle", fn: function () { return [{ x: 4, y: 4 }, { x: 16, y: 4 }, { x: 16, y: 8 }, { x: 4, y: 8 }]; } },
{ labelKey: "presetParallelogram", fn: function () { return [{ x: 4, y: 8 }, { x: 9, y: 3 }, { x: 16, y: 3 }, { x: 11, y: 8 }]; } },
{ labelKey: "presetTrapezoid", fn: function () { return [{ x: 4, y: 8 }, { x: 7, y: 3 }, { x: 13, y: 3 }, { x: 16, y: 8 }]; } },
{ labelKey: "presetKite", fn: function () { return [{ x: 10, y: 2 }, { x: 14, y: 6 }, { x: 10, y: 10 }, { x: 6, y: 6 }]; } },
];
var vertices = presets[0].fn();
presets.forEach(function (p) {
var btn = document.createElement("button");
btn.textContent = t(p.labelKey);
btn.addEventListener("click", function () { vertices = p.fn(); update(); });
controls.appendChild(btn);
});
// ── SVG helpers ───────────────────────────────────────────────────────────────
var px;
var dragging = null, dragM, dragV;
function cm(v) { return v * px; }
function ns(type, attrs, parent) {
var e = document.createElementNS("http://www.w3.org/2000/svg", type);
for (var k in attrs) e.setAttribute(k, attrs[k]);
(parent || svg).appendChild(e);
return e;
}
// SVG background rect — painted before grid so it sits behind everything.
// background-color-base: #ffffff light / #101418 dark.
// Without this, SVG strokes and text set at init time render against an
// unknown background, causing invisible lines in dark mode.
var bgRect = ns("rect", {
width: "100%", height: "100%",
fill: hiruwiki.getThemeColor('background-color-base', '#ffffff')
});
var gridG = ns("g", { class: "quad-grid" });
var diagG = ns("g", { class: "quad-diagonal" });
var diagL1 = ns("line", {}, diagG);
var diagL2 = ns("line", {}, diagG);
var shape = ns("polygon", { class: "quad-shape" });
var verts = [], sides = [], pts = [], arcs = [], angTxts = [], rights = [];
for (var i = 0; i < 4; i++) {
verts.push(ns("circle", { r: 6, class: "quad-vertex" }));
verts[i].dataset.i = i;
sides.push(ns("text", { class: "quad-side-label" }));
// Vertex letter labels (A/B/C/D): explicit fill so they remain visible
// in dark mode. CSS fill on SVG text is not always inherited reliably
// across all skins. color-base: #202122 light / ~#f8f9fa dark.
pts.push(ns("text", {
class: "quad-point-label",
fill: hiruwiki.getThemeColor('color-base', '#202122')
}));
arcs.push(ns("path", { class: "quad-angle-arc" }));
angTxts.push(ns("text", { class: "quad-angle-label" }));
rights.push(ns("path", { class: "quad-right-marker" }));
}
// ── Geometry ──────────────────────────────────────────────────────────────────
function dist(a, b) { return Math.hypot(a.x - b.x, a.y - b.y); }
function angDeg(A, B, C) {
var bax = A.x - B.x, bay = A.y - B.y, bcx = C.x - B.x, bcy = C.y - B.y;
var dot = bax * bcx + bay * bcy;
var d = Math.hypot(bax, bay) * Math.hypot(bcx, bcy) || 1;
return Math.acos(Math.max(-1, Math.min(1, dot / d))) * 180 / Math.PI;
}
function centroid() {
return {
x: (vertices[0].x + vertices[1].x + vertices[2].x + vertices[3].x) / 4,
y: (vertices[0].y + vertices[1].y + vertices[2].y + vertices[3].y) / 4,
};
}
function arcR() {
var m = Infinity;
for (var i = 0; i < 4; i++) {
var d = dist(vertices[i], vertices[(i + 1) % 4]);
if (d < m) m = d;
}
return m * 0.18;
}
function bisDir(A, B, C) {
var ba = { x: A.x - B.x, y: A.y - B.y }, bc = { x: C.x - B.x, y: C.y - B.y };
var m1 = Math.hypot(ba.x, ba.y) || 1, m2 = Math.hypot(bc.x, bc.y) || 1;
return { x: ba.x / m1 + bc.x / m2, y: ba.y / m1 + bc.y / m2 };
}
function computeArc(A, B, C) {
var a1 = Math.atan2(A.y - B.y, A.x - B.x), a2 = Math.atan2(C.y - B.y, C.x - B.x);
var r = arcR();
var s = { x: B.x + r * Math.cos(a1), y: B.y + r * Math.sin(a1) };
var e = { x: B.x + r * Math.cos(a2), y: B.y + r * Math.sin(a2) };
var cross = (A.x - B.x) * (C.y - B.y) - (A.y - B.y) * (C.x - B.x);
var sw = cross > 0 ? 1 : 0;
return "M" + cm(s.x) + " " + cm(s.y) + "A" + cm(r) + " " + cm(r) + " 0 0 " + sw + " " + cm(e.x) + " " + cm(e.y);
}
function isRight(a) { return Math.abs(a - 90) < 0.6; }
function eq(a, b, e) { e = e || 0.1; return Math.abs(a - b) < e; }
function sidesParallel(p1, p2, p3, p4) {
var d1 = { x: p2.x - p1.x, y: p2.y - p1.y }, d2 = { x: p4.x - p3.x, y: p4.y - p3.y };
return Math.abs(d1.x * d2.y - d1.y * d2.x) / (Math.hypot(d1.x, d1.y) * Math.hypot(d2.x, d2.y) || 1) < 0.05;
}
function countPar() {
var c = 0;
if (sidesParallel(vertices[0], vertices[1], vertices[3], vertices[2])) c++;
if (sidesParallel(vertices[0], vertices[3], vertices[1], vertices[2])) c++;
return c;
}
function segsX(p1, p2, p3, p4) {
function c2(u, v) { return u.x * v.y - u.y * v.x; }
var d = { x: p2.x - p1.x, y: p2.y - p1.y }, e = { x: p4.x - p3.x, y: p4.y - p3.y };
var den = c2(d, e);
if (Math.abs(den) < 1e-9) return false;
var f = { x: p3.x - p1.x, y: p3.y - p1.y };
var tVal = c2(f, e) / den, u = c2(f, d) / den;
return tVal > 0.001 && tVal < 0.999 && u > 0.001 && u < 0.999;
}
function getSides() {
return [
dist(vertices[0], vertices[1]),
dist(vertices[1], vertices[2]),
dist(vertices[2], vertices[3]),
dist(vertices[3], vertices[0]),
];
}
function getAngles() {
return [
angDeg(vertices[3], vertices[0], vertices[1]),
angDeg(vertices[0], vertices[1], vertices[2]),
angDeg(vertices[1], vertices[2], vertices[3]),
angDeg(vertices[2], vertices[3], vertices[0]),
];
}
// ── Classification ────────────────────────────────────────────────────────────
function classifyShape() {
if (segsX(vertices[0], vertices[1], vertices[2], vertices[3]) ||
segsX(vertices[1], vertices[2], vertices[3], vertices[0])) return t("shapeCrossed");
var s = getSides(), ab = s[0], bc = s[1], cd = s[2], da = s[3];
var angs = getAngles();
var allR = angs.every(isRight);
var allEq = eq(ab, bc) && eq(bc, cd) && eq(cd, da);
var oppEq = eq(ab, cd) && eq(bc, da);
if (allEq && allR) return t("shapeSquare");
if (allR) return t("shapeRectangle");
if (allEq) return t("shapeRhombus");
var par = countPar();
if (par >= 2 || oppEq) return t("shapeParallelogram");
if (par === 1) return t("shapeTrapezoid");
if ((eq(ab, bc) && eq(cd, da)) || (eq(da, ab) && eq(bc, cd))) return t("shapeKite");
return t("shapeGeneral");
}
// ── Area ──────────────────────────────────────────────────────────────────────
function getAreaInfo(cls) {
var s = getSides(), ab = s[0], bc = s[1], cd = s[2];
var d1 = dist(vertices[0], vertices[2]);
var d2 = dist(vertices[1], vertices[3]);
if (cls === t("shapeSquare")) {
return { steps: t("areaSquare", [ab.toFixed(2)]), area: ab * ab };
}
if (cls === t("shapeRectangle")) {
return { steps: t("areaRectangle", [ab.toFixed(2), bc.toFixed(2)]), area: ab * bc };
}
if (cls === t("shapeRhombus")) {
return { steps: t("areaDiagHalf", [d1.toFixed(2), d2.toFixed(2)]), area: d1 * d2 / 2 };
}
if (cls === t("shapeParallelogram")) {
var A = vertices[0], B = vertices[1];
var bx = (B.x - A.x) / ab, by = (B.y - A.y) / ab;
var D = vertices[3];
var h = Math.abs((D.x - A.x) * (-by) + (D.y - A.y) * bx);
return { steps: t("areaParallelogram", [ab.toFixed(2), h.toFixed(2)]), area: ab * h };
}
if (cls === t("shapeTrapezoid")) {
var b1 = ab, b2 = cd;
var A2 = vertices[0], B2 = vertices[1];
var bx2 = (B2.x - A2.x) / b1, by2 = (B2.y - A2.y) / b1;
var D2 = vertices[3];
var h2 = Math.abs((D2.x - A2.x) * (-by2) + (D2.y - A2.y) * bx2);
return { steps: t("areaTrapezoid", [b1.toFixed(2), b2.toFixed(2), h2.toFixed(2)]), area: (b1 + b2) / 2 * h2 };
}
if (cls === t("shapeKite")) {
return { steps: t("areaDiagHalf", [d1.toFixed(2), d2.toFixed(2)]), area: d1 * d2 / 2 };
}
// General — Gauss (shoelace)
var termLines = [], sum = 0;
for (var j = 0; j < 4; j++) {
var va = vertices[j], vb = vertices[(j + 1) % 4];
var cross = va.x * vb.y - vb.x * va.y;
sum += cross;
termLines.push(t("areaGaussTerm", [names[j], names[(j + 1) % 4], cross.toFixed(2)]));
}
return {
steps: t("areaGaussHeader") + termLines.join("\n") + t("areaGaussSum", [sum.toFixed(2)]),
area: Math.abs(sum) / 2,
};
}
// ── Grid ──────────────────────────────────────────────────────────────────────
function drawGrid() {
gridG.innerHTML = "";
for (var x = 0; x <= boardWidth; x++) {
var lx = document.createElementNS("http://www.w3.org/2000/svg", "line");
lx.setAttribute("x1", cm(x)); lx.setAttribute("y1", 0);
lx.setAttribute("x2", cm(x)); lx.setAttribute("y2", cm(boardHeight));
gridG.appendChild(lx);
}
for (var y = 0; y <= boardHeight; y++) {
var ly = document.createElementNS("http://www.w3.org/2000/svg", "line");
ly.setAttribute("x1", 0); ly.setAttribute("y1", cm(y));
ly.setAttribute("x2", cm(boardWidth)); ly.setAttribute("y2", cm(y));
gridG.appendChild(ly);
}
}
// ── Main update ───────────────────────────────────────────────────────────────
function update() {
shape.setAttribute("points", vertices.map(function (v) { return cm(v.x) + "," + cm(v.y); }).join(" "));
diagL1.setAttribute("x1", cm(vertices[0].x)); diagL1.setAttribute("y1", cm(vertices[0].y));
diagL1.setAttribute("x2", cm(vertices[2].x)); diagL1.setAttribute("y2", cm(vertices[2].y));
diagL2.setAttribute("x1", cm(vertices[1].x)); diagL2.setAttribute("y1", cm(vertices[1].y));
diagL2.setAttribute("x2", cm(vertices[3].x)); diagL2.setAttribute("y2", cm(vertices[3].y));
for (var i = 0; i < 4; i++) {
verts[i].setAttribute("cx", cm(vertices[i].x));
verts[i].setAttribute("cy", cm(vertices[i].y));
}
var c = centroid();
for (var i = 0; i < 4; i++) {
var v = vertices[i];
var dx = v.x - c.x, dy = v.y - c.y, m = Math.hypot(dx, dy) || 1;
pts[i].textContent = names[i];
pts[i].setAttribute("x", cm(v.x + (dx / m) * 0.85));
pts[i].setAttribute("y", cm(v.y + (dy / m) * 0.85));
var a = vertices[i], b = vertices[(i + 1) % 4];
var mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
sides[i].textContent = dist(a, b).toFixed(2);
sides[i].setAttribute("x", cm(mid.x));
sides[i].setAttribute("y", cm(mid.y));
var B = vertices[i], A = vertices[(i + 3) % 4], C = vertices[(i + 1) % 4];
var ang = angDeg(A, B, C), rAng = isRight(ang);
arcs[i].setAttribute("d", rAng ? "" : computeArc(A, B, C));
var bd = bisDir(A, B, C), bm = Math.hypot(bd.x, bd.y) || 1, r = arcR();
angTxts[i].textContent = ang.toFixed(1) + "\u00B0";
angTxts[i].setAttribute("x", cm(B.x + (bd.x / bm) * r * 1.9));
angTxts[i].setAttribute("y", cm(B.y + (bd.y / bm) * r * 1.9));
if (rAng) {
var sv = r * 0.55;
var v1 = { x: A.x - B.x, y: A.y - B.y }, v2 = { x: C.x - B.x, y: C.y - B.y };
var m1 = Math.hypot(v1.x, v1.y) || 1, m2 = Math.hypot(v2.x, v2.y) || 1;
v1.x /= m1; v1.y /= m1; v2.x /= m2; v2.y /= m2;
var p1 = { x: B.x + v1.x * sv, y: B.y + v1.y * sv }, p2 = { x: p1.x + v2.x * sv, y: p1.y + v2.y * sv }, p3 = { x: B.x + v2.x * sv, y: B.y + v2.y * sv };
rights[i].setAttribute("d", "M" + cm(p1.x) + " " + cm(p1.y) + "L" + cm(p2.x) + " " + cm(p2.y) + "L" + cm(p3.x) + " " + cm(p3.y));
} else {
rights[i].setAttribute("d", "");
}
}
var cls = classifyShape();
var sl = getSides(), ab = sl[0], bc = sl[1], cd = sl[2], da = sl[3];
var d1 = dist(vertices[0], vertices[2]);
var d2 = dist(vertices[1], vertices[3]);
var perim = ab + bc + cd + da;
var ainfo = getAreaInfo(cls);
var u = t("unitCm"), u2 = t("unitCm2");
elClass.textContent = cls;
elAB.textContent = ab.toFixed(2) + "\u00A0" + u;
elBC.textContent = bc.toFixed(2) + "\u00A0" + u;
elCD.textContent = cd.toFixed(2) + "\u00A0" + u;
elDA.textContent = da.toFixed(2) + "\u00A0" + u;
elD1.textContent = d1.toFixed(2) + "\u00A0" + u;
elD2.textContent = d2.toFixed(2) + "\u00A0" + u;
elPerim.textContent = perim.toFixed(2) + "\u00A0" + u;
elSteps.textContent = ainfo.steps;
elAreaResult.textContent = "";
var valSpan = document.createElement("span");
valSpan.textContent = ainfo.area.toFixed(2) + "\u00A0";
var unitSpan = document.createElement("span");
unitSpan.className = "quad-area-unit";
unitSpan.textContent = u2;
elAreaResult.appendChild(valSpan);
elAreaResult.appendChild(unitSpan);
}
// ── Drag ──────────────────────────────────────────────────────────────────────
function getPos(e) {
return {
x: e.touches ? e.touches[0].clientX : e.clientX,
y: e.touches ? e.touches[0].clientY : e.clientY
};
}
function startDrag(e, v) {
dragging = parseInt(v.dataset.i);
var r = svg.getBoundingClientRect();
var pos = getPos(e);
dragM = { x: (pos.x - r.left) / px, y: (pos.y - r.top) / px };
dragV = { x: vertices[dragging].x, y: vertices[dragging].y };
e.preventDefault();
}
verts.forEach(function (v) {
v.addEventListener("mousedown", function (e) { startDrag(e, v); });
v.addEventListener("touchstart", function (e) { startDrag(e, v); }, { passive: false });
});
function handleMove(e) {
if (dragging === null) return;
e.preventDefault();
var r = svg.getBoundingClientRect();
var pos = getPos(e);
var mx = (pos.x - r.left) / px, my = (pos.y - r.top) / px;
vertices[dragging] = { x: dragV.x + Math.trunc(mx - dragM.x), y: dragV.y + Math.trunc(my - dragM.y) };
update();
}
function endDrag() { dragging = null; }
window.addEventListener("mousemove", handleMove);
window.addEventListener("touchmove", handleMove, { passive: false });
window.addEventListener("mouseup", endDrag);
window.addEventListener("touchend", endDrag);
// ── Init ──────────────────────────────────────────────────────────────────────
function init() {
var w = svg.getBoundingClientRect().width;
if (w === 0) {
requestAnimationFrame(init);
return;
}
px = w / boardWidth;
drawGrid();
update();
}
requestAnimationFrame(init);
window.addEventListener("resize", function () { requestAnimationFrame(init); });
// Footer branding
var footer = document.createElement("div");
footer.className = "hw-footer";
var fLogo = document.createElement("a");
fLogo.className = "hw-footer-icon";
fLogo.href = mw.util.getUrl('Wikipedia:Hiruwiki');
fLogo.title = 'Hiruwiki';
if (window.hiruwiki && window.hiruwiki.getLogoSvg) {
fLogo.insertAdjacentHTML('beforeend', hiruwiki.getLogoSvg(22));
}
var fText = document.createElement("span");
fText.className = "hw-footer__text";
fText.textContent = t('hint');
footer.appendChild(fLogo);
footer.appendChild(fText);
container.appendChild(footer);
} // end initQuadWidget
// ── Bootstrap ─────────────────────────────────────────────────────────────────
mw.hook("wikipage.content").add(function () {
document.querySelectorAll('.hiruwiki[data-module="quadrangles"]').forEach(function (el) {
if (el.dataset.quadReady) return;
el.dataset.quadReady = true;
initQuadWidget(el);
});
});
})();
g86his7pmci125rr6o286p5wrrdx0xc
MediaWiki:Hiruwiki/modules/radian.js
8
179521
781443
770248
2026-10-28T10:40:05Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781443
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/* radian.js */
(function() {
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"nl": {
"_name": "Radialen",
"rad": "rad",
"hint": "Sleep de hendel om de relatie tussen graden en radialen te zien"
},
"fr": {
"_name": "Radians",
"rad": "rad",
"hint": "Faites glisser la poignée pour visualiser la relation entre les degrés et les radians"
},
"tr": {
"_name": "Radyanlar",
"rad": "rad",
"hint": "Derece ve radyan arasındaki ilişkiyi görmek için imleci sürükleyin"
},
"eu": {
"_name": "Radianak",
"rad": "rad",
"hint": "Arrastatu heldulekua graduen eta radianen arteko erlazioa ikusteko"
},
"sk": {
"_name": "Radiány",
"rad": "rad",
"hint": "Potiahnutím rukoväte uvidíte vzťah medzi stupňami a radiánmi"
},
"skr-arab": {
"_name": "ریڈین",
"rad": "rad",
"hint": "ڈگریاں تے ریڈین دے وچکار تعلق ویکھݨ کیتے ہینڈل گھسیٹو"
},
"ko": {
"_name": "라디안",
"rad": "rad",
"hint": "핸들을 드래그하여 도(°)와 라디안(radian) 사이의 관계를 확인하세요."
},
"en": {
"_name": "Radians",
"rad": "rad",
"hint": "Drag the handle to see the relationship between degrees and radians"
},
"ca": {
"_name": "Radians",
"rad": "rad"
},
"qqq": {
"_name": "Name of the Radians module",
"rad": "Abbreviation for radians",
"hint": "Instruction text for the radian-degree relationship applet"
},
"ga": {
"_name": "Raidiáin",
"rad": "rad",
"hint": "Tarraing an láimhseáil chun an gaol idir céimeanna agus raidiáin a fheiceáil"
},
"es": {
"_name": "Radianes",
"rad": "rad",
"hint": "Arrastra el controlador para ver la relación entre grados y radianes"
},
"mk": {
"_name": "Радијани",
"rad": "rad",
"hint": "Повлечете ја рачката за да го видите односот помеѓу степените и радијаните"
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
// Find all radian modules
document.querySelectorAll('.hiruwiki[data-module="radian"]').forEach(function(container) {
const width = 700;
const height = 540;
const cx = width/2;
const cy = height/2;
const R = 200;
const colors = ["#e63946","#457b9d","#2a9d8f","#f4a261","#9d4edd","#06d6a0"];
let angle = 0;
let lastAngle = 0;
// Create SVG inside the container
const svg = document.createElementNS("http://www.w3.org/2000/svg","svg");
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
svg.classList.add("hiruwiki-svg");
container.appendChild(svg);
function create(t){ return document.createElementNS("http://www.w3.org/2000/svg", t); }
function polar(a,r){ return {x: cx + r*Math.cos(a), y: cy - r*Math.sin(a)}; }
function arcPath(a0,a1){
const p0 = polar(a0,R);
const p1 = polar(a1,R);
const large = (a1-a0)>Math.PI?1:0;
return `M ${p0.x} ${p0.y} A ${R} ${R} 0 ${large} 0 ${p1.x} ${p1.y}`;
}
function gcd(a,b){ return b?gcd(b,a%b):a; }
function piFraction(angle){
const ratio = angle/Math.PI;
for(let d=1;d<=12;d++){
let n=Math.round(ratio*d);
if(Math.abs(ratio-n/d)<0.01){
const g=gcd(n,d); n/=g; d/=g;
if(n===0)return "0";
if(n===1 && d===1) return "π";
if(d===1) return n+"π";
if(n===1) return "π/"+d;
return n+"π/"+d;
}
}
return ratio.toFixed(2)+"π";
}
/* PROTRACTOR */
for(let d=0; d<=360; d+=5){
const a = d*Math.PI/180;
let len = 6, cls="tickSmall";
if(d%30===0){len=18; cls="tickMajor";}
else if(d%10===0){len=12; cls="tickMedium";}
const p1 = polar(a,R);
const p2 = polar(a,R-len);
const line = create("line");
line.setAttribute("x1",p1.x); line.setAttribute("y1",p1.y);
line.setAttribute("x2",p2.x); line.setAttribute("y2",p2.y);
line.setAttribute("class",cls);
svg.appendChild(line);
if(d%30===0){
const tpos = polar(a,R-30);
const txt = create("text");
txt.setAttribute("x",tpos.x); txt.setAttribute("y",tpos.y);
txt.setAttribute("text-anchor","middle");
txt.setAttribute("class","tickLabel");
txt.textContent = d+"°";
svg.appendChild(txt);
}
}
/* RADIAN TICKS: 1-6, π, 2π */
const radTicksAll = [1,2,3,4,5,6,Math.PI,2*Math.PI];
radTicksAll.forEach(r=>{
const a = r;
const isLabel = [1,2,3,4,5,6,Math.PI,2*Math.PI].includes(r);
const outerLen = isLabel ? 25 : 15;
const p1 = polar(a,R);
const p2 = polar(a,R-outerLen);
const tick = create("line");
tick.setAttribute("x1",p1.x); tick.setAttribute("y1",p1.y);
tick.setAttribute("x2",p2.x); tick.setAttribute("y2",p2.y);
tick.setAttribute("class","radianTick");
let idx = Math.floor(r) % colors.length;
if(r===Math.PI) idx = Math.floor(Math.PI) % colors.length;
if(r===2*Math.PI) idx = Math.floor(2*Math.PI) % colors.length;
tick.setAttribute("stroke", colors[idx]);
svg.appendChild(tick);
if(isLabel){
const labelPos = polar(a,R+35);
const lbl = create("text");
lbl.setAttribute("x",labelPos.x); lbl.setAttribute("y",labelPos.y);
lbl.setAttribute("text-anchor","middle"); lbl.setAttribute("class","tickLabel");
lbl.setAttribute("fill", colors[idx]);
if(r===Math.PI) lbl.textContent="π";
else if(r===2*Math.PI) lbl.textContent="2π";
else lbl.textContent=r.toFixed(1);
svg.appendChild(lbl);
}
});
/* GROUPS */
const arcsGroup=create("g"); svg.appendChild(arcsGroup);
/* BASE RADIUS */
const base=create("line"); base.setAttribute("class","radius");
base.setAttribute("x1",cx); base.setAttribute("y1",cy);
base.setAttribute("x2",cx+R); base.setAttribute("y2",cy); svg.appendChild(base);
const baseIndicator=create("line"); baseIndicator.setAttribute("class","baseIndicator");
baseIndicator.setAttribute("x1",cx); baseIndicator.setAttribute("y1",cy);
baseIndicator.setAttribute("x2",cx+R); baseIndicator.setAttribute("y2",cy); svg.appendChild(baseIndicator);
const baseDot=create("circle"); baseDot.setAttribute("r",7); baseDot.setAttribute("class","baseDot");
svg.appendChild(baseDot);
/* MOVING RADIUS */
const radiusLine=create("line"); radiusLine.setAttribute("class","radius"); svg.appendChild(radiusLine);
const handle=create("circle"); handle.setAttribute("r",8); handle.setAttribute("class","handle"); svg.appendChild(handle);
/* LABEL */
const label=create("text"); label.setAttribute("class","label"); svg.appendChild(label);
function update(){
arcsGroup.innerHTML="";
const radCount=Math.floor(angle);
for(let i=0;i<=radCount;i++){
const a0=i; const a1=Math.min(angle,i+1);
if(a1>a0){
const arc=create("path");
arc.setAttribute("d",arcPath(a0,a1));
arc.setAttribute("class","arc");
arc.setAttribute("stroke",colors[i%colors.length]);
arcsGroup.appendChild(arc);
}
}
const p = polar(angle,R);
radiusLine.setAttribute("x1",cx); radiusLine.setAttribute("y1",cy);
radiusLine.setAttribute("x2",p.x); radiusLine.setAttribute("y2",p.y);
handle.setAttribute("cx",p.x); handle.setAttribute("cy",p.y);
/* LABEL OUTSIDE */
const labelR = R + 50;
let lp = polar(angle,labelR);
label.innerHTML="";
const radText = angle.toFixed(2)+" rad";
const piText = piFraction(angle);
const t1 = create("tspan"); t1.setAttribute("x",lp.x); t1.setAttribute("dy",0); t1.textContent=radText;
const t2 = create("tspan"); t2.setAttribute("x",lp.x); t2.setAttribute("dy",18); t2.textContent=piText;
label.appendChild(t1); label.appendChild(t2);
if(lp.x > width - 50){ label.setAttribute("text-anchor","end"); lp.x=Math.min(lp.x,width-10); }
else if(lp.x < 50){ label.setAttribute("text-anchor","start"); lp.x=Math.max(lp.x,10); }
else label.setAttribute("text-anchor","middle");
label.setAttribute("x",lp.x); label.setAttribute("y",lp.y);
/* BASE RADIAN INDICATOR */
const current=Math.floor(angle);
const frac=angle-current;
const color=colors[current%colors.length];
baseIndicator.setAttribute("stroke",color);
baseDot.setAttribute("cx",cx+R*frac); baseDot.setAttribute("cy",cy); baseDot.setAttribute("fill",color);
}
function setAngle(e){
const rect=svg.getBoundingClientRect();
const sx = width / rect.width;
const sy = height / rect.height;
const x=((e.touches?e.touches[0].clientX:e.clientX)-rect.left) * sx;
const y=((e.touches?e.touches[0].clientY:e.clientY)-rect.top) * sy;
let a=Math.atan2(cy-y,x-cx);
if(a<0) a+=2*Math.PI;
if(lastAngle>5.5 && a<1) angle=2*Math.PI;
else if(lastAngle<1 && a>5.5) angle=0;
else angle=a;
angle=Math.max(0,Math.min(angle,2*Math.PI));
lastAngle=angle;
update();
}
let dragging=false;
svg.addEventListener("mousedown",e=>{
e.preventDefault();
dragging=true;
setAngle(e);
});
svg.addEventListener("mousemove",e=>{if(dragging)setAngle(e);});
document.addEventListener("mouseup",()=>dragging=false);
svg.addEventListener("touchstart",e=>{
e.preventDefault();
dragging=true;
setAngle(e);
});
svg.addEventListener("touchmove",e=>{
e.preventDefault();
setAngle(e);
});
document.addEventListener("touchend",()=>dragging=false);
update();
// Footer branding
var footer = document.createElement("div");
footer.className = "hw-footer";
var fLogo = document.createElement("a");
fLogo.className = "hw-footer-icon";
fLogo.href = mw.util.getUrl('Wikipedia:Hiruwiki');
fLogo.title = 'Hiruwiki';
fLogo.innerHTML = hiruwiki.getLogoSvg(22);
var fText = document.createElement("span");
fText.innerHTML = t('hint');
footer.appendChild(fLogo);
footer.appendChild(fText);
container.appendChild(footer);
});
})();
pdpivhrdlru8wvh5yk7c5ox0h41bfqk
MediaWiki:Hiruwiki/modules/slope.js
8
179523
781444
770250
2026-10-28T10:40:08Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781444
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/* =========================================================
* Hiruwiki module: slope
* Visualisation of the slope between two draggable points
* =========================================================
* i18n: add a new key to the `messages` object below and
* a matching entry for every language code you need.
* Fall back to 'en' when the wiki language is absent.
* ========================================================= */
( function () {
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"nl": {
"_name": "Helling",
"undefined": "Verticale lijn → helling ongedefinieerd",
"hint": "Sleep de punten om de hellingsberekening te zien",
"point1": "Punt P₁",
"point2": "Punt P₂",
"coords1": "Coördinaten van P₁",
"coords2": "Coördinaten van P₂",
"rise": "Stijging (Δy)",
"run": "Aanloop (Δx)",
"slope": "Helling (m)",
"slopeCard": "Helling",
"pointsCard": "Punten"
},
"fr": {
"_name": "Pente",
"undefined": "Ligne verticale → pente indéfinie",
"hint": "Faites glisser les points pour voir le calcul de la pente",
"point1": "Point P₁",
"point2": "Point P₂",
"coords1": "Coordonnées de P₁",
"coords2": "Coordonnées de P₂",
"rise": "Montée (Δy)",
"run": "Course (Δx)",
"slope": "Pente (m)",
"slopeCard": "Pente",
"pointsCard": "Points"
},
"tr": {
"_name": "Eğim",
"undefined": "Dikey çizgi → eğim belirsiz",
"hint": "Eğim hesaplamasını görmek için noktaları sürükleyin.",
"point1": "P₁ Noktası",
"point2": "P₂ Noktası",
"coords1": "P₁ koordinatları",
"coords2": "P₂ koordinatları",
"rise": "Yükseklik artışı (Δy)"
},
"eu": {
"_name": "Maldia",
"undefined": "Lerro bertikala → malda ezarri gabe",
"hint": "Puntu zatiak arrastatu maldaren kalkulua ikusteko",
"point1": "P₁ puntua",
"point2": "P₂ puntua",
"coords1": "P₁ koordenatuak",
"coords2": "P₂ koordenatuak",
"rise": "Igoera (Δy)",
"run": "Ibilbidea (Δx)",
"slope": "Malda (m)",
"slopeCard": "Malda",
"pointsCard": "Puntuak"
},
"sk": {
"_name": "Smernica",
"undefined": "Zvislá priamka → smernica nie je definovaná",
"hint": "Potiahnutím bodov zobrazíte výpočet smernice",
"point1": "Bod P₁",
"point2": "Bod P₂",
"coords1": "Súradnice P₁",
"coords2": "Súradnice P₂",
"rise": "Zmena y (Δy)",
"run": "Zmena x (Δx)",
"slope": "Smernica (m)",
"slopeCard": "Smernica",
"pointsCard": "Body"
},
"skr-arab": {
"_name": "ڈھلوان",
"undefined": "عمودی لکیر → ڈھلوان غیر متعین",
"hint": "نقطے گھسیٹ کے ڈھلوان دا حساب ویکھو",
"point1": "نقطہ P₁",
"point2": "نقطہ P₂",
"coords1": "P₁ دے نقاط",
"coords2": "P₂ دے نقاط",
"rise": "اُچائی (Δy)",
"run": "افقی فاصلہ (Δx)",
"slope": "ڈھلوان (m)",
"slopeCard": "ڈھلوان",
"pointsCard": "نقطے"
},
"lb": {
"point1": "Punkt P₁",
"point2": "Punkt P₂",
"coords1": "Koordinate vu P₁",
"coords2": "Koordinate vu P₂",
"pointsCard": "Punkten"
},
"ko": {
"_name": "기울기",
"undefined": "수직선 → 기울기 정의 안 됨",
"hint": "점을 드래그하여 기울기 계산을 확인하세요",
"point1": "점 P₁",
"point2": "점 P₂",
"coords1": "P₁ 좌표",
"coords2": "P₂ 좌표",
"rise": "상승 (Δy)",
"run": "이동 (Δx)",
"slope": "기울기 (m)",
"slopeCard": "기울기",
"pointsCard": "점"
},
"en": {
"_name": "Slope",
"undefined": "Vertical line → slope undefined",
"hint": "Drag the points to see the slope calculation",
"point1": "Point P₁",
"point2": "Point P₂",
"coords1": "P₁ coordinates",
"coords2": "P₂ coordinates",
"rise": "Rise (Δy)",
"run": "Run (Δx)",
"slope": "Slope (m)",
"slopeCard": "Slope",
"pointsCard": "Points"
},
"qqq": {
"_name": "Name of the Slope module",
"undefined": "Message shown when the line is vertical and the slope is undefined",
"hint": "Instruction text for the slope calculation interaction",
"point1": "name of the first point",
"point2": "name of the second point",
"coords1": "coordinates of first point",
"coords2": "coordinates of second point",
"rise": "message for the vertical distance",
"run": "message for the horizontal distance",
"slope": "name of the division of both",
"slopeCard": "title of the slope section",
"pointsCard": "title used in the points section"
},
"ga": {
"_name": "Fána",
"undefined": "Líne ingearach → fána neamhshainithe",
"hint": "Tarraing na pointí chun ríomh an fhána a fheiceáil",
"point1": "Pointe P₁",
"point2": "Pointe P₂",
"coords1": "Comhordanáidí P₁",
"coords2": "Comhordanáidí P₂",
"rise": "Ardú (Δy)",
"run": "Rith (Δx)",
"slope": "Fána (m)",
"slopeCard": "Fána",
"pointsCard": "Pointí"
},
"es": {
"_name": "Pendiente",
"undefined": "Línea vertical → pendiente indefinida",
"hint": "Arrastra los puntos para ver el cálculo de la pendiente",
"point1": "Punto P₁",
"point2": "Punto P₂",
"coords1": "Coordenadas de P₁",
"coords2": "Coordenadas de P₂",
"rise": "Elevación (Δy)",
"run": "Avance (Δx)",
"slope": "Pendiente (m)",
"slopeCard": "Pendiente",
"pointsCard": "Puntos"
},
"mk": {
"_name": "Наклон",
"undefined": "Вертикала → недефиниран наклон",
"hint": "Повлечете ги точките за да ја видите пресметката на наклонот",
"point1": "Точка P₁",
"point2": "Точка P₂",
"coords1": "Координати на P₁",
"coords2": "Координати на P₂",
"rise": "Пораст (Δy)",
"run": "Поместување (Δx)",
"slope": "Наклон (m)",
"slopeCard": "Наклон",
"pointsCard": "Точки"
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
/* ── Constants ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var SCALE = 40; /* pixels per unit on the grid */
var RULER_H = 36; /* ruler strip height (px) */
var RANGE = 10; /* grid extends ±RANGE units from origin */
var DPR = window.devicePixelRatio || 1;
/* ── Build widget ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
function buildWidget( container ) {
var lang2 = ( window.mw && mw.config.get( 'wgUserLanguage' ) ) || 'en';
lang2 = lang2.split( '-' )[ 0 ];
if ( !messages[ lang2 ] ) { lang2 = 'en'; }
var msg = messages[ lang2 ];
/* -- HTML skeleton -- */
container.innerHTML =
'<div class="hw-slope">' +
'<canvas class="hw-canvas"></canvas>' +
'<div class="hw-params">' +
'<div class="hw-card">' +
'<div class="hw-card-title">' +
'<span class="hw-badge hw-badge-t">' + msg.pointsCard + '</span>' +
'</div>' +
'<div class="hw-row"><span>' + msg.coords1 + '</span><span id="hw-sl-p1"></span></div>' +
'<div class="hw-row"><span>' + msg.coords2 + '</span><span id="hw-sl-p2"></span></div>' +
'<div class="hw-row"><span>' + msg.rise + '</span><span id="hw-sl-rise"></span></div>' +
'<div class="hw-row"><span>' + msg.run + '</span><span id="hw-sl-run"></span></div>' +
'</div>' +
'<div class="hw-card">' +
'<div class="hw-card-title">' +
'<span class="hw-badge hw-badge-c">' + msg.slopeCard + '</span>' +
'</div>' +
'<div class="hw-row"><span>' + msg.slope + '</span>' +
'<span id="hw-sl-m"></span>' +
'</div>' +
'<div class="hw-slope-formula">' +
'<span class="hw-formula-label">m =</span>' +
'<span class="hw-formula-value" id="hw-sl-formula">—</span>' +
'</div>' +
'</div>' +
'</div>' +
'</div>';
/* -- Footer -- */
var footer = document.createElement( 'div' );
footer.className = 'hw-footer';
var fLogo = document.createElement( 'a' );
fLogo.className = 'hw-footer-icon';
fLogo.href = ( window.mw && mw.util.getUrl( 'Wikipedia:Hiruwiki' ) ) || '#';
fLogo.title = 'Hiruwiki';
if ( window.hiruwiki && window.hiruwiki.getLogoSvg ) {
fLogo.insertAdjacentHTML( 'beforeend', hiruwiki.getLogoSvg( 22 ) );
}
var fText = document.createElement( 'span' );
fText.className = 'hw-footer__text';
fText.textContent = msg.hint;
footer.appendChild( fLogo );
footer.appendChild( fText );
container.appendChild( footer );
var canvas = container.querySelector( '.hw-canvas' );
var ctx = canvas.getContext( '2d' );
var W, H;
var dragging = -1;
/* grid coordinates (–RANGE … +RANGE) */
var points = [
{ x: -3, y: -2 },
{ x: 3, y: 2 }
];
function $( id ) { return container.querySelector( '#' + id ); }
/* ── coordinate helpers ── */
function toCanvasX( gx ) { return W / 2 + gx * SCALE; }
function toCanvasY( gy ) { return RULER_H + ( H - RULER_H ) / 2 - gy * SCALE; }
function fromCanvas( cx, cy ) {
return {
x: Math.round( ( cx - W / 2 ) / SCALE ),
y: Math.round( ( ( RULER_H + ( H - RULER_H ) / 2 ) - cy ) / SCALE )
};
}
/* ── dark-mode detection ── */
function isDark() {
var root = document.documentElement;
if ( root.classList.contains( 'skin-theme-clientpref-night' ) ||
root.classList.contains( 'client-dark-mode' ) ||
document.body.classList.contains( 'mw-dark-mode' ) ) {
return true;
}
return !!( window.matchMedia && window.matchMedia( '(prefers-color-scheme: dark)' ).matches );
}
function palette() {
var dark = isDark();
return {
dark: dark,
line: hiruwiki.getThemeColor( 'hw-color-primary', dark ? '#AFA9EC' : '#534AB7' ),
rise: hiruwiki.getThemeColor( 'hw-color-success', dark ? '#5DCAA5' : '#0F6E56' ),
run: hiruwiki.getThemeColor( 'hw-color-warning', dark ? '#FAC775' : '#BA7517' ),
text: hiruwiki.getThemeColor( 'hw-text-base', dark ? '#E8E6DC' : '#1a1a18' ),
ruleBg: hiruwiki.getThemeColor( 'hw-bg-subtle', dark ? '#2C2C2A' : '#F1EFE8' ),
ruleTick: hiruwiki.getThemeColor( 'hw-text-placeholder', '#888780' ),
ruleText: hiruwiki.getThemeColor( 'hw-text-muted', '#5F5E5A' ),
grid: hiruwiki.getThemeColor( 'hw-border', dark ? '#444441' : '#D3D1C7' ),
axis: hiruwiki.getThemeColor( 'hw-text-base', dark ? '#E8E6DC' : '#1a1a18' ),
dotBg: hiruwiki.getThemeColor( 'hw-bg-base', dark ? '#1e1e1c' : '#ffffff' )
};
}
/* ── ruler ── */
function drawRuler( p ) {
ctx.fillStyle = p.ruleBg;
ctx.fillRect( 0, 0, W, RULER_H );
ctx.strokeStyle = p.ruleTick;
ctx.lineWidth = 0.5;
ctx.beginPath(); ctx.moveTo( 0, RULER_H ); ctx.lineTo( W, RULER_H ); ctx.stroke();
var originPx = W / 2;
var maxUnits = Math.ceil( W / SCALE ) + 1;
ctx.font = '10px sans-serif';
ctx.textBaseline = 'bottom';
for ( var u = -maxUnits; u <= maxUnits; u++ ) {
var px = originPx + u * SCALE;
if ( px < 0 || px > W + SCALE ) { continue; }
ctx.strokeStyle = p.ruleTick; ctx.lineWidth = 0.8;
ctx.beginPath(); ctx.moveTo( px, RULER_H - 10 ); ctx.lineTo( px, RULER_H ); ctx.stroke();
if ( u !== 0 ) {
ctx.fillStyle = p.ruleText; ctx.textAlign = 'center';
ctx.fillText( u, px, RULER_H - 12 );
}
for ( var mm = 1; mm < 10; mm++ ) {
var mpx = px + mm * SCALE / 10;
if ( mpx > W ) { break; }
ctx.strokeStyle = p.ruleTick; ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo( mpx, RULER_H - ( mm === 5 ? 7 : 4 ) );
ctx.lineTo( mpx, RULER_H );
ctx.stroke();
}
}
ctx.fillStyle = p.ruleText;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.font = '500 10px sans-serif';
ctx.fillText( 'x', 4, RULER_H / 2 );
}
/* ── grid ── */
function drawGrid( p ) {
var originX = W / 2;
var originY = RULER_H + ( H - RULER_H ) / 2;
/* minor gridlines */
ctx.strokeStyle = p.grid;
ctx.lineWidth = 1.0;
for ( var u = -RANGE; u <= RANGE; u++ ) {
/* vertical */
var gx = originX + u * SCALE;
if ( gx < 0 || gx > W ) { continue; }
ctx.globalAlpha = 0.35;
for ( var m = 1; m < 10; m++ ) {
var mx = gx + m * SCALE / 10;
if ( mx > W ) { break; }
ctx.beginPath(); ctx.moveTo( mx, RULER_H ); ctx.lineTo( mx, H ); ctx.stroke();
}
ctx.globalAlpha = 1.0;
if ( u !== 0 ) {
ctx.beginPath(); ctx.moveTo( gx, RULER_H ); ctx.lineTo( gx, H ); ctx.stroke();
}
/* horizontal */
var gy = originY - u * SCALE;
if ( gy < RULER_H || gy > H ) { continue; }
ctx.globalAlpha = 0.35;
for ( var my = 1; my < 10; my++ ) {
var myy = gy - my * SCALE / 10;
if ( myy < RULER_H ) { break; }
ctx.beginPath(); ctx.moveTo( 0, myy ); ctx.lineTo( W, myy ); ctx.stroke();
}
ctx.globalAlpha = 1.0;
if ( u !== 0 ) {
ctx.beginPath(); ctx.moveTo( 0, gy ); ctx.lineTo( W, gy ); ctx.stroke();
}
}
/* axes */
ctx.strokeStyle = p.axis; ctx.lineWidth = 1.5; ctx.globalAlpha = 1.0;
/* x-axis */
ctx.beginPath(); ctx.moveTo( 0, originY ); ctx.lineTo( W, originY ); ctx.stroke();
/* y-axis */
ctx.beginPath(); ctx.moveTo( originX, RULER_H ); ctx.lineTo( originX, H ); ctx.stroke();
/* axis arrows */
var arr = 8;
ctx.fillStyle = p.axis;
/* right arrow on x */
ctx.beginPath();
ctx.moveTo( W - 8, originY );
ctx.lineTo( W - 8 - arr, originY - arr / 2 );
ctx.lineTo( W - 8 - arr, originY + arr / 2 );
ctx.fill();
/* up arrow on y */
ctx.beginPath();
ctx.moveTo( originX, RULER_H + 8 );
ctx.lineTo( originX - arr / 2, RULER_H + 8 + arr );
ctx.lineTo( originX + arr / 2, RULER_H + 8 + arr );
ctx.fill();
/* axis labels */
ctx.font = '500 13px sans-serif'; ctx.textBaseline = 'middle';
ctx.fillStyle = p.ruleText;
ctx.textAlign = 'left'; ctx.fillText( 'x', W - 20, originY - 12 );
ctx.textAlign = 'left'; ctx.fillText( 'y', originX + 8, RULER_H + 20 );
/* tick labels on y-axis */
ctx.font = '10px sans-serif'; ctx.textBaseline = 'middle'; ctx.textAlign = 'right';
for ( var v = -RANGE; v <= RANGE; v++ ) {
if ( v === 0 ) { continue; }
var yy = originY - v * SCALE;
if ( yy < RULER_H + 4 || yy > H - 4 ) { continue; }
ctx.fillStyle = p.ruleText;
ctx.fillText( v, originX - 6, yy );
}
}
/* ── coordinate label next to a point ── */
function drawCoordLabel( text, cx, cy, alignRight, above, p ) {
ctx.font = '13px sans-serif';
var pad = 4;
var tw = ctx.measureText( text ).width;
var bw = tw + pad * 2, bh = 18;
var tx = alignRight ? cx - bw - 10 : cx + 10;
var ty = above ? cy - bh - 8 : cy + 8;
ctx.fillStyle = p.dotBg;
ctx.strokeStyle = p.grid;
ctx.lineWidth = 0.8;
ctx.beginPath();
ctx.roundRect( tx - pad, ty - 2, bw, bh, 3 );
ctx.fill(); ctx.stroke();
ctx.fillStyle = p.text;
ctx.textBaseline = 'middle';
ctx.textAlign = 'left';
ctx.fillText( text, tx, ty + bh / 2 - 2 );
}
/* ── slope triangle (rise + run dashes) ── */
function drawSlopeTriangle( p ) {
var pt1 = points[ 0 ], pt2 = points[ 1 ];
if ( pt1.x === pt2.x ) { return; }
/* order so the "corner" is at (pt2.x, pt1.y) */
var x1 = toCanvasX( pt1.x ), y1 = toCanvasY( pt1.y );
var x2 = toCanvasX( pt2.x ), y2 = toCanvasY( pt2.y );
var mx = toCanvasX( pt2.x ), my = toCanvasY( pt1.y );
/* run — horizontal dashed line */
ctx.save();
ctx.strokeStyle = p.run;
ctx.lineWidth = 1.8;
ctx.setLineDash( [ 6, 4 ] );
ctx.beginPath(); ctx.moveTo( x1, my ); ctx.lineTo( mx, my ); ctx.stroke();
/* rise — vertical dashed line */
ctx.strokeStyle = p.rise;
ctx.beginPath(); ctx.moveTo( mx, my ); ctx.lineTo( x2, y2 ); ctx.stroke();
ctx.setLineDash( [] );
ctx.restore();
/* Δx label centred on run */
var rise = pt2.y - pt1.y;
var run = pt2.x - pt1.x;
ctx.font = '500 12px sans-serif';
ctx.textBaseline = 'middle';
ctx.textAlign = 'center';
ctx.fillStyle = p.run;
ctx.fillText( 'Δx = ' + run, ( x1 + mx ) / 2, my + ( rise >= 0 ? 14 : -14 ) );
/* Δy label centred on rise */
ctx.fillStyle = p.rise;
ctx.fillText( 'Δy = ' + rise, mx + ( run >= 0 ? 26 : -26 ), ( my + y2 ) / 2 );
}
/* ── extended line across canvas ── */
function lineBoxIntersections( m, b ) {
var pts = [];
var originY = RULER_H + ( H - RULER_H ) / 2;
/* convert canvas units to grid units for the line equation */
/* The line in canvas space: cy = originY - (m*(cx - W/2)/SCALE)*SCALE */
/* Simpler: work in grid coords and convert ends */
var xL = -( W / 2 ) / SCALE, xR = ( W / 2 ) / SCALE;
var yL = m * xL + b, yR = m * xR + b;
/* clip to vertical grid range */
var yMin = -( ( H - RULER_H ) / 2 ) / SCALE, yMax = ( ( H - RULER_H ) / 2 ) / SCALE;
function clip( x0, y0, x1, y1 ) {
var res = [];
/* just return the two endpoints; the line itself is clipped by canvas boundaries */
if ( y0 < yMin ) {
var t = ( yMin - y0 ) / ( y1 - y0 );
res.push( { x: x0 + t * ( x1 - x0 ), y: yMin } );
} else if ( y0 > yMax ) {
var t2 = ( yMax - y0 ) / ( y1 - y0 );
res.push( { x: x0 + t2 * ( x1 - x0 ), y: yMax } );
} else {
res.push( { x: x0, y: y0 } );
}
if ( y1 < yMin ) {
var t3 = ( yMin - y0 ) / ( y1 - y0 );
res.push( { x: x0 + t3 * ( x1 - x0 ), y: yMin } );
} else if ( y1 > yMax ) {
var t4 = ( yMax - y0 ) / ( y1 - y0 );
res.push( { x: x0 + t4 * ( x1 - x0 ), y: yMax } );
} else {
res.push( { x: x1, y: y1 } );
}
return res;
}
return clip( xL, yL, xR, yR );
}
/* ── main line draw ── */
function drawLine( p ) {
var pt1 = points[ 0 ], pt2 = points[ 1 ];
ctx.lineWidth = 2;
if ( pt1.x === pt2.x ) {
/* vertical line — dashed extension, solid between points */
ctx.strokeStyle = p.line;
ctx.setLineDash( [ 6, 6 ] );
ctx.beginPath();
ctx.moveTo( toCanvasX( pt1.x ), RULER_H );
ctx.lineTo( toCanvasX( pt1.x ), H );
ctx.stroke();
ctx.setLineDash( [] );
return;
}
var m = ( pt2.y - pt1.y ) / ( pt2.x - pt1.x );
var b = pt1.y - m * pt1.x;
var ends = lineBoxIntersections( m, b );
/* dashed extensions */
ctx.strokeStyle = p.line; ctx.setLineDash( [ 6, 6 ] );
ctx.beginPath();
ctx.moveTo( toCanvasX( ends[ 0 ].x ), toCanvasY( ends[ 0 ].y ) );
ctx.lineTo( toCanvasX( pt1.x ), toCanvasY( pt1.y ) );
ctx.stroke();
ctx.beginPath();
ctx.moveTo( toCanvasX( pt2.x ), toCanvasY( pt2.y ) );
ctx.lineTo( toCanvasX( ends[ 1 ].x ), toCanvasY( ends[ 1 ].y ) );
ctx.stroke();
ctx.setLineDash( [] );
/* solid segment between the two points */
ctx.beginPath();
ctx.moveTo( toCanvasX( pt1.x ), toCanvasY( pt1.y ) );
ctx.lineTo( toCanvasX( pt2.x ), toCanvasY( pt2.y ) );
ctx.stroke();
}
/* ── point dots + labels ── */
function drawPoints( p ) {
points.forEach( function ( pt, i ) {
var cx = toCanvasX( pt.x ), cy = toCanvasY( pt.y );
var other = points[ 1 - i ];
ctx.save();
ctx.beginPath();
ctx.arc( cx, cy, dragging === i ? 8 : 6.5, 0, Math.PI * 2 );
ctx.fillStyle = p.line;
ctx.fill();
ctx.strokeStyle = p.dotBg;
ctx.lineWidth = 2;
ctx.stroke();
ctx.restore();
/* label: align away from the other point */
var alignRight = pt.x > other.x;
var above = pt.y > other.y;
drawCoordLabel( '(' + pt.x + ', ' + pt.y + ')', cx, cy, alignRight, above, p );
} );
}
/* ── stats panel update ── */
function updateStats() {
var pt1 = points[ 0 ], pt2 = points[ 1 ];
var rise = pt2.y - pt1.y;
var run = pt2.x - pt1.x;
$( 'hw-sl-p1' ).textContent = '(' + pt1.x + ', ' + pt1.y + ')';
$( 'hw-sl-p2' ).textContent = '(' + pt2.x + ', ' + pt2.y + ')';
$( 'hw-sl-rise' ).textContent = rise;
$( 'hw-sl-run' ).textContent = run;
if ( run === 0 ) {
$( 'hw-sl-m' ).textContent = '—';
$( 'hw-sl-formula' ).innerHTML = t( 'undefined' );
return;
}
var m = rise / run;
var approx = parseFloat( m.toFixed( 3 ) );
$( 'hw-sl-m' ).textContent = approx;
/* fraction display */
$( 'hw-sl-formula' ).innerHTML =
'<span class="hw-fraction">' +
'<span class="hw-top">' + rise + '</span>' +
'<span class="hw-bottom">' + run + '</span>' +
'</span>' +
' = ' + approx;
}
/* ── main draw ── */
function draw() {
ctx.clearRect( 0, 0, W, H );
var p = palette();
drawGrid( p );
drawRuler( p );
drawLine( p );
drawSlopeTriangle( p );
drawPoints( p );
updateStats();
}
/* ── resize ── */
function resize() {
W = canvas.offsetWidth;
H = 440;
canvas.width = W * DPR;
canvas.height = H * DPR;
ctx.setTransform( DPR, 0, 0, DPR, 0, 0 );
draw();
}
/* ── pointer helpers ── */
function ptFromEvent( e ) {
var rect = canvas.getBoundingClientRect();
var src = e.touches ? e.touches[ 0 ] : e;
return {
x: ( src.clientX - rect.left ) * ( W / rect.width ),
y: ( src.clientY - rect.top ) * ( H / rect.height )
};
}
function hitTest( pt ) {
return points.findIndex( function ( gp ) {
return Math.hypot( toCanvasX( gp.x ) - pt.x, toCanvasY( gp.y ) - pt.y ) < 20;
} );
}
function clamp( gp ) {
return {
x: Math.max( -RANGE, Math.min( RANGE, gp.x ) ),
y: Math.max( -RANGE, Math.min( RANGE, gp.y ) )
};
}
canvas.addEventListener( 'mousedown', function ( e ) {
dragging = hitTest( ptFromEvent( e ) );
if ( dragging >= 0 ) { canvas.style.cursor = 'grabbing'; }
} );
canvas.addEventListener( 'mousemove', function ( e ) {
if ( dragging < 0 ) {
canvas.style.cursor = hitTest( ptFromEvent( e ) ) >= 0 ? 'grab' : 'crosshair';
return;
}
points[ dragging ] = clamp( fromCanvas( ptFromEvent( e ).x, ptFromEvent( e ).y ) );
draw();
} );
canvas.addEventListener( 'mouseup', function () { dragging = -1; canvas.style.cursor = 'crosshair'; } );
canvas.addEventListener( 'mouseleave', function () { dragging = -1; } );
canvas.addEventListener( 'touchstart', function ( e ) {
e.preventDefault(); dragging = hitTest( ptFromEvent( e ) );
}, { passive: false } );
canvas.addEventListener( 'touchmove', function ( e ) {
e.preventDefault();
if ( dragging < 0 ) { return; }
points[ dragging ] = clamp( fromCanvas( ptFromEvent( e ).x, ptFromEvent( e ).y ) );
draw();
}, { passive: false } );
canvas.addEventListener( 'touchend', function () { dragging = -1; } );
/* dark-mode live update (MediaWiki) */
var observer = new MutationObserver( draw );
observer.observe( document.documentElement, { attributes: true, attributeFilter: [ 'class' ] } );
observer.observe( document.body, { attributes: true, attributeFilter: [ 'class' ] } );
/* kick off */
var ro = new ResizeObserver( resize );
ro.observe( canvas );
}
/* ----------------------------------------------------------
* Initialise all matching containers on the page
* ---------------------------------------------------------- */
document.querySelectorAll( '.hiruwiki[data-module="slope"]' ).forEach( buildWidget );
}() );
dtry0a3zqmcun7pmdfyhl35503hiy3a
MediaWiki:Hiruwiki/modules/transformation.js
8
179525
781449
770252
2026-10-28T10:40:25Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781449
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/* transformation.js — hiruwiki geometry transformations module */
( function () {
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"nl": {
"_name": "Transformaties",
"angle": "Hoek",
"enlargement": "Vergroting",
"hintEnlargement": "Verplaats het oranje punt om het centrum van de vergroting in te stellen.",
"hintReflection": "Verplaats beide oranje punten om de spiegellijn te maken.",
"hintRotation": "Verplaats het oranje punt om het draaipunt in te stellen.",
"hintTranslation": "Gebruik de schuifregelaars om het object te verplaatsen.",
"horizontal": "Horizontaal",
"reflection": "Spiegeling",
"rotation": "Rotatie",
"transformations": "Transformaties",
"translation": "Translatie",
"vertical": "Verticaal",
"hint": "Kies een transformatie om deze in actie te zien. Gebruik de blauwe stippen om de vorm van de driehoek te wijzigen"
},
"fr": {
"_name": "Transformations",
"angle": "Angle",
"enlargement": "Homothétie",
"hintEnlargement": "Déplacez le point orange pour définir le centre de l'homothétie.",
"hintReflection": "Déplacez les deux points oranges pour créer la ligne de symétrie.",
"hintRotation": "Déplacez le point orange pour définir le centre de rotation.",
"hintTranslation": "Utilisez les curseurs pour translater l'objet.",
"horizontal": "Horizontal",
"reflection": "Symétrie",
"rotation": "Rotation",
"transformations": "Transformations",
"translation": "Translation",
"vertical": "Vertical",
"hint": "Choisissez une transformation pour la voir en action. Utilisez les points bleus pour changer la forme du triangle"
},
"eu": {
"_name": "Transformazioak",
"angle": "Angelua",
"enlargement": "Eskala",
"hintEnlargement": "Puntu laranja mugitu eskalaren jatorria ezartzeko.",
"hintReflection": "Bi puntu laranjak mugitu ispilu lerroa sortzeko.",
"hintRotation": "Puntu laranja mugitu errotazio zentroa ezartzeko.",
"hintTranslation": "Erabili barrak objektuaren translazioa egiteko.",
"horizontal": "Horizontala",
"reflection": "Islapena",
"rotation": "Biraketa",
"transformations": "Transformazioak",
"translation": "Translazioa",
"vertical": "Bertikala",
"hint": "Aukeratu transformazio bat martxan ikusteko. Erabili puntu urdinak triangeluaren forma aldatzeko."
},
"sk": {
"_name": "Transformácie",
"angle": "Uhol",
"enlargement": "Rovnoľahlosť",
"hintEnlargement": "Posuňte oranžový bod na nastavenie stredu rovnoľahlosti.",
"hintReflection": "Posuňte oba oranžové body na vytvorenie osi súmernosti.",
"hintRotation": "Posuňte oranžový bod na nastavenie stredu otočenia.",
"hintTranslation": "Pomocou posuvníkov posuňte objekt.",
"horizontal": "Vodorovne",
"reflection": "Osová súmernosť",
"rotation": "Otočenie",
"transformations": "Transformácie",
"translation": "Posunutie",
"vertical": "Zvisle",
"hint": "Vyberte transformáciu a uvidíte ju v akcii. Pomocou modrých bodov zmeníte tvar trojuholníka"
},
"skr-arab": {
"_name": "تبدیلی",
"angle": "زاویہ",
"enlargement": "وݙیائی",
"hintEnlargement": "وݙیائی دا مرکز مقرر کرݨ کیتے نارنجی نقطہ گھسیٹو۔",
"hintReflection": "شیشے آلی لکیر بݨاوݨ کیتے ݙوہیں نارنجی نقطے گھسیٹو۔",
"hintRotation": "گھماؤ دا مرکز مقرر کرݨ کیتے نارنجی نقطہ گھسیٹو۔",
"hintTranslation": "شے کوں منتقل کرݨ کیتے سلائیڈر استعمال کرو۔",
"horizontal": "افقی",
"reflection": "انعکاس",
"rotation": "گھماؤ",
"transformations": "تبدیلی",
"translation": "منتقلی",
"vertical": "عمودی",
"hint": "عمل وچ ویکھݨ کیتے کوئی تبدیلی چݨو۔ مثلث دی شکل بدلݨ کیتے نیلے نقطے استعمال کرو۔"
},
"lb": {
"angle": "Wénkel",
"horizontal": "Horizontal",
"transformations": "Transformatiounen",
"translation": "Translatioun"
},
"ko": {
"_name": "변환",
"angle": "각",
"enlargement": "확대",
"hintEnlargement": "주황색 점을 움직여 확대 중심을 설정하세요.",
"hintReflection": "두 개의 주황색 점을 움직여 대칭선을 만드세요.",
"hintRotation": "주황색 점을 움직여 회전 중심을 설정하세요.",
"hintTranslation": "슬라이더를 사용하여 개체를 평행이동 시키세요.",
"horizontal": "수평",
"reflection": "반전",
"rotation": "회전",
"transformations": "변환",
"translation": "평행 이동",
"vertical": "수직",
"hint": "원하는 변환을 선택하여 실제로 어떻게 적용되는지 확인해 보세요. 파란색 점을 사용하여 삼각형의 형태를 바꿔 보세요."
},
"en": {
"_name": "Transformations",
"angle": "Angle",
"enlargement": "Enlargement",
"hintEnlargement": "Move the orange point to set the center of enlargement.",
"hintReflection": "Move both orange points to create the mirror line.",
"hintRotation": "Move the orange point to set the center of rotation.",
"hintTranslation": "Use the sliders to translate the object.",
"horizontal": "Horizontal",
"reflection": "Reflection",
"rotation": "Rotation",
"transformations": "Transformations",
"translation": "Translation",
"vertical": "Vertical",
"hint": "Choose a transformation to see it in action. Use the blue dots to change the form of the triangle"
},
"ca": {
"angle": "Angle",
"enlargement": "Ampliació",
"horizontal": "Horitzontal",
"rotation": "Rotació",
"vertical": "Vertical"
},
"qqq": {
"_name": "Name of the Transformations module",
"angle": "Label for the rotation angle slider",
"enlargement": "Button label and heading for the enlargement transformation",
"hintEnlargement": "Instruction text for the enlargement mode",
"hintReflection": "Instruction text for the reflection mode",
"hintRotation": "Instruction text for the rotation mode",
"hintTranslation": "Instruction text for the translation mode",
"horizontal": "Label for the horizontal translation slider",
"reflection": "Button label and heading for the reflection transformation",
"rotation": "Button label and heading for the rotation transformation",
"transformations": "Main heading for the transformations panel",
"translation": "Button label and heading for the translation transformation",
"vertical": "Label for the vertical translation slider",
"hint": "Instruction text for the transformation module"
},
"ga": {
"_name": "Claochluithe",
"angle": "Uillinn",
"enlargement": "Méadú",
"hintEnlargement": "Bog an pointe oráiste chun lár an mhéadaithe a shocrú.",
"hintReflection": "Bog an dá phointe oráiste chun an líne scátháin a chruthú.",
"hintRotation": "Bog an pointe oráiste chun lár an rothlaithe a shocrú.",
"hintTranslation": "Bain úsáid as na sleamhnáin chun an réad a aistriú.",
"horizontal": "Cothrománach",
"reflection": "Machnamh",
"rotation": "Rothlú",
"transformations": "Claochluithe",
"translation": "Aistriúchán",
"vertical": "Ingearach",
"hint": "Roghnaigh claochlú chun é a fheiceáil i mbun gnímh. Úsáid na poncanna gorma chun cruth an triantáin a athrú."
},
"es": {
"_name": "Transformaciones",
"angle": "Ángulo",
"enlargement": "Homotecia",
"hintEnlargement": "Mueve el punto naranja para establecer el centro de la homotecia.",
"hintReflection": "Mueve ambos puntos naranjes para crear la línea de simetría.",
"hintRotation": "Mueve el punto naranja para establecer el centro de rotación.",
"hintTranslation": "Usa los controles deslizantes para trasladar el objeto.",
"horizontal": "Horizontal",
"reflection": "Simetría",
"rotation": "Rotación",
"transformations": "Transformaciones",
"translation": "Traslación",
"vertical": "Vertical",
"hint": "Elige una transformación para verla en acción. Usa los puntos azules para cambiar la forma del triángulo."
},
"mk": {
"_name": "Трансформации",
"angle": "Агол",
"enlargement": "Хомотетија",
"hintEnlargement": "Поместете ја портокаловата точка за да го зададете центарот на хомотетија.",
"hintReflection": "Поместете ги двете портокалови точки за да го создадете права на отслик.",
"hintRotation": "Поместете ја портокаловата точка за да го зададете центарот на вртење.",
"hintTranslation": "Послужете се со лизгачите за да направите транслација на фигурата.",
"horizontal": "Хоризонтала",
"reflection": "Отслик",
"rotation": "Вртење",
"transformations": "Трансформации",
"translation": "Транслација",
"vertical": "Вертикална",
"hint": "Изберете трансформација за да ја видите на дело. Послужете се со сините точки за да го измените обликот на триаголникот"
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
function init( el ) {
el.innerHTML = [
'<div class="hwt-wrap">',
'<div class="hwt-canvas-wrap"><canvas class="hwt-gc"></canvas></div>',
'<div class="hwt-panel">',
'<div class="hwt-panel-title">' + t('transformations') + '</div>',
'<div class="hwt-btn-group">',
'<button class="hwt-tbtn hwt-active" data-t="rotation">\u27f3 ' + t('rotation') + '</button>',
'<button class="hwt-tbtn" data-t="reflection">\u2194 ' + t('reflection') + '</button>',
'<button class="hwt-tbtn" data-t="enlargement">\u2924 ' + t('enlargement') + '</button>',
'<button class="hwt-tbtn" data-t="translation">\u2192 ' + t('translation') + '</button>',
'</div>',
'<div class="hwt-divider"></div>',
'<div class="hwt-ctrl-block hwt-ctrl-rotation">',
'<div class="hwt-hint">' + t('hintRotation') + '</div>',
'<div class="hwt-ctrl-label">' + t('angle') + ' <span class="hwt-ctrl-val hwt-rot-val">0\u00b0</span></div>',
'<input type="range" class="hwt-rot-slider" min="0" max="360" value="0" step="1">',
'</div>',
'<div class="hwt-ctrl-block hwt-ctrl-reflection" style="display:none">',
'<div class="hwt-hint">' + t('hintReflection') + '</div>',
'</div>',
'<div class="hwt-ctrl-block hwt-ctrl-enlargement" style="display:none">',
'<div class="hwt-hint">' + t('hintEnlargement') + '</div>',
'<div class="hwt-ctrl-label">' + t('enlargement') + ' <span class="hwt-ctrl-val hwt-scale-val">1.00\u00d7</span></div>',
'<input type="range" class="hwt-scale-slider" min="0.5" max="2" value="1" step="0.05">',
'</div>',
'<div class="hwt-ctrl-block hwt-ctrl-translation" style="display:none">',
'<div class="hwt-hint">' + t('hintTranslation') + '</div>',
'<div class="hwt-ctrl-label">' + t('horizontal') + ' <span class="hwt-ctrl-val hwt-tx-val">0</span></div>',
'<input type="range" class="hwt-tx-slider" min="-10" max="10" value="0" step="0.5">',
'<div class="hwt-ctrl-label">' + t('vertical') + ' <span class="hwt-ctrl-val hwt-ty-val">0</span></div>',
'<input type="range" class="hwt-ty-slider" min="-10" max="10" value="0" step="0.5">',
'</div>',
'</div>',
'</div>'
].join( '' );
var canvas = el.querySelector( '.hwt-gc' );
var ctx = canvas.getContext( '2d' );
var wrap = el.querySelector( '.hwt-canvas-wrap' );
var GX = [ -15, 15 ];
var GY = [ -10, 10 ];
var W = 0, H = 0;
/* ── coordinate helpers ── */
function gx( x ) { return ( x - GX[0] ) / ( GX[1] - GX[0] ) * W; }
function gy( y ) { return ( 1 - ( y - GY[0] ) / ( GY[1] - GY[0] ) ) * H; }
function sx( px ) { return px / W * ( GX[1] - GX[0] ) + GX[0]; }
function sy( py ) { return ( 1 - py / H ) * ( GY[1] - GY[0] ) + GY[0]; }
/* ── state ── */
var tri = [ { x: 2, y: 1 }, { x: 6, y: 1 }, { x: 4, y: 6 } ];
var mode = 'rotation';
var rotAngle = 0;
var rotCenter = { x: 0, y: 0 };
var refP1 = { x: -5, y: 0 };
var refP2 = { x: 5, y: 0 };
var scaleK = 1;
var enlargeCenter = { x: 0, y: 0 };
var txVal = 0;
var tyVal = 0;
var drag = null;
/* ── resize ── */
function resize() {
var r = wrap.getBoundingClientRect();
W = r.width;
H = r.height || Math.round( W * 2 / 3 );
canvas.width = Math.round( W * window.devicePixelRatio );
canvas.height = Math.round( H * window.devicePixelRatio );
canvas.style.width = W + 'px';
canvas.style.height = H + 'px';
ctx.scale( window.devicePixelRatio, window.devicePixelRatio );
redraw();
}
/* ── drawing helpers ── */
function drawGrid() {
ctx.clearRect( 0, 0, W, H );
ctx.fillStyle = hiruwiki.getThemeColor('background-color-base', '#fff');
ctx.fillRect( 0, 0, W, H );
ctx.strokeStyle = hiruwiki.getThemeColor('border-color-base', 'rgba(0,0,0,0.07)');
ctx.lineWidth = 0.5;
var x, y;
for ( x = GX[0]; x <= GX[1]; x++ ) {
ctx.beginPath(); ctx.moveTo( gx( x ), 0 ); ctx.lineTo( gx( x ), H ); ctx.stroke();
}
for ( y = GY[0]; y <= GY[1]; y++ ) {
ctx.beginPath(); ctx.moveTo( 0, gy( y ) ); ctx.lineTo( W, gy( y ) ); ctx.stroke();
}
ctx.strokeStyle = hiruwiki.getThemeColor('color-base', 'rgba(0,0,0,0.25)');
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo( gx( 0 ), 0 ); ctx.lineTo( gx( 0 ), H ); ctx.stroke();
ctx.beginPath(); ctx.moveTo( 0, gy( 0 ) ); ctx.lineTo( W, gy( 0 ) ); ctx.stroke();
var fs = Math.max( 10, Math.round( W / 70 ) );
ctx.fillStyle = hiruwiki.getThemeColor('color-placeholder', 'rgba(0,0,0,0.35)');
ctx.font = fs + 'px sans-serif';
ctx.textAlign = 'center';
for ( x = GX[0]; x <= GX[1]; x += 5 ) {
if ( x === 0 ) { continue; }
ctx.fillText( x, gx( x ), gy( 0 ) + 13 );
}
ctx.textAlign = 'right';
for ( y = GY[0]; y <= GY[1]; y += 5 ) {
if ( y === 0 ) { continue; }
ctx.fillText( y, gx( 0 ) - 5, gy( y ) + 4 );
}
}
function drawPoly( pts, stroke, fill, lw ) {
lw = lw || 2;
ctx.beginPath();
ctx.moveTo( gx( pts[0].x ), gy( pts[0].y ) );
for ( var i = 1; i < pts.length; i++ ) {
ctx.lineTo( gx( pts[i].x ), gy( pts[i].y ) );
}
ctx.closePath();
ctx.fillStyle = fill; ctx.fill();
ctx.strokeStyle = stroke; ctx.lineWidth = lw; ctx.stroke();
}
function drawPoint( x, y, color, r ) {
r = r || 6;
ctx.beginPath();
ctx.arc( gx( x ), gy( y ), r, 0, Math.PI * 2 );
ctx.fillStyle = color; ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.85)'; ctx.lineWidth = 1.5; ctx.stroke();
}
function drawLine( p1, p2, color, lw, dash ) {
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = lw || 1.5;
ctx.setLineDash( dash || [] );
ctx.beginPath();
ctx.moveTo( gx( p1.x ), gy( p1.y ) );
ctx.lineTo( gx( p2.x ), gy( p2.y ) );
ctx.stroke();
ctx.restore();
}
function drawVertexLabels() {
var labels = [ 'A', 'B', 'C' ];
var offsets = [ { x: -14, y: -8 }, { x: 10, y: -8 }, { x: 0, y: 14 } ];
var fs = Math.max( 11, Math.round( W / 55 ) );
ctx.font = 'bold ' + fs + 'px sans-serif';
ctx.fillStyle = '#2563eb';
for ( var i = 0; i < tri.length; i++ ) {
ctx.fillText( labels[i], gx( tri[i].x ) + offsets[i].x, gy( tri[i].y ) + offsets[i].y );
}
}
/* ── math helpers ── */
function rotPt( p, cx, cy, deg ) {
var a = deg * Math.PI / 180;
var dx = p.x - cx, dy = p.y - cy;
return {
x: cx + dx * Math.cos( a ) - dy * Math.sin( a ),
y: cy + dx * Math.sin( a ) + dy * Math.cos( a )
};
}
function reflectPt( p, p1, p2 ) {
var dx = p2.x - p1.x, dy = p2.y - p1.y;
var t = ( ( p.x - p1.x ) * dx + ( p.y - p1.y ) * dy ) / ( dx * dx + dy * dy );
var fx = p1.x + t * dx, fy = p1.y + t * dy;
return { x: 2 * fx - p.x, y: 2 * fy - p.y };
}
function extendLine( p1, p2 ) {
var dx = p2.x - p1.x, dy = p2.y - p1.y, big = 60;
return [
{ x: p1.x - dx * big, y: p1.y - dy * big },
{ x: p1.x + dx * big, y: p1.y + dy * big }
];
}
/* ── main redraw ── */
function redraw() {
if ( W === 0 ) { return; }
drawGrid();
if ( mode === 'rotation' ) {
var rimg = tri.map( function ( p ) {
return rotPt( p, rotCenter.x, rotCenter.y, rotAngle );
} );
if ( rotAngle !== 0 ) {
drawPoly( rimg, '#d05d20', 'rgba(208,93,32,0.12)' );
for ( var i = 0; i < tri.length; i++ ) {
drawLine( rotCenter, tri[i], 'rgba(245,158,11,0.4)', 1, [ 4, 4 ] );
drawLine( rotCenter, rimg[i], 'rgba(245,158,11,0.4)', 1, [ 4, 4 ] );
}
}
drawPoly( tri, '#2563eb', 'rgba(37,99,235,0.12)' );
for ( var j = 0; j < tri.length; j++ ) { drawPoint( tri[j].x, tri[j].y, '#2563eb', 5 ); }
drawPoint( rotCenter.x, rotCenter.y, '#f59e0b', 8 );
} else if ( mode === 'reflection' ) {
var ext = extendLine( refP1, refP2 );
var rfl = tri.map( function ( p ) { return reflectPt( p, refP1, refP2 ); } );
ctx.save();
ctx.strokeStyle = '#7c3aed'; ctx.lineWidth = 1.5; ctx.setLineDash( [ 6, 4 ] );
ctx.beginPath();
ctx.moveTo( gx( ext[0].x ), gy( ext[0].y ) );
ctx.lineTo( gx( ext[1].x ), gy( ext[1].y ) );
ctx.stroke();
ctx.restore();
for ( var k = 0; k < tri.length; k++ ) {
drawLine( tri[k], rfl[k], 'rgba(100,116,139,0.35)', 1, [ 3, 3 ] );
}
drawPoly( rfl, '#d05d20', 'rgba(208,93,32,0.12)' );
drawPoly( tri, '#2563eb', 'rgba(37,99,235,0.12)' );
for ( var l = 0; l < tri.length; l++ ) { drawPoint( tri[l].x, tri[l].y, '#2563eb', 5 ); }
for ( var m = 0; m < rfl.length; m++ ) { drawPoint( rfl[m].x, rfl[m].y, '#d05d20', 5 ); }
drawPoint( refP1.x, refP1.y, '#f59e0b', 8 );
drawPoint( refP2.x, refP2.y, '#f59e0b', 8 );
} else if ( mode === 'enlargement' ) {
var eimg = tri.map( function ( p ) {
return {
x: enlargeCenter.x + ( p.x - enlargeCenter.x ) * scaleK,
y: enlargeCenter.y + ( p.y - enlargeCenter.y ) * scaleK
};
} );
var big2 = 60;
for ( var n = 0; n < tri.length; n++ ) {
var dx2 = tri[n].x - enlargeCenter.x, dy2 = tri[n].y - enlargeCenter.y;
var far = { x: enlargeCenter.x + dx2 * big2, y: enlargeCenter.y + dy2 * big2 };
drawLine( enlargeCenter, far, 'rgba(245,158,11,0.4)', 0.8 );
}
drawPoly( eimg, '#d05d20', 'rgba(208,93,32,0.12)' );
drawPoly( tri, '#2563eb', 'rgba(37,99,235,0.12)' );
for ( var o = 0; o < tri.length; o++ ) { drawPoint( tri[o].x, tri[o].y, '#2563eb', 5 ); }
for ( var p2 = 0; p2 < eimg.length; p2++ ) { drawPoint( eimg[p2].x, eimg[p2].y, '#d05d20', 5 ); }
drawPoint( enlargeCenter.x, enlargeCenter.y, '#f59e0b', 8 );
} else if ( mode === 'translation' ) {
var timg = tri.map( function ( p ) { return { x: p.x + txVal, y: p.y + tyVal }; } );
for ( var q = 0; q < tri.length; q++ ) {
drawLine( tri[q], timg[q], 'rgba(100,116,139,0.4)', 1.2, [ 4, 3 ] );
}
drawPoly( timg, '#d05d20', 'rgba(208,93,32,0.12)' );
drawPoly( tri, '#2563eb', 'rgba(37,99,235,0.12)' );
for ( var s = 0; s < tri.length; s++ ) { drawPoint( tri[s].x, tri[s].y, '#2563eb', 5 ); }
for ( var t2 = 0; t2 < timg.length; t2++ ) { drawPoint( timg[t2].x, timg[t2].y, '#d05d20', 5 ); }
}
drawVertexLabels();
}
/* ── mode switching ── */
function setMode( m ) {
mode = m;
el.querySelectorAll( '.hwt-tbtn' ).forEach( function ( btn ) {
btn.classList.toggle( 'hwt-active', btn.dataset.t === m );
} );
var blocks = [ 'rotation', 'reflection', 'enlargement', 'translation' ];
blocks.forEach( function ( name ) {
var block = el.querySelector( '.hwt-ctrl-' + name );
if ( block ) { block.style.display = name === m ? 'flex' : 'none'; }
} );
redraw();
}
el.querySelectorAll( '.hwt-tbtn' ).forEach( function ( btn ) {
btn.addEventListener( 'click', function () { setMode( btn.dataset.t ); } );
} );
/* ── sliders ── */
el.querySelector( '.hwt-rot-slider' ).addEventListener( 'input', function () {
rotAngle = +this.value;
el.querySelector( '.hwt-rot-val' ).textContent = this.value + '\u00b0';
redraw();
} );
el.querySelector( '.hwt-scale-slider' ).addEventListener( 'input', function () {
scaleK = +this.value;
el.querySelector( '.hwt-scale-val' ).textContent = ( +this.value ).toFixed( 2 ) + '\u00d7';
redraw();
} );
el.querySelector( '.hwt-tx-slider' ).addEventListener( 'input', function () {
txVal = +this.value;
el.querySelector( '.hwt-tx-val' ).textContent = this.value;
redraw();
} );
el.querySelector( '.hwt-ty-slider' ).addEventListener( 'input', function () {
tyVal = +this.value;
el.querySelector( '.hwt-ty-val' ).textContent = this.value;
redraw();
} );
/* ── drag ── */
function hitRadius() { return Math.max( 14, W / 40 ); }
function getHandles() {
var base = [ tri[0], tri[1], tri[2] ];
if ( mode === 'rotation' ) { return base.concat( [ rotCenter ] ); }
if ( mode === 'reflection' ) { return base.concat( [ refP1, refP2 ] ); }
if ( mode === 'enlargement' ) { return base.concat( [ enlargeCenter ] ); }
return base;
}
function onPointerDown( e ) {
e.preventDefault();
var rect = canvas.getBoundingClientRect();
var cl = e.touches ? e.touches[0] : e;
var px = cl.clientX - rect.left;
var py = cl.clientY - rect.top;
var hr = hitRadius();
var handles = getHandles();
var best = null, bestD = Infinity;
handles.forEach( function ( h ) {
var d = Math.hypot( gx( h.x ) - px, gy( h.y ) - py );
if ( d < hr && d < bestD ) { bestD = d; best = h; }
} );
drag = best;
}
function onPointerMove( e ) {
if ( !drag ) { return; }
e.preventDefault();
var rect = canvas.getBoundingClientRect();
var cl = e.touches ? e.touches[0] : e;
var px = cl.clientX - rect.left;
var py = cl.clientY - rect.top;
drag.x = Math.round( sx( px ) * 2 ) / 2;
drag.y = Math.round( sy( py ) * 2 ) / 2;
redraw();
}
function onPointerUp() { drag = null; }
canvas.addEventListener( 'mousedown', onPointerDown );
canvas.addEventListener( 'mousemove', onPointerMove );
canvas.addEventListener( 'mouseup', onPointerUp );
canvas.addEventListener( 'touchstart', onPointerDown, { passive: false } );
canvas.addEventListener( 'touchmove', onPointerMove, { passive: false } );
canvas.addEventListener( 'touchend', onPointerUp );
/* ── resize observer ── */
if ( window.ResizeObserver ) {
new ResizeObserver( resize ).observe( wrap );
} else {
window.addEventListener( 'resize', resize );
}
resize();
// Footer branding
var footer = document.createElement("div");
footer.className = "hw-footer";
var fLogo = document.createElement("a");
fLogo.className = "hw-footer-icon";
fLogo.href = mw.util.getUrl('Wikipedia:Hiruwiki');
fLogo.title = 'Hiruwiki';
fLogo.innerHTML = hiruwiki.getLogoSvg(22);
var fText = document.createElement("span");
fText.innerHTML = t('hint');
footer.appendChild(fLogo);
footer.appendChild(fText);
el.appendChild(footer);
}
/* ── bootstrap: find all target elements ── */
document.querySelectorAll( '.hiruwiki[data-module="transformation"]' ).forEach( function ( el ) {
init( el );
} );
}() );
m72ay4xmckyai8z2xl62n9x4fg3afba
MediaWiki:Hiruwiki/modules/triangle-angles.js
8
179527
781450
770254
2026-10-28T10:40:28Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781450
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/* Hiruwiki Triangle Angles Module */
(function () {
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"nl": {
"_name": "Hoeken van een driehoek",
"reset": "Opnieuw instellen",
"hint": "Sleep hoekpunten om de driehoek te wijzigen · Som van de hoeken is altijd 180°",
"labelAngleA": "Hoek A",
"labelAngleB": "Hoek B",
"labelAngleC": "Hoek C",
"labelSum": "Som",
"formula": "A + B + C = 180°"
},
"fr": {
"_name": "Angles du triangle",
"reset": "Réinitialiser",
"hint": "Faites glisser les sommets pour changer le triangle · La somme des angles est toujours 180°",
"labelAngleA": "Angle A",
"labelAngleB": "Angle B",
"labelAngleC": "Angle C",
"labelSum": "Somme",
"formula": "A + B + C = 180°"
},
"eu": {
"_name": "Hirukiaren angeluak",
"reset": "Berrezarri",
"hint": "Arrastatu erpinak hirukia aldatzeko · Angeluen batura beti 180° da",
"labelAngleA": "A angelua",
"labelAngleB": "B angelua",
"labelAngleC": "C angelua",
"labelSum": "Batura",
"formula": "A + B + C = 180°"
},
"sk": {
"_name": "Uhly trojuholníka",
"reset": "Vynulovať",
"hint": "Potiahnutím vrcholov zmeníte trojuholník · Súčet uhlov je vždy 180°",
"labelAngleA": "Uhol A",
"labelAngleB": "Uhol B",
"labelAngleC": "Uhol C",
"labelSum": "Súčet",
"formula": "A + B + C = 180°"
},
"skr-arab": {
"_name": "مثلث دے زاویے",
"reset": "دوبارہ",
"hint": "مثلث بدلݨ کیتے سر گھسیٹو · زاویاں دا جوڑ ہمیشہ 180° ہوندے",
"labelAngleA": "زاویہ A",
"labelAngleB": "زاویہ B",
"labelAngleC": "زاویہ C",
"labelSum": "جوڑ",
"formula": "A + B + C = 180°"
},
"lb": {
"labelAngleA": "Wénkel A",
"labelAngleB": "Wénkel B",
"labelAngleC": "Wénkel C",
"formula": "A + B + C = 180°"
},
"ko": {
"_name": "삼각형의 각도",
"reset": "초기화",
"hint": "꼭짓점을 드래그하여 삼각형의 모양을 변경하세요. 각의 합은 항상 180°입니다.",
"labelAngleA": "각 A",
"labelAngleB": "각 B",
"labelAngleC": "각 C",
"labelSum": "총합",
"formula": "A + B + C = 180°"
},
"en": {
"_name": "Triangle Angles",
"reset": "Reset",
"hint": "Drag vertices to change the triangle · Sum of angles is always 180°",
"labelAngleA": "Angle A",
"labelAngleB": "Angle B",
"labelAngleC": "Angle C",
"labelSum": "Sum",
"formula": "A + B + C = 180°"
},
"ca": {
"reset": "Reinicia"
},
"qqq": {
"_name": "Name of the Triangle Angles module",
"reset": "Button label to reset the triangle",
"hint": "Explanation about how the module is interactive",
"labelAngleA": "Label for angle A",
"labelAngleB": "Label for angle B",
"labelAngleC": "Label for angle C",
"labelSum": "Label for the sum of angles",
"formula": "Text showing the formula A + B + C = 180°"
},
"ga": {
"_name": "Uillinneacha Triantáin",
"reset": "Athshocraigh",
"hint": "Tarraing na rinn chun an triantán a athrú · Is é suim na n-uillinneacha ná 180° i gcónaí",
"labelAngleA": "Uillinn A",
"labelAngleB": "Uillinn B",
"labelAngleC": "Uillinn C",
"labelSum": "Suim",
"formula": "A + B + C = 180°"
},
"es": {
"_name": "Ángulos del triángulo",
"reset": "Reiniciar",
"hint": "Arrastra los vértices para cambiar el triángulo · La suma de los ángulos es siempre 180°",
"labelAngleA": "Ángulo A",
"labelAngleB": "Ángulo B",
"labelAngleC": "Ángulo C",
"labelSum": "Suma",
"formula": "A + B + C = 180°"
},
"mk": {
"_name": "Агли на триаголник",
"reset": "Одново",
"hint": "Повлечете темиња за да го измените триаголникот · Збирот од аглите секогаш изнесува 180°",
"labelAngleA": "Агол A",
"labelAngleB": "Агол B",
"labelAngleC": "Агол C",
"labelSum": "Збир",
"formula": "A + B + C = 180°"
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
/* ── Shared constants ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var W = 680;
var H = 360;
var GRID = 24;
var GSW = 0.5;
var TSW = 2;
var DR = 8;
var NS = 'http://www.w3.org/2000/svg';
/*
* Angle data colors — fixed hues that encode identity (A=red, B=green, C=blue).
* These intentionally do not track the Codex theme; they are semantic data
* colors that must remain distinct in both light and dark mode.
* Values are chosen to be readable against both #ffffff and #101418.
* A: #C0392B — Codex-adjacent destructive red, readable on both
* B: #1d7a3a — mid-green, good contrast on both backgrounds
* C: #1a5fa5 — mid-blue, readable on light; slightly dim on dark but
* distinct from background. Kept for colour-identity reasons.
*/
var COL = { A: '#C0392B', B: '#1d7a3a', C: '#1a5fa5' };
/*
* Theme colors are now resolved via hiruwiki.getThemeColor() using Codex
* tokens, not via prefers-color-scheme. This correctly responds to the
* skin's dark mode toggle (Vector 2022 preferences), not just the OS setting.
*
* tcol() — primary text / triangle outline: color-base
* gcol() — grid lines: border-color-subtle at low opacity
* bgcol() — SVG background: background-color-base
* divCol() — pie chart divider line: border-color-base
*/
function tcol() { return hiruwiki.getThemeColor('color-base', '#202122'); }
function bgcol() { return hiruwiki.getThemeColor('background-color-base', '#ffffff'); }
function divCol(){ return hiruwiki.getThemeColor('border-color-base', '#a2a9b1'); }
/*
* Grid color: border-color-subtle doesn't support rgba directly, so we
* use it as a stroke and set opacity via stroke-opacity instead.
* border-color-subtle: #c8ccd1 light / #2e3136 dark
*/
function gcol() { return hiruwiki.getThemeColor('border-color-subtle', '#c8ccd1'); }
/* ── Widget factory ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
function createWidget(container) {
/* Canvas wrap + SVG */
var wrap = document.createElement('div');
wrap.className = 'hw-angles-wrap';
var svg = document.createElementNS(NS, 'svg');
svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
wrap.appendChild(svg);
container.appendChild(wrap);
/* Info bar */
var bar = document.createElement('div');
bar.className = 'hw-angles-bar';
var rstBtn = document.createElement('button');
rstBtn.className = 'hw-angles-btn-reset';
rstBtn.textContent = t('reset');
bar.appendChild(rstBtn);
function makeInfo(label, color) {
var info = document.createElement('div');
info.className = 'hw-info';
var lbl = document.createElement('span');
lbl.className = 'hw-lbl';
lbl.textContent = label;
var val = document.createElement('span');
val.className = 'hw-val';
val.textContent = '—';
if (color) { val.style.color = color; }
info.appendChild(lbl);
info.appendChild(val);
return val;
}
var elValA = makeInfo(t('labelAngleA'), COL.A);
var elValB = makeInfo(t('labelAngleB'), COL.B);
var elValC = makeInfo(t('labelAngleC'), COL.C);
bar.appendChild(elValA.parentElement);
bar.appendChild(elValB.parentElement);
bar.appendChild(elValC.parentElement);
var sumInfo = document.createElement('div');
sumInfo.className = 'hw-info';
sumInfo.style.flex = '2';
var sumLbl = document.createElement('span');
sumLbl.className = 'hw-lbl';
// Was hardcoded 'Sum' — now correctly uses the i18n key
sumLbl.textContent = t('labelSum');
var sumVal = document.createElement('span');
sumVal.className = 'hw-val';
sumVal.textContent = '180°';
var sumFormula = document.createElement('span');
sumFormula.className = 'hw-formula';
// Was hardcoded 'A + B + C = 180°' — now correctly uses the i18n key
sumFormula.textContent = t('formula');
sumInfo.appendChild(sumLbl);
sumInfo.appendChild(sumVal);
sumInfo.appendChild(sumFormula);
bar.appendChild(sumInfo);
container.appendChild(bar);
/* Footer branding */
var footer = document.createElement("div");
footer.className = "hw-footer";
var fLogo = document.createElement("a");
fLogo.className = "hw-footer-icon";
fLogo.href = (window.mw && mw.util && mw.util.getUrl) ? mw.util.getUrl('Wikipedia:Hiruwiki') : '#';
if (window.hiruwiki && window.hiruwiki.getLogoSvg) {
fLogo.insertAdjacentHTML('beforeend', hiruwiki.getLogoSvg(22));
} else {
var fImg = document.createElement('img');
fImg.src = 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/4e/Hiruwiki.svg/32px-Hiruwiki.svg.png';
fImg.width = 22;
fImg.height = 22;
fLogo.appendChild(fImg);
}
var fText = document.createElement("span");
fText.className = "hw-footer__text";
fText.textContent = t('hint');
footer.appendChild(fLogo);
footer.appendChild(fText);
container.appendChild(footer);
/* ── SVG helpers ── */
function mk(tag, attrs, p) {
var e = document.createElementNS(NS, tag);
for (var k in attrs) { if (attrs.hasOwnProperty(k)) { e.setAttribute(k, attrs[k]); } }
(p || svg).appendChild(e);
return e;
}
/* SVG background rect — background-color-base: #ffffff light / #101418 dark.
Must be the first child of the SVG so it sits behind everything.
Without it, SVG strokes resolve against an unknown background in dark mode. */
var bgRect = mk('rect', { width: W, height: H, fill: bgcol() });
/* Grid — redrawn on every upd() so it tracks theme changes */
var gG = mk('g', {});
function redrawGrid() {
gG.innerHTML = '';
var gc = gcol();
for (var x = 0; x <= W; x += GRID) {
mk('line', { x1: x, y1: 0, x2: x, y2: H,
stroke: gc, 'stroke-width': GSW, 'stroke-opacity': '0.5' }, gG);
}
for (var y = 0; y <= H; y += GRID) {
mk('line', { x1: 0, y1: y, x2: W, y2: y,
stroke: gc, 'stroke-width': GSW, 'stroke-opacity': '0.5' }, gG);
}
}
redrawGrid();
var arcA = mk('path', { fill: COL.A, opacity: '0.28' });
var arcB = mk('path', { fill: COL.B, opacity: '0.28' });
var arcC = mk('path', { fill: COL.C, opacity: '0.28' });
/* Triangle fill — color-progressive at very low opacity.
Using a fixed rgba here; the hue is progressive-adjacent and works
on both light and dark backgrounds at 0.07 opacity. */
var poly = mk('polygon', { fill: 'rgba(51,102,204,0.07)', 'stroke-width': TSW });
function mkDeg(fill) {
return mk('text', {
'font-size': 14, 'font-weight': '700', fill: fill,
'pointer-events': 'none', 'text-anchor': 'middle', 'dominant-baseline': 'middle'
});
}
var degA = mkDeg(COL.A), degB = mkDeg(COL.B), degC = mkDeg(COL.C);
function mkDot(fill) { return mk('circle', { r: DR, fill: fill, cursor: 'grab' }); }
var dotA = mkDot(COL.A), dotB = mkDot(COL.B), dotC = mkDot(COL.C);
function mkName() {
return mk('text', {
'font-size': 14, 'font-weight': '700',
'pointer-events': 'none', 'text-anchor': 'middle', 'dominant-baseline': 'middle'
// fill is set dynamically in upd() via tcol()
});
}
var nmA = mkName(), nmB = mkName(), nmC = mkName();
var sumG = mk('g', { transform: 'translate(' + (W / 2) + ',' + (H - 24) + ') rotate(180)' });
// Divider line — border-color-base, set dynamically in upd()
var sumLine = mk('line', { x1: -60, y1: 0, x2: 60, y2: 0, 'stroke-width': 0.5 }, sumG);
var sA = mk('path', { fill: COL.A, opacity: 0.55 }, sumG);
var sB = mk('path', { fill: COL.B, opacity: 0.55 }, sumG);
var sC = mk('path', { fill: COL.C, opacity: 0.55 }, sumG);
/* ── Math ── */
var side = 180, th = side * Math.sqrt(3) / 2, cx = W / 2, cy = (H - 50) / 2 + 8;
var init = { A: { x: cx, y: cy - th / 2 }, B: { x: cx - side / 2, y: cy + th / 2 }, C: { x: cx + side / 2, y: cy + th / 2 } };
var pts = JSON.parse(JSON.stringify(init));
function angAt(v, a, b) {
var ax = a.x - v.x, ay = a.y - v.y, bx = b.x - v.x, by = b.y - v.y;
var d = ax * bx + ay * by, m = Math.hypot(ax, ay) * Math.hypot(bx, by) || 1;
return Math.acos(Math.max(-1, Math.min(1, d / m))) * 180 / Math.PI;
}
function bis(v, a, b) {
var la = Math.hypot(a.x - v.x, a.y - v.y) || 1, lb = Math.hypot(b.x - v.x, b.y - v.y) || 1;
var dx = (a.x - v.x) / la + (b.x - v.x) / lb, dy = (a.y - v.y) / la + (b.y - v.y) / lb;
var l = Math.hypot(dx, dy) || 1;
return { x: dx / l, y: dy / l };
}
function arc(c, p1, p2, r) {
var v1 = { x: p1.x - c.x, y: p1.y - c.y }, v2 = { x: p2.x - c.x, y: p2.y - c.y };
var a1 = Math.atan2(v1.y, v1.x), a2 = Math.atan2(v2.y, v2.x);
if ((v1.x * v2.y - v1.y * v2.x) < 0) { var tmp = a1; a1 = a2; a2 = tmp; }
if (a2 < a1) { a2 += 2 * Math.PI; }
return 'M' + c.x + ',' + c.y + ' L' + (c.x + r * Math.cos(a1)) + ',' + (c.y + r * Math.sin(a1)) +
' A' + r + ',' + r + ' 0 0 1 ' + (c.x + r * Math.cos(a2)) + ',' + (c.y + r * Math.sin(a2)) + ' Z';
}
function pie(aA, aB, aC) {
var r = 54;
function w(s, sw) {
var a = s * Math.PI / 180, b = (s + sw) * Math.PI / 180;
return 'M0,0 L' + (r * Math.cos(a)) + ',' + (r * Math.sin(a)) +
' A' + r + ',' + r + ' 0 0 1 ' + (r * Math.cos(b)) + ',' + (r * Math.sin(b)) + ' Z';
}
sA.setAttribute('d', w(0, aA));
sB.setAttribute('d', w(aA, aB));
sC.setAttribute('d', w(aA + aB, aC));
}
/* ── Update ── */
function upd() {
var A = pts.A, B = pts.B, C = pts.C;
// Resolve theme colors on every update so the widget responds to
// skin dark-mode toggle without needing a page reload.
var tc = tcol();
var bg = bgcol();
var dc = divCol();
// Repaint background rect so it tracks theme changes
bgRect.setAttribute('fill', bg);
poly.setAttribute('points', A.x + ',' + A.y + ' ' + B.x + ',' + B.y + ' ' + C.x + ',' + C.y);
poly.setAttribute('stroke', tc);
var angA = angAt(A, B, C), angB = angAt(B, C, A), angC = angAt(C, A, B);
arcA.setAttribute('d', arc(A, B, C, 30));
arcB.setAttribute('d', arc(B, C, A, 30));
arcC.setAttribute('d', arc(C, A, B, 30));
var gx = (A.x + B.x + C.x) / 3, gy = (A.y + B.y + C.y) / 3;
[[degA, A, B, C, angA], [degB, B, C, A, angB], [degC, C, A, B, angC]].forEach(function (row) {
var el = row[0], v = row[1], a = row[2], b = row[3], ang = row[4];
var bd = bis(v, a, b);
el.setAttribute('x', v.x + bd.x * 50);
el.setAttribute('y', v.y + bd.y * 50);
el.textContent = ang.toFixed(1) + '°';
});
[[nmA, A, 'A'], [nmB, B, 'B'], [nmC, C, 'C']].forEach(function (row) {
var el = row[0], v = row[1], l = row[2];
var ox = v.x - gx, oy = v.y - gy, ol = Math.hypot(ox, oy) || 1;
el.setAttribute('x', v.x + ox / ol * 22);
el.setAttribute('y', v.y + oy / ol * 22);
// Vertex name labels use color-base so they're readable on both backgrounds
el.setAttribute('fill', tc);
el.textContent = l;
});
[[dotA, A], [dotB, B], [dotC, C]].forEach(function (row) {
row[0].setAttribute('cx', row[1].x);
row[0].setAttribute('cy', row[1].y);
});
elValA.textContent = angA.toFixed(1) + '°';
elValB.textContent = angB.toFixed(1) + '°';
elValC.textContent = angC.toFixed(1) + '°';
// Pie divider line — border-color-base tracks dark mode
sumLine.setAttribute('stroke', dc);
redrawGrid();
pie(angA, angB, angC);
}
/* ── Drag ── */
var drag = null;
var dm = { A: dotA, B: dotB, C: dotC };
function spt(e) {
var r = svg.getBoundingClientRect(), s = e.touches ? e.touches[0] : e;
return { x: (s.clientX - r.left) * (W / r.width), y: (s.clientY - r.top) * (H / r.height) };
}
['A', 'B', 'C'].forEach(function (k) {
dm[k].addEventListener('mousedown', function (e) {
e.preventDefault();
drag = k;
svg.style.cursor = 'grabbing';
dm[k].setAttribute('cursor', 'grabbing');
});
dm[k].addEventListener('touchstart', function (e) {
e.preventDefault();
drag = k;
}, { passive: false });
});
svg.addEventListener('mousemove', function (e) {
if (!drag) return;
var p = spt(e);
pts[drag] = { x: Math.max(12, Math.min(W - 12, p.x)), y: Math.max(12, Math.min(H - 30, p.y)) };
upd();
});
svg.addEventListener('touchmove', function (e) {
e.preventDefault();
if (!drag) return;
var p = spt(e);
pts[drag] = { x: Math.max(12, Math.min(W - 12, p.x)), y: Math.max(12, Math.min(H - 30, p.y)) };
upd();
}, { passive: false });
function stopDrag() {
if (drag) { dm[drag].setAttribute('cursor', 'grab'); drag = null; }
svg.style.cursor = 'crosshair';
}
svg.addEventListener('mouseup', stopDrag);
svg.addEventListener('mouseleave', stopDrag);
svg.addEventListener('touchend', stopDrag);
rstBtn.addEventListener('click', function () {
pts = JSON.parse(JSON.stringify(init));
upd();
});
// No longer listening on prefers-color-scheme — getThemeColor() reads
// resolved CSS variables on every upd() call, so toggling dark mode
// in Vector 2022 preferences is picked up on the next interaction.
// If instant re-render on theme toggle is needed, a MutationObserver
// on document.documentElement could call upd() when class changes.
upd();
}
/* ── Boot ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
function init() {
document.querySelectorAll('.hiruwiki[data-module="triangle-angles"]').forEach(function (el) {
createWidget(el);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
hwfika1roqdfjcsw7bcsv1z8gesk3ai
MediaWiki:Hiruwiki/modules/triangle-area.js
8
179529
781451
770256
2026-10-28T10:40:32Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781451
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/* Triangle Area Proof – Hiruwiki module
Injected into any element: <div class="hiruwiki" data-module="triangle-area"></div>
*/
(function () {
'use strict';
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"nl": {
"_name": "Oppervlakte van een driehoek",
"btnProve": "Bewijs het",
"btnReset": "Opnieuw instellen",
"labelArea": "Oppervlakte",
"labelBase": "Basis (b)",
"labelHeight": "Hoogte (h)",
"labelBaseShort": "b",
"labelHeightShort": "h",
"proofResultPara": "Oppervlakte parallellogram = b × h = <strong>$1 × $2 = $3 cm²</strong>",
"proofResultTri": "→ Driehoek = ½ × $1 = <strong>$2 cm²</strong>",
"unitCm": "cm",
"unitCm2": "cm²",
"hint": "Sleep de hoekpunten om de vorm van de driehoek te wijzigen"
},
"fr": {
"_name": "Aire du triangle",
"btnProve": "Prouver",
"btnReset": "Réinitialiser",
"labelArea": "Aire",
"labelBase": "Base (b)",
"labelHeight": "Hauteur (h)",
"labelBaseShort": "b",
"labelHeightShort": "h",
"proofResultPara": "Aire du parallélogramme = b × h = <strong>$1 × $2 = $3 cm²</strong>",
"proofResultTri": "→ Triangle = ½ × $1 = <strong>$2 cm²</strong>",
"unitCm": "cm",
"unitCm2": "cm²",
"hint": "Tirez les sommets pour changer la forme du triangle"
},
"eu": {
"_name": "Hirukiaren azalera",
"btnProve": "Froga ezazu",
"btnReset": "Berrezarri",
"labelArea": "Azalera",
"labelBase": "Oinarria (b)",
"labelHeight": "Altuera (h)",
"labelBaseShort": "b",
"labelHeightShort": "h",
"proofResultPara": "Paralelogramoaren azalera = b × h = <strong>$1 × $2 = $3 cm²</strong>",
"proofResultTri": "→ Triangeluarena = ½ × $1 = <strong>$2 cm²</strong>",
"unitCm": "cm",
"unitCm2": "cm²",
"hint": "Arrastatu erpinak triangeluaren forma aldatzeko"
},
"sk": {
"_name": "Obsah trojuholníka",
"btnProve": "Dokázať",
"btnReset": "Vynulovať",
"labelArea": "Obsah",
"labelBase": "Základňa (b)",
"labelHeight": "Výška (h)",
"labelBaseShort": "b",
"labelHeightShort": "h",
"proofResultPara": "Obsah rovnobežníka = b × h = <strong>$1 × $2 = $3 cm²</strong>",
"proofResultTri": "→ Trojuholník = ½ × $1 = <strong>$2 cm²</strong>",
"unitCm": "cm",
"unitCm2": "cm²",
"hint": "Potiahnutím vrcholov zmeníte tvar trojuholníka"
},
"skr-arab": {
"_name": "مثلث دا رقبہ",
"btnProve": "ثابت کرو",
"btnReset": "دوبارہ",
"labelArea": "رقبہ",
"labelBase": "قاعدہ (b)",
"labelHeight": "اُچائی (h)",
"labelBaseShort": "b",
"labelHeightShort": "h",
"proofResultPara": "متوازی الاضلاع دا رقبہ = b × h = <strong>$1 × $2 = $3 cm²</strong>",
"proofResultTri": "→ مثلث = ½ × $1 = <strong>$2 cm²</strong>",
"unitCm": "سم",
"unitCm2": "سم²",
"hint": "مثلث دی شکل بدلݨ کیتے سر گھسیٹو"
},
"lb": {
"labelArea": "Fläch",
"labelHeightShort": "h",
"unitCm": "cm",
"unitCm2": "cm²"
},
"ko": {
"_name": "삼각형 면적",
"btnProve": "증명하기",
"btnReset": "초기화",
"labelArea": "면적",
"labelBase": "밑변 (b)",
"labelHeight": "높이 (h)",
"labelBaseShort": "밑변",
"labelHeightShort": "높이",
"proofResultPara": "평행사변형 넓이 = b × h = <strong>$1 × $2 = $3 cm²</strong>",
"proofResultTri": "→ 삼각형 = ½ × $1 = <strong>$2 cm²</strong>",
"unitCm": "cm",
"unitCm2": "cm²",
"hint": "꼭짓점을 드래그하여 삼각형의 모양을 변경하기"
},
"en": {
"_name": "Triangle Area",
"btnProve": "Prove it",
"btnReset": "Reset",
"labelArea": "Area",
"labelBase": "Base (b)",
"labelHeight": "Height (h)",
"labelBaseShort": "b",
"labelHeightShort": "h",
"proofResultPara": "Parallelogram area = b × h = <strong>$1 × $2 = $3 cm²</strong>",
"proofResultTri": "→ Triangle = ½ × $1 = <strong>$2 cm²</strong>",
"unitCm": "cm",
"unitCm2": "cm²",
"hint": "Drag the vertices to change the triangle's shape"
},
"it": {
"unitCm": "cm",
"unitCm2": "cm²"
},
"ca": {
"btnReset": "Reinicia",
"labelArea": "Àrea",
"labelBaseShort": "b",
"labelHeightShort": "h",
"unitCm": "cm",
"unitCm2": "cm²"
},
"qqq": {
"_name": "Name of the Triangle Area module",
"btnProve": "Button label to start the area proof animation",
"btnReset": "Button label to reset the triangle",
"labelArea": "Label for the computed area value",
"labelBase": "Label for the base measurement, with variable name in parentheses",
"labelHeight": "Label for the height measurement, with variable name in parentheses",
"labelBaseShort": "Short symbol for base (usually 'b')",
"labelHeightShort": "Short symbol for height (usually 'h')",
"proofResultPara": "Proof result text for the parallelogram area. Uses HTML. Parameters: $1 = base, $2 = height, $3 = parallelogram area.",
"proofResultTri": "Proof result text for the triangle area (half the parallelogram). Uses HTML. Parameters: $1 = parallelogram area, $2 = triangle area.",
"unitCm": "Unit of length (centimeters)",
"unitCm2": "Unit of area (square centimeters)",
"hint": "Explanation about how the module is interactive"
},
"ga": {
"_name": "Limistéar an Triantáin",
"btnProve": "Cruthaigh é",
"btnReset": "Athshocraigh",
"labelArea": "Limistéar",
"labelBase": "Bonn (b)",
"labelHeight": "Airde (u)",
"labelBaseShort": "b",
"labelHeightShort": "h",
"proofResultPara": "Achar comhthreomharáin = b × h = <strong>$1 × $2 = $3 cm²</strong>",
"proofResultTri": "→ Triantán = ½ × $1 = <strong>$2 cm²</strong>",
"unitCm": "cm",
"unitCm2": "cm²",
"hint": "Tarraing na buaicphointí chun cruth an triantáin a athrú"
},
"es": {
"_name": "Área del triángulo",
"btnProve": "Pruébalo",
"btnReset": "Reiniciar",
"labelArea": "Área",
"labelBase": "Base (b)",
"labelHeight": "Altura (h)",
"labelBaseShort": "b",
"labelHeightShort": "h",
"proofResultPara": "Área del paralelogramo = b × h = <strong>$1 × $2 = $3 cm²</strong>",
"proofResultTri": "→ Triángulo = ½ × $1 = <strong>$2 cm²</strong>",
"unitCm": "cm",
"unitCm2": "cm²",
"hint": "Arrastra los vértices para cambiar la forma del triángulo"
},
"mk": {
"_name": "Плоштина на триаголник",
"btnProve": "Докажи",
"btnReset": "Одново",
"labelArea": "Плоштина",
"labelBase": "Основа (b)",
"labelHeight": "Висина (h)",
"labelBaseShort": "b",
"labelHeightShort": "h",
"proofResultPara": "Плоштина на паралелограм = b × h = <strong>$1 × $2 = $3 см²</strong>",
"proofResultTri": "→ Триаголник = ½ × $1 = <strong>$2 см²</strong>",
"unitCm": "см",
"unitCm2": "см²",
"hint": "Повлечете ги темињата за да го смените обликот на триаголникот"
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
var W = 680, H = 430, CM = 37.8, DUR = 1600;
function init(container) {
// Build DOM
container.className += ' hiruwiki-triangle-area';
container.innerHTML =
'<canvas></canvas>' +
'<div class="ta-info-bar">' +
'<div class="ta-info-block">' +
'<span class="ta-label">' + t('labelBase') + '</span>' +
'<span class="ta-value ta-val-b">—</span>' +
'</div>' +
'<div class="ta-info-block">' +
'<span class="ta-label">' + t('labelHeight') + '</span>' +
'<span class="ta-value ta-val-h">—</span>' +
'</div>' +
'<div class="ta-info-block" style="flex:2">' +
'<span class="ta-label">' + t('labelArea') + '</span>' +
'<span class="ta-value ta-val-area">—</span>' +
'<span class="ta-formula">A = ½ × b × h</span>' +
'</div>' +
'<button class="ta-btn ta-btn-proof">' + t('btnProve') + '</button>' +
'<div class="ta-proof-result"></div>' +
'</div>';
var cvs = container.querySelector('canvas');
cvs.className = 'hw-canvas';
var ctx = cvs.getContext('2d');
cvs.width = W;
cvs.height = H;
var elB = container.querySelector('.ta-val-b');
var elH = container.querySelector('.ta-val-h');
var elArea = container.querySelector('.ta-val-area');
var elResult = container.querySelector('.ta-proof-result');
var btnProof = container.querySelector('.ta-btn-proof');
// State
var V = {
A: { x: W / 2 - 2 * CM, y: 300 },
B: { x: W / 2 + 2 * CM, y: 300 },
C: { x: W / 2 + 0.3 * CM, y: 300 - 3 * CM }
};
var phase = 'idle', animStart = null, dragging = null;
// --- Math helpers ---
function dist(a, b) {
return Math.sqrt(Math.pow(b.x - a.x, 2) + Math.pow(b.y - a.y, 2));
}
function mid(a, b) {
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
}
function cross2d(ax, ay, bx, by) {
return ax * by - ay * bx;
}
function rotPt(P, M, angle) {
var c = Math.cos(angle), s = Math.sin(angle);
var dx = P.x - M.x, dy = P.y - M.y;
return { x: M.x + dx * c - dy * s, y: M.y + dx * s + dy * c };
}
function footOfLine(A, B, P) {
var dx = B.x - A.x, dy = B.y - A.y;
var t = ((P.x - A.x) * dx + (P.y - A.y) * dy) / (dx * dx + dy * dy);
return { x: A.x + t * dx, y: A.y + t * dy, t: t };
}
function fmt(px) {
return (px / CM).toFixed(2);
}
function ease(t) {
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
}
// P1-P2 = longest side (base), P3 = apex
function longestSide() {
var A = V.A, B = V.B, C = V.C;
var dAB = dist(A, B), dBC = dist(B, C), dCA = dist(C, A);
if (dAB >= dBC && dAB >= dCA) { return { P1: A, P2: B, P3: C, b: dAB }; }
if (dBC >= dAB && dBC >= dCA) { return { P1: B, P2: C, P3: A, b: dBC }; }
return { P1: C, P2: A, P3: B, b: dCA };
}
function metrics() {
var s = longestSide();
var foot = footOfLine(s.P1, s.P2, s.P3);
var h = dist(s.P3, foot);
return {
P1: s.P1, P2: s.P2, P3: s.P3, b: s.b, h: h, foot: foot,
bCm: fmt(s.b), hCm: fmt(h),
areaCm: (0.5 * (s.b / CM) * (h / CM)).toFixed(2)
};
}
// Arc midpoint of F (at +π/2) must be on OPPOSITE side of axis from otherPt
function rotDir(F, pivot, axP1, axP2, otherPt) {
var ax = axP2.x - axP1.x, ay = axP2.y - axP1.y;
var sideOther = cross2d(ax, ay, otherPt.x - axP1.x, otherPt.y - axP1.y);
var Fmid = rotPt(F, pivot, Math.PI / 2);
var sideFmid = cross2d(ax, ay, Fmid.x - axP1.x, Fmid.y - axP1.y);
return (sideFmid * sideOther < 0) ? 1 : -1;
}
function updateUI() {
var m = metrics();
elB.textContent = m.bCm + ' ' + t('unitCm');
elH.textContent = m.hCm + ' ' + t('unitCm');
elArea.textContent = m.areaCm + ' ' + t('unitCm2');
}
// --- Drawing ---
function drawGrid() {
ctx.strokeStyle = hiruwiki.getThemeColor('border-color-base', '#ccc');
ctx.lineWidth = 1.0;
for (var x = 0; x < W; x += CM) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
}
for (var y = 0; y < H; y += CM) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
}
}
function fillPoly(pts, fill, stroke, lw) {
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (var i = 1; i < pts.length; i++) { ctx.lineTo(pts[i].x, pts[i].y); }
ctx.closePath();
ctx.fillStyle = fill; ctx.fill();
ctx.lineJoin = 'round';
ctx.strokeStyle = stroke; ctx.lineWidth = lw || 2; ctx.stroke();
}
function drawVtx(pt, label) {
ctx.save();
ctx.beginPath(); ctx.arc(pt.x, pt.y, 8, 0, Math.PI * 2);
ctx.fillStyle = (label === 'A' ? 'red' : label === 'B' ? 'green' : 'blue'); ctx.fill();
ctx.font = 'bold 15px sans-serif';
ctx.fillStyle = (label === 'A' ? 'red' : label === 'B' ? 'green' : 'blue');
var ox = label === 'A' ? -20 : label === 'B' ? 12 : 10;
var oy = label === 'C' ? -13 : 20;
ctx.fillText(label, pt.x + ox, pt.y + oy);
ctx.restore();
}
function drawScene(m) {
var P1 = m.P1, P2 = m.P2, P3 = m.P3, foot = m.foot;
var fdx = foot.x - P3.x, fdy = foot.y - P3.y;
var flen = Math.sqrt(fdx * fdx + fdy * fdy) || 1;
// Extension line if foot outside base segment
if (foot.t < 0) {
ctx.save(); ctx.setLineDash([4, 4]);
ctx.strokeStyle = 'rgba(186,117,23,0.3)'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(foot.x, foot.y); ctx.lineTo(P1.x, P1.y); ctx.stroke();
ctx.setLineDash([]); ctx.restore();
} else if (foot.t > 1) {
ctx.save(); ctx.setLineDash([4, 4]);
ctx.strokeStyle = 'rgba(186,117,23,0.3)'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(P2.x, P2.y); ctx.lineTo(foot.x, foot.y); ctx.stroke();
ctx.setLineDash([]); ctx.restore();
}
// Dashed height line
ctx.save(); ctx.setLineDash([5, 5]);
ctx.strokeStyle = hiruwiki.getThemeColor('color-warning', '#BA7517'); ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(P3.x, P3.y); ctx.lineTo(foot.x, foot.y); ctx.stroke();
ctx.setLineDash([]); ctx.restore();
// Right angle mark at foot
if (flen > 14) {
var ux = fdx / flen, uy = fdy / flen, nx = -uy, ny = ux, s = 9;
ctx.beginPath();
ctx.moveTo(foot.x, foot.y);
ctx.lineTo(foot.x - ux * s, foot.y - uy * s);
ctx.lineTo(foot.x - ux * s + nx * s, foot.y - uy * s + ny * s);
ctx.lineTo(foot.x + nx * s, foot.y + ny * s);
ctx.closePath();
ctx.strokeStyle = hiruwiki.getThemeColor('color-warning', '#BA7517'); ctx.lineWidth = 1; ctx.stroke();
}
// h label (offset perpendicular to height)
var mx = (P3.x + foot.x) / 2, my = (P3.y + foot.y) / 2;
ctx.save(); ctx.font = '500 15px sans-serif';
ctx.fillStyle = hiruwiki.getThemeColor('color-warning', '#BA7517'); ctx.textAlign = 'center';
ctx.fillText(t('labelHeightShort') + ' = ' + m.hCm + ' ' + t('unitCm'), mx - fdy / flen * 16, my + fdx / flen * 16);
ctx.restore();
// b label (offset away from P3)
var bdx = P2.x - P1.x, bdy = P2.y - P1.y;
var bl = Math.sqrt(bdx * bdx + bdy * bdy) || 1;
var bnx = bdy / bl, bny = -bdx / bl;
var toCx = P3.x - (P1.x + P2.x) / 2, toCy = P3.y - (P1.y + P2.y) / 2;
var sign = (toCx * bnx + toCy * bny) > 0 ? -1 : 1;
ctx.save(); ctx.font = '500 15px sans-serif';
ctx.fillStyle = hiruwiki.getThemeColor('color-base', '#333'); ctx.textAlign = 'center';
ctx.fillText(t('labelBaseShort') + ' = ' + m.bCm + ' ' + t('unitCm'),
(P1.x + P2.x) / 2 + sign * bnx * 24,
(P1.y + P2.y) / 2 + sign * bny * 24);
ctx.restore();
}
function drawProof(progress) {
var m = metrics();
var P1 = m.P1, P2 = m.P2, P3 = m.P3;
var F = { x: m.foot.x, y: m.foot.y };
var e = ease(Math.min(progress, 1));
// Pivots = midpoints of the two SHORT sides
var mP1P3 = mid(P1, P3);
var mP2P3 = mid(P2, P3);
// Left piece (P1, F, P3): rotate around mid(P1,P3), F swings away from P2
var dirL = rotDir(F, mP1P3, P1, P3, P2);
var angL = Math.PI * e * dirL;
var lP1 = rotPt(P1, mP1P3, angL), lF = rotPt(F, mP1P3, angL), lP3 = rotPt(P3, mP1P3, angL);
fillPoly([lP1, lF, lP3], 'rgba(216,90,48,0.25)', '#D85A30', 2);
// Right piece (F, P2, P3): rotate around mid(P2,P3), F swings away from P1
var dirR = rotDir(F, mP2P3, P2, P3, P1);
var angR = Math.PI * e * dirR;
var rF = rotPt(F, mP2P3, angR), rP2 = rotPt(P2, mP2P3, angR), rP3 = rotPt(P3, mP2P3, angR);
fillPoly([rF, rP2, rP3], 'rgba(24,95,165,0.25)', hiruwiki.getThemeColor('color-progressive', '#185fa5'), 2);
if (e >= 0.99) {
ctx.save();
ctx.beginPath();
ctx.moveTo(P1.x, P1.y); ctx.lineTo(P2.x, P2.y);
ctx.lineTo(rF.x, rF.y); ctx.lineTo(lF.x, lF.y);
ctx.closePath();
ctx.lineJoin = 'round';
ctx.strokeStyle = hiruwiki.getThemeColor('color-base', '#333'); ctx.lineWidth = 1.5;
ctx.setLineDash([6, 3]); ctx.stroke(); ctx.setLineDash([]);
ctx.restore();
var rectArea = ((m.b / CM) * (m.h / CM)).toFixed(2);
elResult.style.display = 'block';
elResult.innerHTML = t('proofResultPara', [m.bCm, m.hCm, rectArea]) +
t('proofResultTri', [rectArea, m.areaCm]);
}
}
function draw(ts) {
ctx.clearRect(0, 0, W, H);
drawGrid();
var m = metrics();
fillPoly([V.A, V.B, V.C], 'rgba(29,158,117,0.14)', hiruwiki.getThemeColor('color-success', '#1d9e75'), 2);
// Highlight longest side (base)
ctx.save(); ctx.lineCap = 'round'; ctx.strokeStyle = hiruwiki.getThemeColor('color-success', '#1d9e75'); ctx.lineWidth = 3.5;
ctx.beginPath(); ctx.moveTo(m.P1.x, m.P1.y); ctx.lineTo(m.P2.x, m.P2.y); ctx.stroke();
ctx.restore();
drawScene(m);
drawVtx(V.A, 'A'); drawVtx(V.B, 'B'); drawVtx(V.C, 'C');
if (phase === 'animating' || phase === 'done') {
var elapsed = phase === 'done' ? DUR : (ts - animStart);
drawProof(elapsed / DUR);
if (elapsed < DUR && phase === 'animating') {
requestAnimationFrame(draw);
} else if (phase === 'animating') {
phase = 'done';
draw(performance.now());
}
}
}
function redraw() { requestAnimationFrame(function (ts) { draw(ts); }); }
// --- Button ---
btnProof.addEventListener('click', function () {
if (phase !== 'idle') {
phase = 'idle';
elResult.style.display = 'none';
btnProof.textContent = t('btnProve');
redraw();
return;
}
phase = 'animating'; animStart = null;
btnProof.textContent = t('btnReset');
requestAnimationFrame(function frame(ts) {
if (!animStart) { animStart = ts; }
draw(ts);
if (ts - animStart < DUR) { requestAnimationFrame(frame); }
else { phase = 'done'; draw(ts); }
});
});
// --- Pointer events ---
function cpos(e) {
var r = cvs.getBoundingClientRect(), sx = W / r.width;
var cx = e.touches ? e.touches[0].clientX : e.clientX;
var cy = e.touches ? e.touches[0].clientY : e.clientY;
return { x: (cx - r.left) * sx, y: (cy - r.top) * sx };
}
function hitV(pos) {
var keys = Object.keys(V);
for (var i = 0; i < keys.length; i++) {
if (dist(pos, V[keys[i]]) < 16) { return keys[i]; }
}
return null;
}
cvs.addEventListener('mousedown', function (e) {
var h = hitV(cpos(e));
if (h) { dragging = h; cvs.style.cursor = 'grabbing'; }
});
cvs.addEventListener('touchstart', function (e) {
var h = hitV(cpos(e));
if (h) { dragging = h; e.preventDefault(); }
}, { passive: false });
function onMove(e) {
var pos = cpos(e);
if (!dragging) {
cvs.style.cursor = hitV(pos) ? 'grab' : 'crosshair';
return;
}
pos.x = Math.max(20, Math.min(W - 20, pos.x));
pos.y = Math.max(20, Math.min(H - 20, pos.y));
V[dragging] = pos;
if (phase !== 'idle') {
phase = 'idle';
elResult.style.display = 'none';
btnProof.textContent = t('btnProve');
}
updateUI(); redraw();
}
cvs.addEventListener('mousemove', onMove);
cvs.addEventListener('touchmove', function (e) { onMove(e); e.preventDefault(); }, { passive: false });
function onUp() { dragging = null; cvs.style.cursor = 'crosshair'; }
window.addEventListener('mouseup', onUp);
window.addEventListener('touchend', onUp);
updateUI();
redraw();
// Footer — hint text defined in this module's own i18n, not loaded externally
var footer = document.createElement('div');
footer.className = 'hw-footer';
var fLogo = document.createElement('a');
fLogo.className = 'hw-footer-icon';
fLogo.href = (window.mw && mw.util.getUrl('Wikipedia:Hiruwiki')) || '#';
fLogo.title = 'Hiruwiki';
function appendLogo() {
if (window.hiruwiki && window.hiruwiki.getLogoSvg) {
fLogo.innerHTML = hiruwiki.getLogoSvg(22);
} else {
setTimeout(appendLogo, 50);
}
}
appendLogo();
var fText = document.createElement('span');
fText.className = 'hw-footer__text';
fText.innerHTML = t('hint');
footer.appendChild(fLogo);
footer.appendChild(fText);
container.appendChild(footer);
}
// Find and initialise all matching containers
mw.hook('wikipage.content').add(function () {
document.querySelectorAll('.hiruwiki[data-module="triangle-area"]').forEach(function (el) {
if (!el.dataset.taInit) {
el.dataset.taInit = '1';
init(el);
}
});
});
}());
amw8bsh4t0oe9wzhx8epmmrdt71xqg0
MediaWiki:Hiruwiki/modules/triangle-centroid.js
8
179531
781452
770258
2026-10-28T10:40:35Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781452
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/* =========================================================
* Hiruwiki module: triangle-centroid
* Visualisation of the centroid of a triangle
* =========================================================
* i18n: add a new key to the `messages` object below and
* a matching entry for every language code you need.
* Fall back to 'en' when the wiki language is absent.
* ========================================================= */
( function () {
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"nl": {
"_name": "Zwaartepunt van een driehoek",
"hint": "Sleep een hoekpunt om het zwaartepunt te verkennen"
},
"fr": {
"_name": "Centre de gravité du triangle",
"hint": "Faites glisser n'importe quel sommet pour explorer le centroïde"
},
"eu": {
"_name": "Hirukiaren barizentroa",
"hint": "Erpinak mugitu ditzakezu barizentroa ikusteko"
},
"sk": {
"_name": "Ťažisko trojuholníka",
"hint": "Potiahnutím ľubovoľného vrcholu preskúmate ťažisko"
},
"skr-arab": {
"_name": "مثلث دا مرکزِ ثقل",
"hint": "مرکزِ ثقل کوں ݙیکھݨ کیتے کوئی وی سرا گھسیٹو"
},
"ko": {
"_name": "삼각형 무게중심",
"hint": "꼭짓점을 드래그해서 무게중심을 탐구하세요"
},
"en": {
"_name": "Triangle Centroid",
"hint": "Drag any vertex to explore the centroid"
},
"qqq": {
"_name": "Name of the Triangle Centroid module",
"hint": "Instruction text shown in the footer."
},
"ga": {
"_name": "Lárphointe Triantáin",
"hint": "Tarraing aon bhuaicphointe chun an lárphointe a iniúchadh"
},
"es": {
"_name": "Baricentro del triángulo",
"hint": "Arrastra un vértice para explorar el baricentro"
},
"mk": {
"_name": "Тежиште на триаголник",
"hint": "Повлечете некое теме за да го истражите тежиштето"
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
/* ── Constants ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var SCALE = 40; /* pixels per centimetre */
var DPR = window.devicePixelRatio || 1;
/* ── Geometry helpers ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
function dist( a, b ) {
return Math.hypot( b.x - a.x, b.y - a.y );
}
function midpoint( A, B ) {
return { x: ( A.x + B.x ) / 2, y: ( A.y + B.y ) / 2 };
}
function centroid( A, B, C ) {
return { x: ( A.x + B.x + C.x ) / 3, y: ( A.y + B.y + C.y ) / 3 };
}
function angleDeg( V, A, B ) {
var ax = A.x - V.x, ay = A.y - V.y;
var bx = B.x - V.x, by = B.y - V.y;
var cos = ( ax * bx + ay * by ) /
( Math.hypot( ax, ay ) * Math.hypot( bx, by ) );
return Math.acos( Math.max( -1, Math.min( 1, cos ) ) ) * 180 / Math.PI;
}
function labelOffset( V, others, mag ) {
var dx = 0, dy = 0;
others.forEach( function ( O ) { dx += V.x - O.x; dy += V.y - O.y; } );
var l = Math.hypot( dx, dy ) || 1;
return { x: V.x + dx / l * mag, y: V.y + dy / l * mag };
}
/* ── Build widget ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
function buildWidget( container ) {
var lang = ( window.mw && mw.config.get( 'wgUserLanguage' ) ) || 'en';
lang = lang.split( '-' )[ 0 ];
if ( !messages[ lang ] ) { lang = 'en'; }
/* -- HTML skeleton -- */
container.innerHTML =
'<div class="hw-centroid">' +
'<canvas class="hw-canvas"></canvas>' +
'</div>';
/* -- Footer -- */
var footer = document.createElement( 'div' );
footer.className = 'hw-footer';
var fLogo = document.createElement( 'a' );
fLogo.className = 'hw-footer-icon';
fLogo.href = ( window.mw && mw.util.getUrl( 'Wikipedia:Hiruwiki' ) ) || '#';
fLogo.title = 'Hiruwiki';
if ( window.hiruwiki && window.hiruwiki.getLogoSvg ) {
fLogo.insertAdjacentHTML( 'beforeend', hiruwiki.getLogoSvg( 22 ) );
}
var fText = document.createElement( 'span' );
fText.className = 'hw-footer__text';
fText.textContent = messages[ lang ].hint;
footer.appendChild( fLogo );
footer.appendChild( fText );
container.appendChild( footer );
var canvas = container.querySelector( '.hw-canvas' );
var ctx = canvas.getContext( '2d' );
var W, H, verts, dragging = -1;
/* -- vertex initialisation -- */
function initVerts() {
var ox = W / 2 - 5.5 * SCALE;
var oy = 20;
verts = [
{ x: ox + 5.5 * SCALE, y: oy + 1.2 * SCALE },
{ x: ox + 1.2 * SCALE, y: oy + 8.0 * SCALE },
{ x: ox + 9.8 * SCALE, y: oy + 8.0 * SCALE }
];
}
/* -- colour palette -- */
function isDark() {
var root = document.documentElement;
if ( root.classList.contains( 'skin-theme-clientpref-night' ) ||
root.classList.contains( 'client-dark-mode' ) ||
document.body.classList.contains( 'mw-dark-mode' ) ) {
return true;
}
return false;
}
function palette() {
var dark = isDark();
return {
dark: dark,
tri: dark ? '#AFA9EC' : '#534AB7',
cen: dark ? '#EF9F27' : '#BA7517',
median: dark ? '#5DCAA5' : '#0F6E56',
grid: dark ? '#444441' : '#D3D1C7',
dotBg: dark ? '#1e1e1c' : '#ffffff'
};
}
/* -- grid (major cm lines only, very faint) -- */
function drawGrid( p ) {
ctx.lineWidth = 0.5;
ctx.strokeStyle = p.grid;
ctx.globalAlpha = 0.18;
for ( var cm = 0; cm * SCALE < W + SCALE; cm++ ) {
ctx.beginPath();
ctx.moveTo( cm * SCALE, 0 );
ctx.lineTo( cm * SCALE, H );
ctx.stroke();
}
for ( var row = 0; row * SCALE < H + SCALE; row++ ) {
ctx.beginPath();
ctx.moveTo( 0, row * SCALE );
ctx.lineTo( W, row * SCALE );
ctx.stroke();
}
ctx.globalAlpha = 1.0;
}
/* -- main draw -- */
function draw() {
if ( !verts ) { return; }
ctx.clearRect( 0, 0, W, H );
var p = palette();
drawGrid( p );
var A = verts[ 0 ], B = verts[ 1 ], C = verts[ 2 ];
var G = centroid( A, B, C );
var Ma = midpoint( B, C );
var Mb = midpoint( C, A );
var Mc = midpoint( A, B );
/* 1. dashed medians — full vertex-to-midpoint lines */
ctx.save();
ctx.strokeStyle = p.median; ctx.lineWidth = 1.4; ctx.setLineDash( [ 6, 5 ] );
[ [ A, Ma ], [ B, Mb ], [ C, Mc ] ].forEach( function ( pr ) {
ctx.beginPath(); ctx.moveTo( pr[ 0 ].x, pr[ 0 ].y ); ctx.lineTo( pr[ 1 ].x, pr[ 1 ].y ); ctx.stroke();
} );
ctx.setLineDash( [] ); ctx.restore();
/* 2. triangle fill + stroke */
ctx.save();
ctx.beginPath();
ctx.moveTo( A.x, A.y ); ctx.lineTo( B.x, B.y ); ctx.lineTo( C.x, C.y ); ctx.closePath();
ctx.fillStyle = p.dark ? 'rgba(175,169,236,.07)' : 'rgba(83,74,183,.06)';
ctx.fill();
ctx.strokeStyle = p.tri; ctx.lineWidth = 2; ctx.stroke();
ctx.restore();
/* 3. midpoint dots */
[ Ma, Mb, Mc ].forEach( function ( M ) {
ctx.save();
ctx.beginPath(); ctx.arc( M.x, M.y, 4, 0, Math.PI * 2 );
ctx.fillStyle = p.median; ctx.fill();
ctx.restore();
} );
/* 4. centroid dot */
ctx.save();
ctx.beginPath(); ctx.arc( G.x, G.y, 8, 0, Math.PI * 2 );
ctx.fillStyle = p.cen; ctx.fill();
ctx.strokeStyle = p.dotBg; ctx.lineWidth = 2; ctx.stroke();
ctx.restore();
/* 5. vertex dots */
verts.forEach( function ( V, i ) {
ctx.save();
ctx.beginPath(); ctx.arc( V.x, V.y, dragging === i ? 8 : 6.5, 0, Math.PI * 2 );
ctx.fillStyle = p.tri; ctx.fill();
ctx.strokeStyle = p.dotBg; ctx.lineWidth = 2; ctx.stroke();
ctx.restore();
} );
/* 6. text labels */
ctx.save();
ctx.textBaseline = 'middle';
/* vertex names */
ctx.font = "italic 700 18px 'Latin Modern Math', 'STIX Two Math', 'Computer Modern', 'Times New Roman', serif";
[ A, B, C ].forEach( function ( V, i ) {
var lbl = labelOffset( V, verts.filter( function ( _, j ) { return j !== i; } ), 26 );
ctx.fillStyle = p.tri; ctx.textAlign = 'center';
ctx.fillText( [ 'A', 'B', 'C' ][ i ], lbl.x, lbl.y );
} );
/* G label */
ctx.fillStyle = p.cen;
ctx.font = "italic 700 18px 'Latin Modern Math', 'STIX Two Math', 'Computer Modern', 'Times New Roman', serif";
ctx.textAlign = 'center';
ctx.fillText( 'G', G.x + 16, G.y - 16 );
ctx.restore();
}
/* -- resize -- */
function resize() {
W = canvas.offsetWidth;
H = 440;
canvas.width = W * DPR;
canvas.height = H * DPR;
ctx.setTransform( DPR, 0, 0, DPR, 0, 0 );
initVerts();
draw();
}
/* -- pointer helpers -- */
function ptFromEvent( e ) {
var rect = canvas.getBoundingClientRect();
var src = e.touches ? e.touches[ 0 ] : e;
return {
x: ( src.clientX - rect.left ) * ( W / rect.width ),
y: ( src.clientY - rect.top ) * ( H / rect.height )
};
}
function hitTest( pt ) {
return verts.findIndex( function ( v ) {
return Math.hypot( v.x - pt.x, v.y - pt.y ) < 16;
} );
}
function clamp( pt ) {
return {
x: Math.max( 10, Math.min( W - 10, pt.x ) ),
y: Math.max( 4, Math.min( H - 10, pt.y ) )
};
}
canvas.addEventListener( 'mousedown', function ( e ) { dragging = hitTest( ptFromEvent( e ) ); if ( dragging >= 0 ) { canvas.style.cursor = 'grabbing'; } } );
canvas.addEventListener( 'mousemove', function ( e ) {
if ( dragging < 0 ) {
canvas.style.cursor = hitTest( ptFromEvent( e ) ) >= 0 ? 'grab' : 'crosshair';
return;
}
verts[ dragging ] = clamp( ptFromEvent( e ) );
draw();
} );
canvas.addEventListener( 'mouseup', function () { dragging = -1; canvas.style.cursor = 'crosshair'; } );
canvas.addEventListener( 'mouseleave', function () { dragging = -1; } );
canvas.addEventListener( 'touchstart', function ( e ) {
e.preventDefault(); dragging = hitTest( ptFromEvent( e ) );
}, { passive: false } );
canvas.addEventListener( 'touchmove', function ( e ) {
e.preventDefault();
if ( dragging < 0 ) { return; }
verts[ dragging ] = clamp( ptFromEvent( e ) );
draw();
}, { passive: false } );
canvas.addEventListener( 'touchend', function () { dragging = -1; } );
/* dark-mode live update (MediaWiki) */
var observer = new MutationObserver( draw );
observer.observe( document.documentElement, { attributes: true, attributeFilter: [ 'class' ] } );
observer.observe( document.body, { attributes: true, attributeFilter: [ 'class' ] } );
/* kick off */
var ro = new ResizeObserver( resize );
ro.observe( canvas );
}
/* ----------------------------------------------------------
* Initialise all matching containers on the page
* ---------------------------------------------------------- */
document.querySelectorAll( '.hiruwiki[data-module="triangle-centroid"]' ).forEach( buildWidget );
}() );
8u2f35hcnau4371odo7apj2l14s3cjb
MediaWiki:Hiruwiki/modules/triangles.js
8
179533
781453
770260
2026-10-28T10:40:38Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781453
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
(function () {
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"nl": {
"_name": "Driehoeken",
"acute": "scherphoekig",
"angles": "Hoeken",
"area": "Oppervlakte",
"byAngles": "Op basis van hoeken:",
"bySides": "Op basis van zijden:",
"classification": "Classificatie",
"equilateral": "gelijkzijdig",
"isosceles": "gelijkbenig",
"measurements": "Afmetingen",
"obtuse": "stomphoekig",
"perimeter": "Omtrek",
"reset": "Opnieuw instellen ↺",
"right": "rechthoekig",
"scalene": "ongelijkzijdig",
"sides": "Zijden",
"sideA": "zijde a (BC)",
"sideB": "zijde b (CA)",
"sideC": "zijde c (AB)",
"unitCm": "cm",
"unitCm2": "cm²",
"hint": "Sleep hoekpunten om de vorm en classificatie te wijzigen"
},
"fr": {
"_name": "Triangles",
"acute": "acutangle",
"angles": "Angles",
"area": "Aire",
"byAngles": "Par angles :",
"bySides": "Par côtés :",
"classification": "Classification",
"equilateral": "équilatéral",
"isosceles": "isocèle",
"measurements": "Mesures",
"obtuse": "obtusangle",
"perimeter": "Périmètre",
"reset": "↺ Réinitialiser",
"right": "rectangle",
"scalene": "scalène",
"sides": "Côtés",
"sideA": "côté a (BC)",
"sideB": "côté b (CA)",
"sideC": "côté c (AB)",
"unitCm": "cm",
"unitCm2": "cm²",
"hint": "Tirez les sommets pour changer de forme et de classification"
},
"eu": {
"_name": "Hirukiak",
"acute": "zorrotza",
"angles": "Angeluak",
"area": "Azalera",
"byAngles": "Angeluen arabera:",
"bySides": "Aldeen arabera:",
"classification": "Sailkapena",
"equilateral": "aldekidea",
"isosceles": "isoszelea",
"measurements": "Neurriak",
"obtuse": "kamutsa",
"perimeter": "Perimetroa",
"reset": "Berrezarri ↺",
"right": "zuzena",
"scalene": "eskaleno",
"sides": "Aldeak",
"sideA": "a aldea (BC)",
"sideB": "b aldea (CA)",
"sideC": "c aldea (AB)",
"unitCm": "cm",
"unitCm2": "cm²",
"hint": "Arrastatu erpinak forma eta sailkapena aldatzeko"
},
"sk": {
"_name": "Trojuholníky",
"acute": "ostrouhlý",
"angles": "Uhly",
"area": "Obsah",
"byAngles": "Podľa uhlov:",
"bySides": "Podľa strán:",
"classification": "Klasifikácia",
"equilateral": "rovnostranný",
"isosceles": "rovnoramenný",
"measurements": "Rozmery",
"obtuse": "tupouhlý",
"perimeter": "Obvod",
"reset": "Vynulovať ↺",
"right": "pravouhlý",
"scalene": "rôznostranný",
"sides": "Strany",
"sideA": "strana a (BC)",
"sideB": "strana b (CA)",
"sideC": "strana c (AB)",
"unitCm": "cm",
"unitCm2": "cm²",
"hint": "Potiahnutím vrcholov zmeníte tvar a klasifikáciu"
},
"skr-arab": {
"_name": "مثلثیں",
"acute": "حاد الزاویہ",
"angles": "زاویے",
"area": "رقبہ",
"byAngles": "زاویے دے لحاظ نال:",
"bySides": "ضلعے دے لحاظ نال:",
"classification": "درجہ بندی",
"equilateral": "مساوی الاضلاع",
"isosceles": "مساوی الساقین",
"measurements": "پیمائشاں",
"obtuse": "منفرج الزاویہ",
"perimeter": "گھیر",
"reset": "دوبارہ ↺",
"right": "قائم الزاویہ",
"scalene": "مختلف الاضلاع",
"sides": "ضلعے",
"sideA": "ضلع a (BC)",
"sideB": "ضلع b (CA)",
"sideC": "ضلع c (AB)",
"unitCm": "سم",
"unitCm2": "سم²",
"hint": "شکل تے درجہ بندی بدلݨ کیتے سرے گھسیٹو"
},
"lb": {
"angles": "Wénkelen",
"area": "Fläch",
"perimeter": "Ëmfang",
"sides": "Säiten",
"unitCm": "cm",
"unitCm2": "cm²"
},
"ko": {
"_name": "삼각형",
"acute": "예각 삼각형",
"angles": "각",
"area": "면적",
"byAngles": "각별로:",
"bySides": "변별로:",
"classification": "분류",
"equilateral": "정삼각형",
"isosceles": "이등변삼각형",
"measurements": "측정",
"obtuse": "둔각 삼각형",
"perimeter": "둘레",
"reset": "초기화 ↺",
"right": "직각 삼각형",
"scalene": "부등변삼각형",
"sides": "변",
"sideA": "변 a (BC)",
"sideB": "변 b (CA)",
"sideC": "변 c (AB)",
"unitCm": "cm",
"unitCm2": "cm²",
"hint": "꼭짓점을 드래그하여 모양과 분류를 변경하세요"
},
"en": {
"_name": "Triangles",
"acute": "acute",
"angles": "Angles",
"area": "Area",
"byAngles": "By angles:",
"bySides": "By sides:",
"classification": "Classification",
"equilateral": "equilateral",
"isosceles": "isosceles",
"measurements": "Measurements",
"obtuse": "obtuse",
"perimeter": "Perimeter",
"reset": "Reset ↺",
"right": "right",
"scalene": "scalene",
"sides": "Sides",
"sideA": "side a (BC)",
"sideB": "side b (CA)",
"sideC": "side c (AB)",
"unitCm": "cm",
"unitCm2": "cm²",
"hint": "Drag vertices to change shape and classification"
},
"it": {
"angles": "Angoli",
"area": "Area",
"byAngles": "Per angoli:",
"bySides": "Per lati:",
"classification": "Classificazione",
"measurements": "Misure",
"perimeter": "Perimetro",
"unitCm": "cm",
"unitCm2": "cm²"
},
"ca": {
"angles": "Angles",
"right": "recte",
"unitCm": "cm",
"unitCm2": "cm²"
},
"qqq": {
"_name": "Name of the Triangles module",
"acute": "Classification name for an acute triangle (all angles < 90°)",
"angles": "Section heading for the angles display",
"area": "Label for the triangle area",
"byAngles": "Label prefix for the angle-based classification. Followed by the classification name.",
"bySides": "Label prefix for the side-based classification. Followed by the classification name.",
"classification": "Section heading for the triangle classification",
"equilateral": "Classification name for an equilateral triangle (all sides equal)",
"isosceles": "Classification name for an isosceles triangle (two sides equal)",
"measurements": "Section heading for the measurements display",
"obtuse": "Classification name for an obtuse triangle (one angle > 90°)",
"perimeter": "Label for the triangle perimeter",
"reset": "Button label to reset the triangle",
"right": "Classification name for a right triangle (one angle = 90°)",
"scalene": "Classification name for a scalene triangle (no sides equal)",
"sides": "Section heading for the sides display",
"sideA": "Label for side a (the one connecting vertices B and C)",
"sideB": "Label for side b (the one connecting vertices C and A)",
"sideC": "Label for side c (the one connecting vertices A and B)",
"unitCm": "Unit of length (centimeters)",
"unitCm2": "Unit of area (square centimeters)",
"hint": "Explanation about how the module is interactive"
},
"ga": {
"_name": "Triantáin",
"acute": "géarmhíochaine",
"angles": "Uillinneacha",
"area": "Limistéar",
"byAngles": "De réir uillinneacha:",
"bySides": "De réir taobhanna:",
"classification": "Aicmiú",
"equilateral": "comhshleasach",
"isosceles": "comhchosach",
"measurements": "Tomhais",
"obtuse": "maol",
"perimeter": "Imlíne",
"reset": "Athshocraigh ↺",
"right": "ar dheis",
"scalene": "corrshleasach",
"sides": "Taobhanna",
"sideA": "taobh a (RC)",
"sideB": "taobh b (CA)",
"sideC": "taobh c (AB)",
"unitCm": "cm",
"unitCm2": "cm²",
"hint": "Tarraing buaicphointí chun cruth agus aicmiú a athrú"
},
"es": {
"_name": "Triángulos",
"acute": "acutángulo",
"angles": "Ángulos",
"area": "Área",
"byAngles": "Por ángulos:",
"bySides": "Por lados:",
"classification": "Clasificación",
"equilateral": "equilátero",
"isosceles": "isósceles",
"measurements": "Medidas",
"obtuse": "obtusángulo",
"perimeter": "Perímetro",
"reset": "↺ Reiniciar",
"right": "rectángulo",
"scalene": "escaleno",
"sides": "Lados",
"sideA": "lado a (BC)",
"sideB": "lado b (CA)",
"sideC": "lado c (AB)",
"unitCm": "cm",
"unitCm2": "cm²",
"hint": "Arrastra los vértices para cambiar la forma y la clasificación"
},
"mk": {
"_name": "Триаголници",
"acute": "остар",
"angles": "Агли",
"area": "Плоштина",
"byAngles": "По агли:",
"bySides": "По страни:",
"classification": "Класификација",
"equilateral": "рамностран",
"isosceles": "рамнокрак",
"measurements": "Димензии",
"obtuse": "тап",
"perimeter": "Обем",
"reset": "Одново ↺",
"right": "правоаголен",
"scalene": "разностран",
"sides": "Страни",
"sideA": "страна a (BC)",
"sideB": "страна b (CA)",
"sideC": "страна c (AB)",
"unitCm": "см",
"unitCm2": "см²",
"hint": "Повлечете ги темињата за да ја смените фигурата и класификацијата"
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
function initTriangleWidget(container) {
var boardWidth = 20;
var boardHeight = 14;
/* ── Build layout ── */
var layout = document.createElement("div");
layout.className = "triangle-layout";
var canvasWrap = document.createElement("div");
canvasWrap.className = "triangle-canvas-wrap";
/* ── Side panel ── */
var panel = document.createElement("div");
panel.className = "triangle-panel";
/* Classification section */
var secClass = document.createElement("div");
secClass.className = "triangle-panel-section";
var lblClass = document.createElement("div");
lblClass.className = "triangle-panel-label";
lblClass.textContent = t('classification');
secClass.appendChild(lblClass);
var badgeSidesWrap = document.createElement("div");
badgeSidesWrap.className = "triangle-badge-wrap";
var badgeSidesLbl = document.createElement("span");
badgeSidesLbl.className = "triangle-badge-sublabel";
badgeSidesLbl.textContent = t('bySides');
var badgeSides = document.createElement("span");
badgeSides.className = "triangle-badge triangle-badge-sides";
badgeSidesWrap.appendChild(badgeSidesLbl);
badgeSidesWrap.appendChild(document.createElement("br"));
badgeSidesWrap.appendChild(badgeSides);
var badgeAnglesWrap = document.createElement("div");
badgeAnglesWrap.className = "triangle-badge-wrap";
var badgeAnglesLbl = document.createElement("span");
badgeAnglesLbl.className = "triangle-badge-sublabel";
badgeAnglesLbl.textContent = t('byAngles');
var badgeAngles = document.createElement("span");
badgeAngles.className = "triangle-badge triangle-badge-angles";
badgeAnglesWrap.appendChild(badgeAnglesLbl);
badgeAnglesWrap.appendChild(document.createElement("br"));
badgeAnglesWrap.appendChild(badgeAngles);
secClass.appendChild(badgeSidesWrap);
secClass.appendChild(badgeAnglesWrap);
panel.appendChild(secClass);
/* Sides section */
var secSides = document.createElement("div");
secSides.className = "triangle-panel-section";
var lblSides = document.createElement("div");
lblSides.className = "triangle-panel-label";
lblSides.textContent = t('sides');
secSides.appendChild(lblSides);
var dotClasses = ["triangle-dot--a", "triangle-dot--b", "triangle-dot--c"];
var sideLabelClasses = ["triangle-side-label--c", "triangle-side-label--a", "triangle-side-label--b"];
var sideNames = [t('sideA'), t('sideB'), t('sideC')];
var sideValEls = [];
for (var si = 0; si < 3; si++) {
var srow = document.createElement("div");
srow.className = "triangle-measure-row";
var skey = document.createElement("span");
skey.className = "triangle-measure-key";
var sdot = document.createElement("span");
sdot.className = "triangle-dot " + dotClasses[si];
skey.appendChild(sdot);
skey.appendChild(document.createTextNode(sideNames[si]));
var sval = document.createElement("span");
sval.className = "triangle-measure-val";
sideValEls.push(sval);
srow.appendChild(skey);
srow.appendChild(sval);
secSides.appendChild(srow);
}
panel.appendChild(secSides);
/* Angles section */
var secAngles = document.createElement("div");
secAngles.className = "triangle-panel-section";
var lblAngles = document.createElement("div");
lblAngles.className = "triangle-panel-label";
lblAngles.textContent = t('angles');
secAngles.appendChild(lblAngles);
var angleNames = ["\u03b1 (A)", "\u03b2 (B)", "\u03b3 (C)"];
var angleValEls = [];
for (var ai = 0; ai < 3; ai++) {
var arow = document.createElement("div");
arow.className = "triangle-measure-row";
var akey = document.createElement("span");
akey.className = "triangle-measure-key triangle-measure-key--angle";
akey.textContent = angleNames[ai];
var aval = document.createElement("span");
aval.className = "triangle-measure-val";
angleValEls.push(aval);
arow.appendChild(akey);
arow.appendChild(aval);
secAngles.appendChild(arow);
}
panel.appendChild(secAngles);
/* Perimeter / Area section */
var secMeta = document.createElement("div");
secMeta.className = "triangle-panel-section";
var lblMeta = document.createElement("div");
lblMeta.className = "triangle-panel-label";
lblMeta.textContent = t('measurements');
secMeta.appendChild(lblMeta);
var perimRow = document.createElement("div");
perimRow.className = "triangle-measure-row";
var perimKey = document.createElement("span");
perimKey.className = "triangle-measure-key";
perimKey.textContent = t('perimeter');
var perimVal = document.createElement("span");
perimVal.className = "triangle-measure-val";
perimRow.appendChild(perimKey);
perimRow.appendChild(perimVal);
var areaRow = document.createElement("div");
areaRow.className = "triangle-measure-row";
var areaKey = document.createElement("span");
areaKey.className = "triangle-measure-key";
areaKey.textContent = t('area');
var areaVal = document.createElement("span");
areaVal.className = "triangle-measure-val";
areaRow.appendChild(areaKey);
areaRow.appendChild(areaVal);
secMeta.appendChild(perimRow);
secMeta.appendChild(areaRow);
panel.appendChild(secMeta);
/* Reset button */
var controls = document.createElement("div");
controls.className = "triangle-controls";
var reset = document.createElement("button");
reset.textContent = t('reset');
controls.appendChild(reset);
panel.appendChild(controls);
/* ── SVG ── */
var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
canvasWrap.appendChild(svg);
layout.appendChild(canvasWrap);
layout.appendChild(panel);
container.appendChild(layout);
/* ── Footer — hint text injected here, copy not loaded externally ── */
var footer = document.createElement("div");
footer.className = "hw-footer";
var fLogo = document.createElement("a");
fLogo.className = "hw-footer-icon";
fLogo.href = mw.util.getUrl('Wikipedia:Hiruwiki');
fLogo.title = 'Hiruwiki';
fLogo.innerHTML = hiruwiki.getLogoSvg(22);
var fText = document.createElement("span");
fText.innerHTML = t('hint');
footer.appendChild(fLogo);
footer.appendChild(fText);
container.appendChild(footer);
/* ── rest of module unchanged below ── */
var px;
function equilateral() {
return [
{ x: 6, y: 9 },
{ x: 14, y: 9 },
{ x: 10, y: 9 - 6.9282 }
];
}
var vertices = equilateral();
var names = ["A", "B", "C"];
var dragging = null;
var dragStartMouse, dragStartVertex;
function cm(v) { return v * px; }
function createSVG(type, attrs, parent) {
if (!parent) { parent = svg; }
var e = document.createElementNS("http://www.w3.org/2000/svg", type);
for (var k in attrs) {
if (attrs.hasOwnProperty(k)) {
e.setAttribute(k, attrs[k]);
}
}
parent.appendChild(e);
return e;
}
var grid = createSVG("g", { "class": "triangle-grid" });
var shapeFill = createSVG("polygon", { "class": "triangle-fill" });
var shapeLines = [];
var shapeLineClasses = ["triangle-shape--c", "triangle-shape--a", "triangle-shape--b"];
for (var li = 0; li < 3; li++) {
shapeLines.push(createSVG("line", { "class": "triangle-shape " + shapeLineClasses[li] }));
}
var verts = [];
var sides = [];
var points = [];
var arcs = [];
var angLabels = [];
var touchTargets = [];
for (var i = 0; i < 3; i++) {
var tt = createSVG("circle", { r: 22, "class": "triangle-touch-target" });
tt.setAttribute("data-i", i);
touchTargets.push(tt);
var vc = createSVG("circle", { r: 7, "class": "triangle-vertex" });
vc.setAttribute("data-i", i);
verts.push(vc);
sides.push(createSVG("text", { "class": "triangle-side-label " + sideLabelClasses[i] }));
points.push(createSVG("text", { "class": "triangle-point-label" }));
arcs.push(createSVG("path", { "class": "triangle-angle-arc" }));
angLabels.push(createSVG("text", { "class": "triangle-angle-label" }));
}
var right = createSVG("path", { "class": "triangle-right-marker" });
/* ── Math helpers ── */
function dist(a, b) {
var dx = a.x - b.x, dy = a.y - b.y;
return Math.sqrt(dx * dx + dy * dy);
}
function angleBetween(A, B, C) {
var BAx = A.x - B.x, BAy = A.y - B.y;
var BCx = C.x - B.x, BCy = C.y - B.y;
var dot = BAx * BCx + BAy * BCy;
var mag = Math.sqrt(BAx * BAx + BAy * BAy) * Math.sqrt(BCx * BCx + BCy * BCy);
var val = dot / mag;
if (val < -1) { val = -1; }
if (val > 1) { val = 1; }
return Math.acos(val);
}
function centroid() {
return {
x: (vertices[0].x + vertices[1].x + vertices[2].x) / 3,
y: (vertices[0].y + vertices[1].y + vertices[2].y) / 3
};
}
function drawGrid() {
grid.innerHTML = "";
var x, y;
for (x = 0; x <= boardWidth; x++) { gridLine(x, 0, x, boardHeight); }
for (y = 0; y <= boardHeight; y++) { gridLine(0, y, boardWidth, y); }
}
function gridLine(x1, y1, x2, y2) {
createSVG("line", { x1: cm(x1), y1: cm(y1), x2: cm(x2), y2: cm(y2) }, grid);
}
function arcRadius() {
var a = dist(vertices[0], vertices[1]);
var b = dist(vertices[1], vertices[2]);
var c = dist(vertices[2], vertices[0]);
var mn = a < b ? a : b;
return (mn < c ? mn : c) * 0.25;
}
function computeArc(A, B, C) {
var a1 = Math.atan2(A.y - B.y, A.x - B.x);
var a2 = Math.atan2(C.y - B.y, C.x - B.x);
var diff = a2 - a1;
if (diff < 0) { diff += Math.PI * 2; }
var r = arcRadius();
var sx = B.x + r * Math.cos(a1), sy = B.y + r * Math.sin(a1);
var ex = B.x + r * Math.cos(a2), ey = B.y + r * Math.sin(a2);
var cross = (A.x - B.x) * (C.y - B.y) - (A.y - B.y) * (C.x - B.x);
var sweep = cross > 0 ? 1 : 0;
return "M" + cm(sx) + " " + cm(sy) +
"A" + cm(r) + " " + cm(r) + " 0 0 " + sweep + " " + cm(ex) + " " + cm(ey);
}
function bisector(A, B, C) {
var BAx = A.x - B.x, BAy = A.y - B.y;
var BCx = C.x - B.x, BCy = C.y - B.y;
var m1 = Math.sqrt(BAx * BAx + BAy * BAy);
var m2 = Math.sqrt(BCx * BCx + BCy * BCy);
return { x: BAx / m1 + BCx / m2, y: BAy / m1 + BCy / m2 };
}
function classifySides() {
var a = dist(vertices[1], vertices[2]);
var b = dist(vertices[0], vertices[2]);
var c = dist(vertices[0], vertices[1]);
var e = 0.05;
if (Math.abs(a - b) < e && Math.abs(b - c) < e) { return t("equilateral"); }
if (Math.abs(a - b) < e || Math.abs(a - c) < e || Math.abs(b - c) < e) { return t("isosceles"); }
return t("scalene");
}
function classifyAngles() {
var A = angleBetween(vertices[1], vertices[0], vertices[2]) * 180 / Math.PI;
var B = angleBetween(vertices[2], vertices[1], vertices[0]) * 180 / Math.PI;
var C = angleBetween(vertices[0], vertices[2], vertices[1]) * 180 / Math.PI;
var e = 0.5;
if (Math.abs(A - 90) < e || Math.abs(B - 90) < e || Math.abs(C - 90) < e) { return t("right"); }
if (A > 90 || B > 90 || C > 90) { return t("obtuse"); }
return t("acute");
}
function triangleArea() {
var a = vertices[0], b = vertices[1], c = vertices[2];
return Math.abs((b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y)) / 2;
}
/* ── Render ── */
function update() {
var j, a, b, pts = "";
for (j = 0; j < 3; j++) {
if (j > 0) { pts += " "; }
pts += cm(vertices[j].x) + "," + cm(vertices[j].y);
}
shapeFill.setAttribute("points", pts);
for (j = 0; j < 3; j++) {
a = vertices[j];
b = vertices[(j + 1) % 3];
shapeLines[j].setAttribute("x1", cm(a.x));
shapeLines[j].setAttribute("y1", cm(a.y));
shapeLines[j].setAttribute("x2", cm(b.x));
shapeLines[j].setAttribute("y2", cm(b.y));
}
for (j = 0; j < 3; j++) {
verts[j].setAttribute("cx", cm(vertices[j].x));
verts[j].setAttribute("cy", cm(vertices[j].y));
touchTargets[j].setAttribute("cx", cm(vertices[j].x));
touchTargets[j].setAttribute("cy", cm(vertices[j].y));
}
updateSides();
updateAngles();
updatePoints();
updateRight();
updatePanel();
}
function updatePoints() {
var c = centroid(), v, dx, dy, m;
for (var i = 0; i < 3; i++) {
v = vertices[i];
dx = v.x - c.x; dy = v.y - c.y;
m = Math.sqrt(dx * dx + dy * dy);
points[i].textContent = names[i];
points[i].setAttribute("x", cm(v.x + (dx / m) * 0.9));
points[i].setAttribute("y", cm(v.y + (dy / m) * 0.9));
}
}
function updateSides() {
var a, b, mx, my, dx, dy, len, nx, ny, dot, offset;
var c = centroid();
for (var i = 0; i < 3; i++) {
a = vertices[i];
b = vertices[(i + 1) % 3];
mx = (a.x + b.x) / 2;
my = (a.y + b.y) / 2;
dx = b.x - a.x;
dy = b.y - a.y;
len = Math.sqrt(dx * dx + dy * dy);
nx = -dy / len;
ny = dx / len;
dot = (mx + nx - c.x) * nx + (my + ny - c.y) * ny;
if (dot < 0) { nx = -nx; ny = -ny; }
offset = 0.55;
sides[i].textContent = dist(a, b).toFixed(2);
sides[i].setAttribute("x", cm(mx + nx * offset));
sides[i].setAttribute("y", cm(my + ny * offset));
}
}
function updateAngles() {
var B, A, C, ang, isRight, b, bm, r;
for (var i = 0; i < 3; i++) {
B = vertices[i];
A = vertices[(i + 2) % 3];
C = vertices[(i + 1) % 3];
ang = angleBetween(A, B, C) * 180 / Math.PI;
isRight = Math.abs(ang - 90) < 0.5;
arcs[i].setAttribute("d", isRight ? "" : computeArc(A, B, C));
b = bisector(A, B, C);
bm = Math.sqrt(b.x * b.x + b.y * b.y);
r = arcRadius();
angLabels[i].textContent = ang.toFixed(1) + "\u00b0";
angLabels[i].setAttribute("x", cm(B.x + (b.x / bm) * r * 1.5));
angLabels[i].setAttribute("y", cm(B.y + (b.y / bm) * r * 1.5));
}
}
function updateRight() {
right.setAttribute("d", "");
var B, A, C, ang, s, v1x, v1y, v2x, v2y, m1, m2, p1x, p1y, p2x, p2y, p3x, p3y;
for (var i = 0; i < 3; i++) {
B = vertices[i];
A = vertices[(i + 2) % 3];
C = vertices[(i + 1) % 3];
ang = angleBetween(A, B, C) * 180 / Math.PI;
if (Math.abs(ang - 90) < 0.5) {
s = arcRadius() * 0.6;
v1x = A.x - B.x; v1y = A.y - B.y;
v2x = C.x - B.x; v2y = C.y - B.y;
m1 = Math.sqrt(v1x * v1x + v1y * v1y);
m2 = Math.sqrt(v2x * v2x + v2y * v2y);
v1x /= m1; v1y /= m1;
v2x /= m2; v2y /= m2;
p1x = B.x + v1x * s; p1y = B.y + v1y * s;
p2x = p1x + v2x * s; p2y = p1y + v2y * s;
p3x = B.x + v2x * s; p3y = B.y + v2y * s;
right.setAttribute("d",
"M" + cm(p1x) + " " + cm(p1y) +
"L" + cm(p2x) + " " + cm(p2y) +
"L" + cm(p3x) + " " + cm(p3y)
);
}
}
}
function updatePanel() {
var a = dist(vertices[1], vertices[2]);
var b = dist(vertices[2], vertices[0]);
var c = dist(vertices[0], vertices[1]);
sideValEls[0].textContent = a.toFixed(2) + " " + t('unitCm');
sideValEls[1].textContent = b.toFixed(2) + " " + t('unitCm');
sideValEls[2].textContent = c.toFixed(2) + " " + t('unitCm');
var A = angleBetween(vertices[1], vertices[0], vertices[2]) * 180 / Math.PI;
var B = angleBetween(vertices[2], vertices[1], vertices[0]) * 180 / Math.PI;
var C = angleBetween(vertices[0], vertices[2], vertices[1]) * 180 / Math.PI;
angleValEls[0].textContent = A.toFixed(1) + "\u00b0";
angleValEls[1].textContent = B.toFixed(1) + "\u00b0";
angleValEls[2].textContent = C.toFixed(1) + "\u00b0";
perimVal.textContent = (a + b + c).toFixed(2) + " " + t('unitCm');
areaVal.textContent = triangleArea().toFixed(2) + " " + t('unitCm2');
badgeSides.textContent = classifySides();
badgeAngles.textContent = classifyAngles();
}
/* ── Drag helpers ── */
function getPosSVG(clientX, clientY) {
var rect = svg.getBoundingClientRect();
return {
x: (clientX - rect.left) / px,
y: (clientY - rect.top) / px
};
}
function startDrag(idx, clientX, clientY) {
dragging = idx;
dragStartMouse = getPosSVG(clientX, clientY);
dragStartVertex = { x: vertices[dragging].x, y: vertices[dragging].y };
}
function makeMouseDown(el) {
el.addEventListener("mousedown", function (e) {
e.preventDefault();
startDrag(parseInt(el.getAttribute("data-i"), 10), e.clientX, e.clientY);
});
}
function makeTouchStart(el) {
el.addEventListener("touchstart", function (e) {
if (e.touches.length !== 1) { return; }
e.preventDefault();
var touch = e.touches[0];
startDrag(parseInt(el.getAttribute("data-i"), 10), touch.clientX, touch.clientY);
}, false);
}
for (var di = 0; di < 3; di++) {
makeMouseDown(verts[di]);
makeMouseDown(touchTargets[di]);
makeTouchStart(verts[di]);
makeTouchStart(touchTargets[di]);
}
function snapHalf(v) {
return Math.round(v * 2) / 2;
}
window.addEventListener("mousemove", function (e) {
if (dragging === null) { return; }
var mouse = getPosSVG(e.clientX, e.clientY);
var margin = 0.6;
vertices[dragging] = {
x: snapHalf(Math.max(margin, Math.min(boardWidth - margin, dragStartVertex.x + (mouse.x - dragStartMouse.x)))),
y: snapHalf(Math.max(margin, Math.min(boardHeight - margin, dragStartVertex.y + (mouse.y - dragStartMouse.y))))
};
update();
});
window.addEventListener("mouseup", function () { dragging = null; });
window.addEventListener("touchmove", function (e) {
if (dragging === null || e.touches.length !== 1) { return; }
e.preventDefault();
var touch = e.touches[0];
var mouse = getPosSVG(touch.clientX, touch.clientY);
var margin = 0.6;
vertices[dragging] = {
x: snapHalf(Math.max(margin, Math.min(boardWidth - margin, dragStartVertex.x + (mouse.x - dragStartMouse.x)))),
y: snapHalf(Math.max(margin, Math.min(boardHeight - margin, dragStartVertex.y + (mouse.y - dragStartMouse.y))))
};
update();
}, false);
window.addEventListener("touchend", function () { dragging = null; });
window.addEventListener("touchcancel", function () { dragging = null; });
reset.onclick = function () {
vertices = equilateral();
update();
};
/* ── Init ── */
function init() {
px = svg.getBoundingClientRect().width / boardWidth;
drawGrid();
update();
}
requestAnimationFrame(init);
window.addEventListener("resize", function () { requestAnimationFrame(init); });
}
mw.hook("wikipage.content").add(function () {
var els = document.querySelectorAll('.hiruwiki[data-module="triangles"]');
for (var i = 0; i < els.length; i++) {
var el = els[i];
if (el.getAttribute("data-triangle-ready")) { continue; }
el.setAttribute("data-triangle-ready", "1");
initTriangleWidget(el);
}
});
})();
q18e1byhjr5jq51o77dh2rsal4ovyff
MediaWiki:Hiruwiki/modules/trigonometry.js
8
179535
781454
770262
2026-10-28T10:40:42Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781454
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/* Hiruwiki module: trigonometry
* Usage: <div class="hiruwiki" data-module="trigonometry"></div>
* Renders a unit-circle trigonometry calculator with animated proofs.
*/
(function () {
'use strict';
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"nl": {
"_name": "Goniometrie",
"angle": "Hoek θ",
"cosDesc": "De blauwe lijn is de <strong>aanliggende</strong> zijde — de horizontale afstand van de oorsprong tot de basis van de loodlijn. cos θ = aanliggende ÷ schuine zijde (= 1).",
"cosFormula": "aanliggende / schuine",
"hideProof": "Bewijs verbergen",
"prove": "Bewijs",
"sinDesc": "De groene lijn is de <strong>overstaande</strong> zijde — de verticale afstand van het punt op de cirkel tot de x-as. sin θ = overstaande ÷ schuine zijde (= 1).",
"sinFormula": "overstaande / schuine",
"tanDesc": "De oranje lijn is getekend op x = 1, rakend aan de cirkel. De hoogte is tan θ, aangezien tan θ = overstaande ÷ aanliggende = sin θ ÷ cos θ.",
"tanFormula": "overstaande / aanliggende",
"hint": "Gebruik de schuifregelaar om de hoek te wijzigen"
},
"fr": {
"_name": "Trigonométrie",
"angle": "Angle θ",
"cosDesc": "La ligne bleue est le côté <strong>adjacent</strong> — la distance horizontale de l'origine à la base de la perpendiculaire. cos θ = adyacente ÷ hypoténuse (= 1).",
"cosFormula": "adjacent / hypoténuse",
"hideProof": "Masquer la preuve",
"prove": "Prouver",
"sinDesc": "La ligne verte est le côté <strong>opposé</strong> — la distance verticale du point sur le cercle à l'axe x. sin θ = opposé ÷ hypoténuse (= 1).",
"sinFormula": "opposé / hypoténuse",
"tanDesc": "La ligne orange est tracée à x = 1, tangente au cercle. Sa hauteur est tan θ, car tan θ = opposé ÷ adjacent = sin θ ÷ cos θ.",
"tanFormula": "opposé / adjacent",
"hint": "Utilisez le curseur pour changer l'angle"
},
"eu": {
"_name": "Trigonometria",
"angle": "Angelua θ",
"cosDesc": "Lerro urdina alde <strong>auzokidea</strong> da — jatorritik elkartzutaren oinarrira dagoen distantzia horizontala. cos θ = auzokidea ÷ hipotenisa (= 1).",
"cosFormula": "auzokidea / hipotenusa",
"hideProof": "Froga ezkutatu",
"prove": "Frogatu",
"sinDesc": "Lerro berdea <strong>aurkako</strong> aldea da — zirkulu puntutik x-ardatzera dagoen distantzia bertikala sin θ = aurkakoa ÷ hipotenusa (= 1).",
"sinFormula": "aurkakoa / hipotenusa",
"tanDesc": "Lerro laranja x = 1 denean marrazten da, zirkuluarekiko tangentea. Bere altuera tan θ da, tan = aurkakoa ÷ auzokidea = sin θ ÷ cos θ baita.",
"tanFormula": "aurkakoa / auzokidea",
"hint": "Erabili graduatzailea angelua aldatzeko"
},
"sk": {
"_name": "Trigonometria",
"angle": "Uhol θ",
"cosDesc": "Modrá čiara je <strong>priľahlá</strong> strana — vodorovná vzdialenosť od počiatku k päte kolmice. cos θ = priľahlá ÷ prepona (= 1).",
"cosFormula": "priľahlá / prepona",
"hideProof": "Skryť dôkaz",
"prove": "Dokázať",
"sinDesc": "Zelená čiara je <strong>protiľahlá</strong> strana — zvislá vzdialenosť od bodu na kružnici k osi x. sin θ = protiľahlá ÷ prepona (= 1).",
"sinFormula": "protiľahlá / prepona",
"tanDesc": "Oranžová čiara je nakreslená pri x = 1, dotyčnica ku kružnici. Jej výška je tan θ, keďže tan θ = protiľahlá ÷ priľahlá = sin θ ÷ cos θ.",
"tanFormula": "protiľahlá / priľahlá",
"hint": "Pomocou posuvníka zmeníte uhol"
},
"skr-arab": {
"_name": "مثلثیات",
"angle": "زاویہ θ",
"cosDesc": "نیلی لکیر <strong>مجاور</strong> ضلع اے — مبدا توں عمود دے قاعدے تائیں افقی فاصلہ۔ cos θ = مجاور ÷ وتر (= 1)۔",
"cosFormula": "مجاور / وتر",
"hideProof": "ثبوت لکاؤ",
"prove": "ثابت کرو",
"sinDesc": "سبز لکیر <strong>مقابل</strong> ضلع اے — دائرے تے نقطے توں x-محور تائیں عمودی فاصلہ۔ sin θ = مقابل ÷ وتر (= 1)۔",
"sinFormula": "مقابل / وتر",
"tanDesc": "نارنجی لکیر x = 1 تے بنائی گئی اے، دائرے کوں مماس۔ ایندی اُچائی tan θ اے، کیوں جو tan θ = مقابل ÷ مجاور = sin θ ÷ cos θ۔",
"tanFormula": "مقابل / مجاور",
"hint": "زاویہ بدلݨ کیتے سلائیڈر ورتو"
},
"lb": {
"_name": "Trigonometrie",
"hideProof": "Beweis verstoppen",
"prove": "Beweisen",
"hint": "Benotzt de Slider, fir de Wénkel z'änneren"
},
"ko": {
"_name": "삼각법",
"angle": "각도 θ",
"cosDesc": "파란색 선은 <strong>인접</strong>변, 즉 원점에서 수선의 밑변까지의 수평 거리입니다. cos θ = 인접변 ÷ 빗변 (= 1).",
"cosFormula": "인접 / 빗변",
"hideProof": "증명 숨기기",
"prove": "증명",
"sinDesc": "녹색 선은 <strong>대</strong>변, 즉 원 위의 점에서 x축까지의 수직 거리입니다. sin θ = 대변 ÷ 빗변 (= 1).",
"sinFormula": "반대편 / 빗변",
"tanDesc": "주황색 선은 x = 1에서 원에 접하는 선으로 그려져 있습니다. tan θ = 대변 ÷ 인접변 = sin θ ÷ cos θ이므로, 높이는 tan θ입니다.",
"tanFormula": "반대편 / 인접",
"hint": "슬라이더를 사용하여 각도를 변경하세요"
},
"en": {
"_name": "Trigonometry",
"angle": "Angle θ",
"cosDesc": "The blue line is the <strong>adjacent</strong> side — the horizontal distance from the origin to the base of the perpendicular. cos θ = adjacent ÷ hypotenuse (= 1).",
"cosFormula": "adjacent / hypotenuse",
"hideProof": "Hide proof",
"prove": "Prove",
"sinDesc": "The green line is the <strong>opposite</strong> side — the vertical distance from the point on the circle to the x-axis. sin θ = opposite ÷ hypotenuse (= 1).",
"sinFormula": "opposite / hypotenuse",
"tanDesc": "The orange line is drawn at x = 1, tangent to the circle. Its height is tan θ, since tan θ = opposite ÷ adjacent = sin θ ÷ cos θ.",
"tanFormula": "opposite / adjacent",
"hint": "Use the slider to change the angle"
},
"it": {
"_name": "Trigonometria",
"prove": "Dimostra"
},
"ca": {
"_name": "Trigonometria",
"angle": "Angle θ"
},
"qqq": {
"_name": "Name of the Trigonometry module",
"angle": "Label for the angle θ slider. Uses θ HTML entity.",
"cosDesc": "Description of the cosine function shown in the proof panel. Uses HTML entities and formatting.",
"cosFormula": "Formula for cosine (adjacent / hypotenuse)",
"hideProof": "Button label to hide the trigonometric proof panel",
"prove": "Button label to show the trigonometric proof panel",
"sinDesc": "Description of the sine function shown in the proof panel. Uses HTML entities and formatting.",
"sinFormula": "Formula for sine (opposite / hypotenuse)",
"tanDesc": "Description of the tangent function shown in the proof panel. Uses HTML entities and formatting.",
"tanFormula": "Formula for tangent (opposite / adjacent)",
"hint": "Instruction text for the unit circle interaction"
},
"ga": {
"_name": "Triantánacht",
"angle": "Uillinn θ",
"cosDesc": "Is í an líne ghorm an taobh **cóngarach** — an fad cothrománach ón mbunphointe go dtí bun an ingearaigh. cos θ = cóngarach hipotenuse (= 1).",
"cosFormula": "cóngarach / taobhagán",
"hideProof": "Folaigh cruthúnas",
"prove": "Cruthaigh",
"sinDesc": "Is í an líne uaine an taobh **os coinne** — an fad ingearach ón bpointe ar an gciorcal go dtí an ais-x. sin θ = os coinne divide; hipotenuse (= 1).",
"sinFormula": "os coinne / taobhagán",
"tanDesc": "Tarraingítear an líne oráiste ag x = 1, tadhlaí don chiorcal. Is é a airde tan θ, ós rud é tan θ = opposite ÷ adjacent = sin θ ÷ cos θ.",
"tanFormula": "os coinne / cóngarach",
"hint": "Úsáid an sleamhnán chun an uillinn a athrú"
},
"es": {
"_name": "Trigonometría",
"angle": "Ángulo θ",
"cosDesc": "La línea azul es el lado <strong>adyacente</strong> — la distancia horizontal desde el origen hasta la base de la perpendicular. cos θ = adyacente ÷ hipotenusa (= 1).",
"cosFormula": "adyacente / hipotenusa",
"hideProof": "Ocultar demostración",
"prove": "Demostrar",
"sinDesc": "La línea verde es el lado <strong>opuesto</strong> — la distancia vertical desde el punto en el círculo hasta el eje x. sen θ = opuesto ÷ hipotenusa (= 1).",
"sinFormula": "opuesto / hipotenusa",
"tanDesc": "La línea naranja se dibuja en x = 1, tangente al círculo. Su altura es tan θ, ya que tan θ = opuesto ÷ adyacente = sen θ ÷ cos θ.",
"tanFormula": "opuesto / adyacente",
"hint": "Utilice el control deslizante para cambiar el ángulo"
},
"mk": {
"_name": "Тригонометрија",
"angle": "Агол θ",
"cosDesc": "Сината линија е <strong>соседната</strong> страна — хоризонталното растојание од почетокот до основата на нормалата. cos θ = adjacent ÷ hypotenuse (= 1).",
"cosFormula": "соседна / хипотенуза",
"hideProof": "Скриј доказ",
"prove": "Докажи",
"sinDesc": "Зелената линија е <strong>спротивната</strong> страна — вертикалното растојание од точката на кружницата до x-оската. sin θ = opposite ÷ hypotenuse (= 1).",
"sinFormula": "спротивна / хипотенуза",
"tanDesc": "Портокаловата линија е повлечена во x = 1, допирна на кружницата. Нејзината висина е tan θ, бидејќи tan θ = opposite ÷ adjacent = sin θ ÷ cos θ.",
"tanFormula": "спротивна / соседна",
"hint": "Повлечете го лизгачот за да го смените аголот"
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
function initTrigonometry(container) {
if (container.dataset.trigInit) { return; }
container.dataset.trigInit = '1';
/* ── Build HTML ─────────────────────────────────────────── */
container.innerHTML =
'<div class="htrig-wrap">' +
'<div class="htrig-layout">' +
'<div class="htrig-canvas-col">' +
'<canvas class="htrig-canvas" width="420" height="420"></canvas>' +
'<div class="htrig-angle-row">' +
'<label class="htrig-angle-label">' + t('angle') + '</label>' +
'<input class="htrig-slider" type="range" min="1" max="89" value="40">' +
'<span class="htrig-angle-val"><span class="htrig-angle-num">40</span>°</span>' +
'</div>' +
'</div>' +
'<div class="htrig-cards">' +
'<div class="htrig-card htrig-card-sin">' +
'<div class="htrig-card-header">' +
'<span class="htrig-fn-name htrig-sin">sin(θ)</span>' +
'<span class="htrig-fn-val" data-fn="sin">0.643</span>' +
'</div>' +
'<div class="htrig-formula">' + t('sinFormula') + '</div>' +
'<button class="htrig-btn htrig-btn-sin" data-fn="sin">' + t('prove') + '</button>' +
'<div class="htrig-desc" data-fn="sin"></div>' +
'</div>' +
'<div class="htrig-card htrig-card-cos">' +
'<div class="htrig-card-header">' +
'<span class="htrig-fn-name htrig-cos">cos(θ)</span>' +
'<span class="htrig-fn-val" data-fn="cos">0.766</span>' +
'</div>' +
'<div class="htrig-formula">' + t('cosFormula') + '</div>' +
'<button class="htrig-btn htrig-btn-cos" data-fn="cos">' + t('prove') + '</button>' +
'<div class="htrig-desc" data-fn="cos"></div>' +
'</div>' +
'<div class="htrig-card htrig-card-tan">' +
'<div class="htrig-card-header">' +
'<span class="htrig-fn-name htrig-tan">tan(θ)</span>' +
'<span class="htrig-fn-val" data-fn="tan">0.839</span>' +
'</div>' +
'<div class="htrig-formula">' + t('tanFormula') + '</div>' +
'<button class="htrig-btn htrig-btn-tan" data-fn="tan">' + t('prove') + '</button>' +
'<div class="htrig-desc" data-fn="tan"></div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>';
/* ── Element references ─────────────────────────────────── */
var canvas = container.querySelector('.htrig-canvas');
var ctx = canvas.getContext('2d');
var slider = container.querySelector('.htrig-slider');
var angleNum = container.querySelector('.htrig-angle-num');
/* ── State ──────────────────────────────────────────────── */
var W = 420, H = 420, CX = 210, CY = 210, R = 160;
var angleDeg = 40;
var activeProof = null;
var proofAnim = 0;
var animFrame = null;
/* ── Colour helpers ─────────────────────────────────────── */
var C = {
sin: hiruwiki.getThemeColor('color-success', '#1d9e75'),
cos: hiruwiki.getThemeColor('color-progressive', '#185fa5'),
tan: hiruwiki.getThemeColor('color-warning', '#BA7517'),
hyp: hiruwiki.getThemeColor('color-placeholder', '#888780'),
angle: hiruwiki.getThemeColor('color-destructive', '#993556')
};
function isDark() {
return document.documentElement.classList.contains('skin-theme-clientpref-night') ||
document.documentElement.classList.contains('client-dark-mode') ||
document.body.classList.contains('mw-dark-mode');
}
function palette() {
return {
bg: hiruwiki.getThemeColor('background-color-base', '#ffffff'),
grid: hiruwiki.getThemeColor('border-color-base', '#d3d1c7'),
axis: hiruwiki.getThemeColor('color-placeholder', '#b4b2a9'),
text: hiruwiki.getThemeColor('color-base', '#3d3d3a')
};
}
/* ── Drawing ────────────────────────────────────────────── */
function draw(proofT) {
var p = palette();
var t = (proofT !== undefined) ? proofT : 1;
var rad = angleDeg * Math.PI / 180;
var px = CX + R * Math.cos(rad);
var py = CY - R * Math.sin(rad);
ctx.clearRect(0, 0, W, H);
/* Background */
ctx.fillStyle = p.bg;
ctx.fillRect(0, 0, W, H);
/* Grid */
ctx.strokeStyle = p.grid;
ctx.lineWidth = 0.5;
ctx.setLineDash([]);
for (var i = -4; i <= 4; i++) {
var gx = CX + i * R / 4;
var gy = CY + i * R / 4;
ctx.beginPath(); ctx.moveTo(gx, 40); ctx.lineTo(gx, H - 40); ctx.stroke();
ctx.beginPath(); ctx.moveTo(40, gy); ctx.lineTo(W - 40, gy); ctx.stroke();
}
/* Unit circle */
ctx.strokeStyle = p.axis;
ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(CX, CY, R, 0, Math.PI * 2); ctx.stroke();
/* Axes */
ctx.strokeStyle = p.axis;
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(40, CY); ctx.lineTo(W - 40, CY); ctx.stroke();
ctx.beginPath(); ctx.moveTo(CX, 40); ctx.lineTo(CX, H - 40); ctx.stroke();
/* Axis labels */
ctx.fillStyle = p.text;
ctx.font = '11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('1', CX + R + 14, CY + 12);
ctx.fillText('-1', CX - R - 14, CY + 12);
ctx.fillText('1', CX + 8, CY - R - 6);
ctx.fillText('-1', CX + 8, CY + R + 14);
ctx.fillText('0', CX + 8, CY + 14);
/* Angle arc */
ctx.strokeStyle = C.angle;
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(CX, CY, 28, 0, -rad, true); ctx.stroke();
ctx.fillStyle = C.angle;
ctx.font = 'bold 12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('\u03b8', CX + 40 * Math.cos(rad / 2), CY - 40 * Math.sin(rad / 2));
/* Hypotenuse */
ctx.strokeStyle = C.hyp;
ctx.lineWidth = 2;
ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(CX, CY); ctx.lineTo(px, py); ctx.stroke();
/* Faint helper lines when no proof is active */
if (!activeProof) {
ctx.globalAlpha = 0.35;
ctx.setLineDash([3, 3]);
ctx.strokeStyle = C.sin; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(px, CY); ctx.stroke();
ctx.strokeStyle = C.cos;
ctx.beginPath(); ctx.moveTo(CX, CY); ctx.lineTo(px, CY); ctx.stroke();
ctx.setLineDash([]);
ctx.globalAlpha = 1;
}
/* ── Proof animations ───────────────────────────────── */
if (activeProof === 'sin') {
var sinY = py + (CY - py) * t;
ctx.strokeStyle = C.sin; ctx.lineWidth = 3; ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(px, sinY); ctx.stroke();
if (t >= 1) {
ctx.strokeStyle = C.hyp; ctx.lineWidth = 1; ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(px, CY); ctx.lineTo(CX, CY); ctx.stroke();
ctx.setLineDash([]);
ctx.strokeStyle = C.sin; ctx.lineWidth = 1;
ctx.strokeRect(px - 8, CY - 8, 8, 8);
ctx.fillStyle = C.sin; ctx.font = 'bold 12px sans-serif'; ctx.textAlign = 'left';
ctx.fillText('sin\u03b8 = ' + Math.sin(rad).toFixed(3), px + 10, (py + CY) / 2);
}
} else if (activeProof === 'cos') {
var cosX = CX + (px - CX) * t;
ctx.strokeStyle = C.cos; ctx.lineWidth = 3; ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(CX, CY); ctx.lineTo(cosX, CY); ctx.stroke();
if (t >= 1) {
ctx.strokeStyle = C.hyp; ctx.lineWidth = 1; ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(px, CY); ctx.stroke();
ctx.setLineDash([]);
ctx.strokeStyle = C.cos; ctx.lineWidth = 1;
ctx.strokeRect(px - 8, CY - 8, 8, 8);
ctx.fillStyle = C.cos; ctx.font = 'bold 12px sans-serif'; ctx.textAlign = 'center';
ctx.fillText('cos\u03b8 = ' + Math.cos(rad).toFixed(3), CX + (px - CX) / 2, CY - 12);
}
} else if (activeProof === 'tan') {
var tanVal = Math.tan(rad);
var tx2 = CX + R;
var ty2 = CY - R * tanVal;
var ty2a = CY - R * tanVal * t;
ctx.strokeStyle = C.tan; ctx.lineWidth = 3; ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(tx2, CY); ctx.lineTo(tx2, ty2a); ctx.stroke();
if (t >= 1) {
ctx.strokeStyle = C.hyp; ctx.lineWidth = 1; ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(CX, CY); ctx.lineTo(tx2, ty2); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = C.tan;
ctx.beginPath(); ctx.arc(tx2, ty2, 5, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = C.tan; ctx.lineWidth = 1;
ctx.strokeRect(tx2 - 8, CY - 8, 8, 8);
ctx.fillStyle = C.tan; ctx.font = 'bold 12px sans-serif';
var dispTan = isFinite(tanVal) ? tanVal.toFixed(3) : '\u221e';
if (tanVal > 1.5) {
ctx.textAlign = 'right';
ctx.fillText('tan\u03b8 = ' + dispTan, tx2 - 10, (CY + ty2) / 2);
} else {
ctx.textAlign = 'left';
ctx.fillText('tan\u03b8 = ' + dispTan, tx2 + 8, (CY + ty2) / 2);
}
}
}
ctx.setLineDash([]);
ctx.globalAlpha = 1;
/* Point on circle (drawn last so it's always on top) */
ctx.fillStyle = C.angle;
ctx.beginPath(); ctx.arc(px, py, 5, 0, Math.PI * 2); ctx.fill();
}
/* ── Value display update ───────────────────────────────── */
function updateValues() {
var rad = angleDeg * Math.PI / 180;
var tanV = Math.tan(rad);
container.querySelector('[data-fn="sin"]').textContent = Math.sin(rad).toFixed(3);
container.querySelector('[data-fn="cos"]').textContent = Math.cos(rad).toFixed(3);
container.querySelector('[data-fn="tan"]').textContent = isFinite(tanV) ? tanV.toFixed(3) : '\u221e';
angleNum.textContent = angleDeg;
}
/* ── Proof animation loop ───────────────────────────────── */
function animateProof() {
if (proofAnim >= 1) {
draw(1);
return;
}
proofAnim += 0.035;
draw(Math.min(proofAnim, 1));
animFrame = requestAnimationFrame(animateProof);
}
function startProofAnim() {
if (animFrame) { cancelAnimationFrame(animFrame); }
proofAnim = 0;
animateProof();
}
/* ── Proof toggle ───────────────────────────────────────── */
var DESCS = {
sin: t('sinDesc'),
cos: t('cosDesc'),
tan: t('tanDesc')
};
function resetAll() {
['sin', 'cos', 'tan'].forEach(function (fn) {
var btn = container.querySelector('.htrig-btn-' + fn);
var desc = container.querySelector('.htrig-desc[data-fn="' + fn + '"]');
btn.classList.remove('htrig-btn-active');
btn.textContent = t('prove');
desc.innerHTML = '';
});
}
container.addEventListener('click', function (e) {
var btn = e.target;
if (!btn.classList.contains('htrig-btn')) { return; }
var fn = btn.dataset.fn;
if (activeProof === fn) {
activeProof = null;
resetAll();
if (animFrame) { cancelAnimationFrame(animFrame); }
draw(1);
} else {
resetAll();
activeProof = fn;
btn.classList.add('htrig-btn-active');
btn.textContent = t('hideProof');
container.querySelector('.htrig-desc[data-fn="' + fn + '"]').innerHTML = DESCS[fn];
startProofAnim();
}
});
/* ── Slider ─────────────────────────────────────────────── */
slider.addEventListener('input', function () {
angleDeg = parseInt(this.value, 10);
updateValues();
if (activeProof) {
startProofAnim();
} else {
draw(1);
}
});
/* ── Dark mode live update (MediaWiki) ─────────────────── */
var observer = new MutationObserver(function () {
draw(activeProof ? 1 : 1);
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
observer.observe(document.body, { attributes: true, attributeFilter: ['class'] });
/* ── Init ───────────────────────────────────────────────── */
updateValues();
draw(1);
// Footer branding
var footer = document.createElement("div");
footer.className = "hw-footer";
var fLogo = document.createElement("a");
fLogo.className = "hw-footer-icon";
fLogo.href = mw.util.getUrl('Wikipedia:Hiruwiki');
fLogo.title = 'Hiruwiki';
fLogo.innerHTML = hiruwiki.getLogoSvg(22);
var fText = document.createElement("span");
fText.innerHTML = t('hint');
footer.appendChild(fLogo);
footer.appendChild(fText);
container.appendChild(footer);
}
/* ── Wire up all matching containers on the page ──────────── */
document.querySelectorAll('.hiruwiki[data-module="trigonometry"]').forEach(function (el) {
initTrigonometry(el);
});
}());
a03mks0owd66qfqcyayijmm406yfbcy
Wikipedia:AI noticeboard/DVRTed/2
4
189915
781168
780938
2026-10-27T15:35:32Z
DVRTed
55993
Adding a new discussion subsection (using [[User:DVRTed/AINB-helper|AINB-helper]])
781168
wikitext
text/x-wiki
{{AINB case banner |cleanup=completed |llmprod=undetermined |conduct=review |case_type=user}}
== Discussion ==
TEST!
=== 27 October 2026 ===
final test [[User:DVRTed|DVRTed]] ([[User talk:DVRTed|talk]]) 15:35, 27 October 2026 (UTC)
== Cleanup ==
{{InfoCard|content='''Tracker detail'''
* User: {{User-ainb|1=DVRTed}}
* Start date: 2022-12-01
* End date: 2026-10-25}}
=== Tracking list ===
{{AIC article list|
{{AIC article row|article=Test Page 817876|status=requested|notes=3 edits: [[Special:Diff/780857|(+314)]] [[Special:Diff/765241|(+83)]] [[Special:Diff/691118|(+14)]]}}
{{AIC article row|article=BS Nymph|status=requested|notes=24 edits: [[Special:Diff/768763|(+70)]] [[Special:Diff/768500|(+314)]] [[Special:Diff/765391|(+5)]] [[Special:Diff/762815|(+330)]] [[Special:Diff/762814|(-2679)]] [[Special:Diff/762811|(+88)]] [[Special:Diff/762776|(+331)]] [[Special:Diff/762774|(+90)]] [[Special:Diff/762772|(+283)]] [[Special:Diff/762716|(+88)]] [[Special:Diff/762713|(+88)]] [[Special:Diff/762710|(+88)]] [[Special:Diff/762707|(+318)]] [[Special:Diff/762691|(+318)]] [[Special:Diff/762688|(+88)]] [[Special:Diff/762686|(+318)]] [[Special:Diff/762684|(+88)]] [[Special:Diff/762682|(+88)]] [[Special:Diff/762681|(+88)]] [[Special:Diff/762678|(+318)]] [[Special:Diff/762677|(-88)]] [[Special:Diff/762676|(+88)]] [[Special:Diff/759773|(-85)]] [[Special:Diff/759772|(+85)]]}}
{{AIC article row|article=Testpage12|status=requested|notes=26 edits: [[Special:Diff/765089|(+330)]] [[Special:Diff/764111|(+72)]] [[Special:Diff/762674|(+88)]] [[Special:Diff/762672|(+318)]] [[Special:Diff/762671|(+88)]] [[Special:Diff/762670|(+318)]] [[Special:Diff/762610|(+88)]] [[Special:Diff/762607|(+318)]] [[Special:Diff/762606|(+318)]] [[Special:Diff/762604|(+318)]] [[Special:Diff/762424|(+320)]] [[Special:Diff/762423|(-515)]] [[Special:Diff/762237|(+323)]] [[Special:Diff/762235|(+96)]] [[Special:Diff/762233|(+96)]] [[Special:Diff/762232|(-418)]] [[Special:Diff/762229|(+96)]] [[Special:Diff/762227|(+323)]] [[Special:Diff/762226|(-323)]] [[Special:Diff/762223|(+323)]] [[Special:Diff/762222|(-442)]] [[Special:Diff/762220|(+5)]] [[Special:Diff/762217|(+103)]] [[Special:Diff/762215|(+335)]] [[Special:Diff/761537|(+2)]] [[Special:Diff/761536|(+4)]] (page created)}}
{{AIC article row|article=Wp/sas/Kabupatén Tangerang|status=requested|notes=5 edits: [[Special:Diff/763465|(+330)]] [[Special:Diff/763463|(+88)]] [[Special:Diff/762818|(+88)]] [[Special:Diff/759771|(-317)]] [[Special:Diff/759770|(+317)]]}}
{{AIC article row|article=Bob Morley|status=requested|notes=1 edit: [[Special:Diff/762028|(+342)]]}}
{{AIC article row|article=Jugerum/edithistory|status=requested|notes=2 edits: [[Special:Diff/760823|(-343)]] [[Special:Diff/760822|(+343)]]}}
}}
37qq45t65i23af9ju9tkal3l5d1r4sg
User:UHHEFUH/common.js
2
189933
781275
781070
2026-10-28T00:29:27Z
UHHEFUH
76432
781275
javascript
text/javascript
(function () {
if (!mw || !mw.config) return;
const TEMPLATE_NAME = '{{Consent}}';
const SUMMARY_TEXT = 'Automated template deployment: Adding Consent template.';
function init() {
const sidebar = document.getElementById('p-navigation') || document.getElementById('mw-panel');
if (!sidebar) return;
const linkContainer = document.createElement('li');
linkContainer.className = 'custom-consent-btn-container';
const actionLink = document.createElement('a');
actionLink.href = '#';
actionLink.textContent = 'Deploy Consent Template';
actionLink.className = 'custom-consent-btn';
actionLink.addEventListener('click', function (e) {
e.preventDefault();
if (confirm('Are you sure you want to scan and insert the template across user talk pages?')) {
actionLink.textContent = 'Scanning...';
actionLink.classList.add('loading');
fetchAndProcessTalkPages(actionLink);
}
});
linkContainer.appendChild(actionLink);
const list = sidebar.querySelector('ul');
if (list) {
list.appendChild(linkContainer);
}
}
function fetchAndProcessTalkPages(buttonEl) {
const api = new mw.Api();
api.get({
action: 'query',
list: 'allpages',
apnamespace: 3,
aplimit: 'max',
format: 'json'
}).done(function (data) {
if (data && data.query && data.query.allpages) {
const pages = data.query.allpages;
processPagesSequentially(pages, 0, api, buttonEl);
} else {
alert('No user talk pages found.');
resetButton(buttonEl);
}
}).fail(function () {
alert('Failed to query the page list.');
resetButton(buttonEl);
});
}
function processPagesSequentially(pages, index, api, buttonEl) {
if (index >= pages.length) {
alert('Finished processing all user talk pages.');
location.reload();
return;
}
const page = pages[index];
const title = page.title;
api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
rvslots: 'main',
format: 'json'
}).done(function (res) {
const pageId = Object.keys(res.query.pages)[0];
let currentContent = '';
if (pageId !== '-1') {
const pageData = res.query.pages[pageId];
if (pageData.revisions && pageData.revisions[0] && pageData.revisions[0].slots.main) {
currentContent = pageData.revisions[0].slots.main['*'];
}
}
if (currentContent.includes(TEMPLATE_NAME)) {
processPagesSequentially(pages, index + 1, api, buttonEl);
} else {
const newContent = TEMPLATE_NAME + '\n' + currentContent;
api.postWithToken('csrf', {
action: 'edit',
title: title,
text: newContent,
summary: SUMMARY_TEXT,
bot: true
}).done(function () {
setTimeout(function() {
processPagesSequentially(pages, index + 1, api, buttonEl);
}, 500);
}).fail(function () {
processPagesSequentially(pages, index + 1, api, buttonEl);
});
}
});
}
function resetButton(buttonEl) {
buttonEl.textContent = 'Deploy Consent Template';
buttonEl.classList.remove('loading');
}
$(document).ready(init);
})();
6j0nd7lmz638jdht1jnrsfsko4914lu
781334
781275
2026-10-28T00:42:32Z
UHHEFUH
76432
781334
javascript
text/javascript
(function () {
if (!mw || !mw.config) return;
const TEMPLATE_NAME = '{{Consent}}';
const SUMMARY_TEXT = 'Automated template deployment: Adding Consent template.';
function init() {
const sidebar = document.getElementById('p-navigation') || document.getElementById('mw-panel');
if (!sidebar) return;
const linkContainer = document.createElement('li');
linkContainer.className = 'custom-consent-btn-container';
const actionLink = document.createElement('a');
actionLink.href = '#';
actionLink.textContent = 'Deploy Consent Template';
actionLink.className = 'custom-consent-btn';
actionLink.addEventListener('click', function (e) {
e.preventDefault();
if (confirm('Are you sure you want to scan and insert the template across user talk pages?')) {
actionLink.textContent = 'Scanning...';
actionLink.classList.add('loading');
fetchAndProcessTalkPages(actionLink);
}
});
linkContainer.appendChild(actionLink);
const list = sidebar.querySelector('ul');
if (list) {
list.appendChild(linkContainer);
}
}
function fetchAndProcessTalkPages(buttonEl) {
const api = new mw.Api();
api.get({
action: 'query',
list: 'allpages',
apnamespace: 3,
aplimit: 'max',
format: 'json'
}).done(function (data) {
if (data && data.query && data.query.allpages) {
const pages = data.query.allpages;
processPagesSequentially(pages, 0, api, buttonEl);
} else {
alert('No user talk pages found.');
resetButton(buttonEl);
}
}).fail(function () {
alert('Failed to query the page list.');
resetButton(buttonEl);
});
}
function processPagesSequentially(pages, index, api, buttonEl) {
if (index >= pages.length) {
alert('Finished processing all user talk pages.');
location.reload();
return;
}
const page = pages[index];
const title = page.title;
api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
rvslots: 'main',
format: 'json'
}).done(function (res) {
const pageId = Object.keys(res.query.pages)[0];
let currentContent = '';
if (pageId !== '-1') {
const pageData = res.query.pages[pageId];
if (pageData.revisions && pageData.revisions[0] && pageData.revisions[0].slots.main) {
currentContent = pageData.revisions[0].slots.main['*'];
}
}
if (currentContent.includes(TEMPLATE_NAME)) {
processPagesSequentially(pages, index + 1, api, buttonEl);
} else {
const newContent = TEMPLATE_NAME + '\n' + currentContent;
api.postWithToken('csrf', {
action: 'edit',
title: title,
text: newContent,
summary: SUMMARY_TEXT,
bot: true
}).done(function () {
setTimeout(function() {
processPagesSequentially(pages, index + 1, api, buttonEl);
}, 500);
}).fail(function () {
processPagesSequentially(pages, index + 1, api, buttonEl);
});
}
});
}
function resetButton(buttonEl) {
buttonEl.textContent = 'Deploy Consent Template';
buttonEl.classList.remove('loading');
}
$(document).ready(init);
})();
(function () {
const CONFIG = {
searchText: "{{Consent}}" // Searches the literal text string on the talk pages
};
function initCustomMailer() {
const targetMenu = document.getElementById('p-cactions') || document.getElementById('p-views') || document.getElementById('p-tb');
if (!targetMenu) return;
const container = document.createElement('li');
container.id = 'ca-custom-bulk-mailer';
const button = document.createElement('a');
button.href = '#';
button.textContent = '📧 Send to everyuser';
button.className = 'mass-email-btn'; // Ties directly into your CSS configurations
button.addEventListener('click', function (e) {
e.preventDefault();
handleEmailInputs();
});
container.appendChild(button);
const ul = targetMenu.querySelector('ul');
if (ul) ul.appendChild(container);
}
async function handleEmailInputs() {
const inputSubject = prompt("Enter the EMAIL SUBJECT:");
if (inputSubject === null) return;
if (inputSubject.trim() === "") { alert("Subject cannot be empty!"); return; }
const inputBody = prompt("Enter the EMAIL MESSAGE BODY:\n(Use \\n for new lines)");
if (inputBody === null) return;
if (inputBody.trim() === "") { alert("Message body cannot be empty!"); return; }
const confirmSend = confirm(
`Double check your message:\n\nSubject: ${inputSubject}\nMessage: ${inputBody}\n\nReady to send this exact text to all users who have the "${CONFIG.searchText}" string on their talk pages?`
);
if (confirmSend) startMigrationProcess(inputSubject, inputBody);
}
async function startMigrationProcess(subjectText, bodyText) {
const api = new mw.Api();
try {
// STRATEGY FIX: Scan the text string of Namespace 3 pages directly
const response = await api.get({
action: 'query',
list: 'search',
srsearch: `ns3:"${CONFIG.searchText}"`, // Forces search inside Namespace 3 (User Talk)
srlimit: 'max',
format: 'json'
});
const searchResults = response.query.search || [];
if (searchResults.length === 0) {
alert(`No talk pages found matching the text "${CONFIG.searchText}". Ensure the template string is written exactly on the target talk pages.`);
return;
}
// Strips "User talk:" and isolates subpages smoothly
const users = searchResults.map(item => {
const cleanName = item.title.replace(/^User talk:/, "");
// Splitting on '/' ensures we isolate the base username if it's on a subpage
return cleanName.split('/')[0];
});
// Filters down to completely unique usernames
const uniqueUsers = [...new Set(users)];
const tokenQuery = await api.get({ action: 'query', meta: 'tokens', type: 'csrf' });
const csrfToken = tokenQuery.query.tokens.csrftoken;
alert(`Found ${uniqueUsers.length} unique user(s). Starting delivery loop...`);
let successCount = 0;
for (let i = 0; i < uniqueUsers.length; i++) {
const user = uniqueUsers[i];
try {
await api.post({
action: 'emailuser',
target: user,
subject: subjectText,
text: bodyText,
token: csrfToken
});
console.log("Successfully notified: " + user);
successCount++;
} catch (err) { console.warn("Could not email " + user + ". Reason: " + err); }
}
alert(`Process completed! Sent ${successCount} out of ${uniqueUsers.length} emails.`);
} catch (error) { alert("A server error occurred. Check console logs."); }
}
mw.loader.using(['mediawiki.api', 'mediawiki.util'], function () {
$(document).ready(initCustomMailer);
});
})();
76gr1l3o0atvr2kmh2idg3m03iw53ri
781338
781334
2026-10-28T00:45:27Z
UHHEFUH
76432
781338
javascript
text/javascript
(function () {
const CONFIG = {
searchText: "{{Consent}}" // Searches the literal text string on the talk pages
};
function initCustomMailer() {
const targetMenu = document.getElementById('p-cactions') || document.getElementById('p-views') || document.getElementById('p-tb');
if (!targetMenu) return;
const container = document.createElement('li');
container.id = 'ca-custom-bulk-mailer';
const button = document.createElement('a');
button.href = '#';
button.textContent = '📧 Send to everyuser';
button.className = 'mass-email-btn'; // Ties directly into your CSS configurations
button.addEventListener('click', function (e) {
e.preventDefault();
handleEmailInputs();
});
container.appendChild(button);
const ul = targetMenu.querySelector('ul');
if (ul) ul.appendChild(container);
}
async function handleEmailInputs() {
const inputSubject = prompt("Enter the EMAIL SUBJECT:");
if (inputSubject === null) return;
if (inputSubject.trim() === "") { alert("Subject cannot be empty!"); return; }
const inputBody = prompt("Enter the EMAIL MESSAGE BODY:\n(Use \\n for new lines)");
if (inputBody === null) return;
if (inputBody.trim() === "") { alert("Message body cannot be empty!"); return; }
const confirmSend = confirm(
`Double check your message:\n\nSubject: ${inputSubject}\nMessage: ${inputBody}\n\nReady to send this exact text to all users who have the "${CONFIG.searchText}" string on their talk pages?`
);
if (confirmSend) startMigrationProcess(inputSubject, inputBody);
}
async function startMigrationProcess(subjectText, bodyText) {
const api = new mw.Api();
try {
// STRATEGY FIX: Scan the text string of Namespace 3 pages directly
const response = await api.get({
action: 'query',
list: 'search',
srsearch: `ns3:"${CONFIG.searchText}"`, // Forces search inside Namespace 3 (User Talk)
srlimit: 'max',
format: 'json'
});
const searchResults = response.query.search || [];
if (searchResults.length === 0) {
alert(`No talk pages found matching the text "${CONFIG.searchText}". Ensure the template string is written exactly on the target talk pages.`);
return;
}
// Strips "User talk:" and isolates subpages smoothly
const users = searchResults.map(item => {
const cleanName = item.title.replace(/^User talk:/, "");
// Splitting on '/' ensures we isolate the base username if it's on a subpage
return cleanName.split('/')[0];
});
// Filters down to completely unique usernames
const uniqueUsers = [...new Set(users)];
const tokenQuery = await api.get({ action: 'query', meta: 'tokens', type: 'csrf' });
const csrfToken = tokenQuery.query.tokens.csrftoken;
alert(`Found ${uniqueUsers.length} unique user(s). Starting delivery loop...`);
let successCount = 0;
for (let i = 0; i < uniqueUsers.length; i++) {
const user = uniqueUsers[i];
try {
await api.post({
action: 'emailuser',
target: user,
subject: subjectText,
text: bodyText,
token: csrfToken
});
console.log("Successfully notified: " + user);
successCount++;
} catch (err) { console.warn("Could not email " + user + ". Reason: " + err); }
}
alert(`Process completed! Sent ${successCount} out of ${uniqueUsers.length} emails.`);
} catch (error) { alert("A server error occurred. Check console logs."); }
}
mw.loader.using(['mediawiki.api', 'mediawiki.util'], function () {
$(document).ready(initCustomMailer);
});
})();
bjd5991fubaq6th3t5fjm50430cce0z
781339
781338
2026-10-28T00:47:51Z
UHHEFUH
76432
781339
javascript
text/javascript
(function () {
const CONFIG = {
searchText: "{{Consent}}" // Scans the text footprint directly on talk pages
};
function initCustomMailer() {
// Targets standard menus and Wikimedia's specific user tools/action menus
const targetMenu = document.getElementById('p-cactions') ||
document.getElementById('p-views') ||
document.getElementById('p-tb') ||
document.querySelector('#p-cactions .vector-menu-content-list');
if (!targetMenu) return;
const container = document.createElement('li');
container.id = 'ca-custom-bulk-mailer';
container.className = 'mw-list-item'; // Follows Wikimedia list item standards
const button = document.createElement('a');
button.href = '#';
button.textContent = '📧 Send to everyuser';
button.className = 'mass-email-btn'; // Connected to your CSS layout configuration
button.addEventListener('click', function (e) {
e.preventDefault();
handleEmailInputs();
});
container.appendChild(button);
// Appends to the list element depending on how the menu is built
if (targetMenu.tagName === 'UL') {
targetMenu.appendChild(container);
} else {
const ul = targetMenu.querySelector('ul');
if (ul) {
ul.appendChild(container);
} else {
targetMenu.appendChild(container); // Fallback for raw containers
}
}
}
async function handleEmailInputs() {
const inputSubject = prompt("Enter the EMAIL SUBJECT:");
if (inputSubject === null) return;
if (inputSubject.trim() === "") { alert("Subject cannot be empty!"); return; }
const inputBody = prompt("Enter the EMAIL MESSAGE BODY:\n(Use \\n for new lines)");
if (inputBody === null) return;
if (inputBody.trim() === "") { alert("Message body cannot be empty!"); return; }
const confirmSend = confirm(
`Double check your message:\n\nSubject: ${inputSubject}\nMessage: ${inputBody}\n\nReady to send this exact text to all test users who have "${CONFIG.searchText}" on their talk pages?`
);
if (confirmSend) startMigrationProcess(inputSubject, inputBody);
}
async function startMigrationProcess(subjectText, bodyText) {
const api = new mw.Api();
try {
// Scan text string footprints specifically within Namespace 3 (User Talk) on Test Wikipedia
const response = await api.get({
action: 'query',
list: 'search',
srsearch: `ns3:"${CONFIG.searchText}"`,
srlimit: 'max',
format: 'json'
});
const searchResults = response.query.search || [];
if (searchResults.length === 0) {
alert(`No talk pages found matching the text "${CONFIG.searchText}". Ensure the template text is present on the destination test talk pages.`);
return;
}
// Strips the language-specific talk prefix and cuts out subpage names cleanly
const users = searchResults.map(item => {
const cleanName = item.title.replace(/^User talk:/, "");
return cleanName.split('/')[0]; // Flattened string selection to map raw usernames
});
// Excludes duplicates if a user has the text on multiple talk boards
const uniqueUsers = [...new Set(users)];
const tokenQuery = await api.get({ action: 'query', meta: 'tokens', type: 'csrf' });
const csrfToken = tokenQuery.query.tokens.csrftoken;
alert(`Found ${uniqueUsers.length} unique user(s) on Test Wiki. Starting delivery loop...`);
let successCount = 0;
for (let i = 0; i < uniqueUsers.length; i++) {
const user = uniqueUsers[i];
try {
await api.post({
action: 'emailuser',
target: user,
subject: subjectText,
text: bodyText,
token: csrfToken
});
console.log("Successfully notified: " + user);
successCount++;
} catch (err) { console.warn("Could not email " + user + ". Reason: " + err); }
}
alert(`Process completed! Sent ${successCount} out of ${uniqueUsers.length} emails.`);
} catch (error) { alert("A server error occurred. Check console logs."); }
}
mw.loader.using(['mediawiki.api', 'mediawiki.util'], function () {
$(document).ready(initCustomMailer);
});
})();
qokazs28bvtpcfgmrk0e28qjd0m5jxd
781341
781339
2026-10-28T00:52:28Z
UHHEFUH
76432
781341
javascript
text/javascript
(function () {
const CONFIG = {
searchText: "{{Consent}}" // Scans the text footprint directly on talk pages
};
function initVector2022Mailer() {
// Use MediaWiki's official hook handler to safely build menu elements across Vector 2022
// 'p-cactions' automatically redirects to the right-side drop-down menu in Vector 2022
const portletLink = mw.util.addPortletLink(
'p-cactions', // Target menu ID
'#', // Link URL
'📧 Send to everyuser', // Visual text label
'ca-custom-bulk-mailer', // Element ID attribute
'Send custom emails to all consented users', // Tooltip message
null, // Access key shortcut
null // Reference node placement placement
);
if (!portletLink) {
console.error("Vector 2022 Mailer: Could not inject the menu anchor link link element.");
return;
}
// Apply our custom CSS class selector manually to the newly created anchor node
const anchorElement = portletLink.querySelector('a') || portletLink;
if (anchorElement) {
anchorElement.className += ' mass-email-btn';
}
// Catch the action event click listener cleanly
portletLink.addEventListener('click', function (e) {
e.preventDefault();
handleEmailInputs();
});
}
async function handleEmailInputs() {
const inputSubject = prompt("Enter the EMAIL SUBJECT:");
if (inputSubject === null) return;
if (inputSubject.trim() === "") { alert("Subject cannot be empty!"); return; }
const inputBody = prompt("Enter the EMAIL MESSAGE BODY:\n(Use \\n for new lines)");
if (inputBody === null) return;
if (inputBody.trim() === "") { alert("Message body cannot be empty!"); return; }
const confirmSend = confirm(
`Double check your message:\n\nSubject: ${inputSubject}\nMessage: ${inputBody}\n\nReady to send this exact text to all test users who have "${CONFIG.searchText}" on their talk pages?`
);
if (confirmSend) startMigrationProcess(inputSubject, inputBody);
}
async function startMigrationProcess(subjectText, bodyText) {
const api = new mw.Api();
try {
const response = await api.get({
action: 'query',
list: 'search',
srsearch: `ns3:"${CONFIG.searchText}"`,
srlimit: 'max',
format: 'json'
});
const searchResults = response.query.search || [];
if (searchResults.length === 0) {
alert(`No talk pages found matching the text "${CONFIG.searchText}".`);
return;
}
const users = searchResults.map(item => {
const cleanName = item.title.replace(/^User talk:/, "");
return cleanName.split('/')[0]; // Grabs the base username text entry strings
});
const uniqueUsers = [...new Set(users)];
const tokenQuery = await api.get({ action: 'query', meta: 'tokens', type: 'csrf' });
const csrfToken = tokenQuery.query.tokens.csrftoken;
alert(`Found ${uniqueUsers.length} unique user(s) on Test Wiki. Starting delivery loop...`);
let successCount = 0;
for (let i = 0; i < uniqueUsers.length; i++) {
const user = uniqueUsers[i];
try {
await api.post({
action: 'emailuser',
target: user,
subject: subjectText,
text: bodyText,
token: csrfToken
});
console.log("Successfully notified: " + user);
successCount++;
} catch (err) { console.warn("Could not email " + user + ". Reason: " + err); }
}
alert(`Process completed! Sent ${successCount} out of ${uniqueUsers.length} emails.`);
} catch (error) { alert("A server error occurred. Check console logs."); }
}
// Ensure the entire core MediaWiki helper dependencies are locked and ready before firing
mw.loader.using(['mediawiki.api', 'mediawiki.util'], function () {
$(document).ready(initVector2022Mailer);
});
})();
62yp4wsjmse44klsqkmhtlher4ec1qf
User:UHHEFUH/common.css
2
189934
781276
781071
2026-10-28T00:29:49Z
UHHEFUH
76432
781276
css
text/css
/* Sidebar Container Spacing */
li.custom-consent-btn-container {
margin: 8px 0 !important;
list-style: none !important;
}
/* Styled Bright Action Button */
a.custom-consent-btn {
display: block !important;
background-color: #007bff !important;
color: #ffffff !important;
font-weight: bold !important;
text-align: center !important;
padding: 6px 10px !important;
border-radius: 4px !important;
text-decoration: none !important;
border: 1px solid #0069d9 !important;
transition: background-color 0.2s ease, transform 0.1s ease !important;
box-shadow: 0 2px 4px rgba(0,0,0,0.1) !important;
}
/* Hover Interaction Style */
a.custom-consent-btn:hover {
background-color: #0069d9 !important;
color: #ffffff !important;
text-decoration: none !important;
box-shadow: 0 4px 6px rgba(0,0,0,0.15) !important;
}
/* Active Click Effect */
a.custom-consent-btn:active {
transform: scale(0.98) !important;
}
/* Processing State Override */
a.custom-consent-btn.loading {
background-color: #6c757d !important;
border-color: #5c636a !important;
pointer-events: none !important;
cursor: not-allowed !important;
opacity: 0.8 !important;
}
9l7r4rw2g3z8t5cofmat7n7v630ex7m
781336
781276
2026-10-28T00:43:28Z
UHHEFUH
76432
781336
css
text/css
/* Sidebar Container Spacing */
li.custom-consent-btn-container {
margin: 8px 0 !important;
list-style: none !important;
}
/* Styled Bright Action Button */
a.custom-consent-btn {
display: block !important;
background-color: #007bff !important;
color: #ffffff !important;
font-weight: bold !important;
text-align: center !important;
padding: 6px 10px !important;
border-radius: 4px !important;
text-decoration: none !important;
border: 1px solid #0069d9 !important;
transition: background-color 0.2s ease, transform 0.1s ease !important;
box-shadow: 0 2px 4px rgba(0,0,0,0.1) !important;
}
/* Hover Interaction Style */
a.custom-consent-btn:hover {
background-color: #0069d9 !important;
color: #ffffff !important;
text-decoration: none !important;
box-shadow: 0 4px 6px rgba(0,0,0,0.15) !important;
}
/* Active Click Effect */
a.custom-consent-btn:active {
transform: scale(0.98) !important;
}
/* Processing State Override */
a.custom-consent-btn.loading {
background-color: #6c757d !important;
border-color: #5c636a !important;
pointer-events: none !important;
cursor: not-allowed !important;
opacity: 0.8 !important;
}
/* Gives the mass email menu button link its crimson appearance */
.mass-email-btn {
color: #d33030 !important;
font-weight: bold !important;
border: 1px solid transparent;
transition: all 0.2s ease-in-out;
}
/* Enhances readability with a solid red hover block when selecting the option */
.mass-email-btn:hover {
color: #ffffff !important;
background-color: #d33030 !important;
border-radius: 2px;
text-decoration: none !important;
padding-left: 4px;
padding-right: 4px;
}
7lcsts185dh8uob8z6c4eevbqfbjrzq
781337
781336
2026-10-28T00:44:14Z
UHHEFUH
76432
781337
css
text/css
/* Gives the mass email menu button link its crimson appearance */
.mass-email-btn {
color: #d33030 !important;
font-weight: bold !important;
border: 1px solid transparent;
transition: all 0.2s ease-in-out;
}
/* Enhances readability with a solid red hover block when selecting the option */
.mass-email-btn:hover {
color: #ffffff !important;
background-color: #d33030 !important;
border-radius: 2px;
text-decoration: none !important;
padding-left: 4px;
padding-right: 4px;
}
50iu87gfm565c1w8qx84hfqwjsldqu0
781342
781337
2026-10-28T00:52:54Z
UHHEFUH
76432
781342
css
text/css
/* Styling targets the link inside the Vector 2022 layout menus */
.mass-email-btn {
color: #d33030 !important; /* Bold Crimson */
font-weight: bold !important;
transition: all 0.2s ease-in-out;
}
.mass-email-btn:hover {
color: #ffffff !important;
background-color: #d33030 !important;
border-radius: 2px;
text-decoration: none !important;
}
kq7vgfq7q0ku0ga37soztepbjpolhe4
Wikipedia:Sandbox/MoveIt moved 2026-10-27T12-05-53-271Z
4
189940
781153
2026-10-27T12:05:54Z
Closed Limelike Curves
60396
MoveIt live rename test: creating the page to be renamed
781153
wikitext
text/x-wiki
Throwaway page for the Move It live rename test (2026-10-27T12-05-53-271Z). Safe to delete.
ol182v7hdwy6o3ydnfrjklqcunsrf8r
781154
781153
2026-10-27T12:05:55Z
Closed Limelike Curves
60396
Closed Limelike Curves moved page [[Wikipedia:Sandbox/MoveIt move 2026-10-27T12-05-53-271Z]] to [[Wikipedia:Sandbox/MoveIt moved 2026-10-27T12-05-53-271Z]]: Move It live rename test (via Move It browser extension)
781153
wikitext
text/x-wiki
Throwaway page for the Move It live rename test (2026-10-27T12-05-53-271Z). Safe to delete.
ol182v7hdwy6o3ydnfrjklqcunsrf8r
781157
781154
2026-10-27T12:05:57Z
Closed Limelike Curves
60396
MoveIt live rename test teardown
781157
wikitext
text/x-wiki
phoiac9h4m842xq45sp7s6u21eteeq1
Wikipedia:Sandbox/MoveIt move 2026-10-27T12-05-53-271Z
4
189941
781155
2026-10-27T12:05:55Z
Closed Limelike Curves
60396
Closed Limelike Curves moved page [[Wikipedia:Sandbox/MoveIt move 2026-10-27T12-05-53-271Z]] to [[Wikipedia:Sandbox/MoveIt moved 2026-10-27T12-05-53-271Z]]: Move It live rename test (via Move It browser extension)
781155
wikitext
text/x-wiki
#REDIRECT [[Wikipedia:Sandbox/MoveIt moved 2026-10-27T12-05-53-271Z]]
{{Redirect category shell|
{{R from move}}
}}
94w1g3ng3xppdydg20nl1jzewx86y0b
781156
781155
2026-10-27T12:05:57Z
Closed Limelike Curves
60396
MoveIt live rename test teardown
781156
wikitext
text/x-wiki
phoiac9h4m842xq45sp7s6u21eteeq1
Wikipedia:AI noticeboard/DVRTed ALT
4
189942
781162
2026-10-27T15:07:56Z
DVRTed
55993
Filing a new case (using [[User:DVRTed/AINB-helper|AINB-helper]])
781162
wikitext
text/x-wiki
{{AINB case banner|cleanup=analysis|llmprod=undetermined|conduct=review|case_name=DVRTed ALT|original_file_date=27 October 2026|case_type=user|}}
<!--See statuses at [[Template:AINB case banner/data]]. For admins: |conduct= can be inactive, pblock, siteblock, or resolved.-->
== Discussion: DVRTed ALT ==
{{User-ainb|1=DVRTed ALT}}
=== 27 October 2026 ===
New case [[User:DVRTed|DVRTed]] ([[User talk:DVRTed|talk]]) 15:07, 27 October 2026 (UTC)
== Cleanup ==
{{AIC article list|
<!--{{AIC article row|article=Title|status=to do (default); ongoing; completed; unnecessary|notes=Additional notes, e.g. links to individual edits}}-->
|collapsible=yes}}
01w21zxz1tzaeffeyv4q60649dvxeem
781167
781162
2026-10-27T15:34:48Z
DVRTed
55993
Adding a new discussion subsection (using [[User:DVRTed/AINB-helper|AINB-helper]])
781167
wikitext
text/x-wiki
{{AINB case banner|cleanup=analysis|llmprod=undetermined|conduct=review|case_name=DVRTed ALT|original_file_date=27 October 2026|case_type=user|}}
<!--See statuses at [[Template:AINB case banner/data]]. For admins: |conduct= can be inactive, pblock, siteblock, or resolved.-->
== Discussion: DVRTed ALT ==
{{User-ainb|1=DVRTed ALT}}
=== 27 October 2026 ===
New case [[User:DVRTed|DVRTed]] ([[User talk:DVRTed|talk]]) 15:07, 27 October 2026 (UTC)
=== 27 October 2026 ===
Test123 [[User:DVRTed|DVRTed]] ([[User talk:DVRTed|talk]]) 15:34, 27 October 2026 (UTC)
== Cleanup ==
{{AIC article list|
<!--{{AIC article row|article=Title|status=to do (default); ongoing; completed; unnecessary|notes=Additional notes, e.g. links to individual edits}}-->
|collapsible=yes}}
au1v74vce6uurkvk5imdk9hhmynpqq5
Wikipedia:AI noticeboard/DVRTed Family
4
189943
781163
2026-10-27T15:15:01Z
DVRTed
55993
Filing a new case (using [[User:DVRTed/AINB-helper|AINB-helper]])
781163
wikitext
text/x-wiki
{{AINB case banner|cleanup=analysis|llmprod=undetermined|conduct=review|case_name=DVRTed Family|original_file_date=27 October 2026|case_type=group|}}
<!--See statuses at [[Template:AINB case banner/data]]. For admins: |conduct= can be inactive, pblock, siteblock, or resolved.-->
== Discussion: DVRTed Family ==
=== 27 October 2026 ===
final test [[User:DVRTed|DVRTed]] ([[User talk:DVRTed|talk]]) 15:15, 27 October 2026 (UTC)
== Cleanup ==
{{AIC article list|
<!--{{AIC article row|article=Title|status=to do (default); ongoing; completed; unnecessary|notes=Additional notes, e.g. links to individual edits}}-->
|collapsible=yes}}
ei7ioc0t0ogsab6hemlbusq4220n6cy
Template:User be-4
10
189944
781164
2026-10-27T15:16:19Z
Ясамойла
68566
пачатак
781164
wikitext
text/x-wiki
{{userbox-level
| level = 4
| id = [[Belarusian language|be]]
| info = Гэты ўдзельнік валодае '''[[:Category:User be|беларускай]]''' мовай '''[[:Category:User be-4|амаль]]''' як роднай.
| usercategory = User be-4
| nocat = {{{nocat|}}}
}}<noinclude>
{{documentation}}
</noinclude>i
biotv4ym9pjuzn3y98ykvznlqfmye5l
Category:User be
14
189945
781165
2026-10-27T15:19:46Z
Ясамойла
68566
пачатак
781165
wikitext
text/x-wiki
{{#babel:be-0|be-1|be-2|be-3|be-4|be-5|be-N}}
[[Category:User|be]]
bw5z8vvf1e5zfwp73agc6wrz68eo6fq
Category:User be-4
14
189946
781166
2026-10-27T15:21:44Z
Ясамойла
68566
пачатак
781166
wikitext
text/x-wiki
[[Category:User be]]
ncafij28dotd1t69o1y4ftcrlk5v0kk
User:Rots61/DisamAssist-a.js
2
189947
781169
2026-10-27T15:35:38Z
Rots61
44293
Created page with "/* * DisamAssist: a tool for repairing links from articles to disambiguation pages. */ // <nowiki> // @ts-nocheck ( () => { var cfg = {}; var txt = {}; var ui; var links, pageChanges; var currentPageTitle, currentPageParameters, currentLink; var possibleBacklinkDestinations; var titleRegex; var forceSamePage = false; var running = false; var choosing = false; var canMarkIntentionalLinks = false; var displayedPages = {}; var editCount = 0; var editLimit;..."
781169
javascript
text/javascript
/*
* DisamAssist: a tool for repairing links from articles to disambiguation pages.
*/
// <nowiki>
// @ts-nocheck
( () => {
var cfg = {};
var txt = {};
var ui;
var links, pageChanges;
var currentPageTitle, currentPageParameters, currentLink;
var possibleBacklinkDestinations;
var titleRegex;
var forceSamePage = false;
var running = false;
var choosing = false;
var canMarkIntentionalLinks = false;
var displayedPages = {};
var editCount = 0;
var editLimit;
var pendingSaves = [];
var pendingEditBox = null;
var pendingEditBoxText;
var lastEditMillis = 0;
var runningSaves = false;
var api;
const dependencies = [
'mediawiki.api',
'mediawiki.confirmCloseWindow',
'mediawiki.Title',
'mediawiki.util',
];
/**
* Add an item in Tools menu
* @param {string} text
* @param {string} id
* @param {(e: MouseEvent) => void} callback
*/
const disamPortletLink = ( text, id, callback ) => {
const portlet = mw.util.addPortletLink(
'p-tb',
cfg.helpPage ? mw.util.getUrl( cfg.helpPage ) : '#',
text,
id
);
const link = portlet?.querySelector( 'a' );
link?.addEventListener( 'click', callback );
addButtonEvents( link );
}
/*
* Entry point. Check whether we are in a disambiguation page. If so, add a link to start the tool
*/
const install = function() {
cfg = window.DisamAssist.cfg;
txt = window.DisamAssist.txt;
if ( mw.config.get( 'wgAction' ) === 'view' && isDisam() ) {
mw.loader.using( dependencies, () => {
api = new mw.Api();
mw.loader.load( 'https://ru.wikipedia.org/w/index.php?title=Участник:Stjn/DisamAssist.css&action=raw&ctype=text/css', 'text/css' );
// This is a " (disambiguation)" page
if ( new RegExp( cfg.disamRegExp ).exec( getTitle() ) ) {
disamPortletLink( txt.startMain, 't-disamAssist-main', startMain );
disamPortletLink( txt.startSame, 't-disamAssist-same', startSame );
} else {
disamPortletLink( txt.start, 't-disamAssist-page', start );
}
} );
}
};
/*
* Start the tool. Display the UI and begin looking for links to fix
*/
const start = function( e ) {
e?.preventDefault();
if ( !running ) {
console.log(Date.now())
running = true;
links = [];
pageChanges = [];
displayedPages = {};
ensureDABExists().then( function( canMark ) {
canMarkIntentionalLinks = canMark;
createUI();
addUnloadConfirm();
markDisamOptions();
checkEditLimit().then( function() {
togglePendingEditBox( false );
doPage();
} );
} );
}
};
/*
* Start DisamAssist. Disambiguate incoming links to the current page, regardless
* of the title.
*/
const startSame = function( e ) {
e.preventDefault();
forceSamePage = true;
start( e );
};
/*
* Start DisamAssist. If the page title ends with " (disambiguation)", disambiguate
* links to the primary topic article. Otherwise, disambiguate links to the current
* page.
*/
const startMain = function( e ) {
e.preventDefault();
forceSamePage = false;
start( e );
};
/*
* Create and show the user interface.
*/
const createUI = function() {
ui = {
display: $( '<div>' ).addClass( 'disamassist-box disamassist-mainbox' ),
finishedMessage: $( '<div>' ).text( txt.noMoreLinks ).hide(),
pageTitleLine: $( '<span>' ).addClass( 'disamassist-pagetitleline' ),
pendingEditCounter: $( '<div>').addClass( 'disamassist-editcounter' ),
context: $( '<pre>' ).addClass( 'disamassist-context' ),
undoButton: createButton( txt.undo, undo ),
omitButton: createButton( txt.omit, omit ),
endButton: createButton( txt.close, saveAndEnd ),
refreshButton: createButton( txt.refresh, refresh ),
titleAsTextButton: createButton( txt.titleAsText, chooseTitleFromPrompt ),
intentionalLinkButton: canMarkIntentionalLinks ? createButton( txt.intentionalLink, chooseIntentionalLink ) : null,
disamNeededButton: cfg.disamNeededText ? createButton( txt.disamNeeded, chooseDisamNeeded ) : null,
removeLinkButton: createButton( txt.removeLink, chooseLinkRemoval )
};
var top = $( '<div>' ).addClass( 'disamassist-top' )
.append( [ui.pageTitleLine, ui.pendingEditCounter] );
var leftButtons = $( '<div>' ).addClass( 'disamassist-leftbuttons' )
.append( [ui.titleAsTextButton, ui.removeLinkButton, ui.intentionalLinkButton, ui.disamNeededButton, ui.omitButton] );
var rightButtons = $( '<div>' ).addClass( 'disamassist-rightbuttons' )
.append( [ui.undoButton, ui.refreshButton, ui.endButton] );
var allButtons = $( '<div>' ).addClass( 'disamassist-allbuttons' )
.append( [leftButtons, rightButtons] );
ui.display.append( [top, ui.finishedMessage, ui.context, allButtons] );
updateEditCounter();
toggleActionButtons( false );
// Insert the UI in the page
$( '#mw-content-text' ).before( ui.display );
ui.display.hide().fadeIn();
};
/**
* Add button role and related keyboard event
* @param {Element} el
*/
const addButtonEvents = ( el ) => {
el?.setAttribute( 'role', 'button' );
el?.addEventListener( 'keydown', function( e ) {
if ( [ 'Enter', 'Space' ].includes( e.code ) ) {
e.preventDefault();
this.click();
}
} );
}
/*
* If there are pending changes, show a confirm dialog before closing
*/
const addUnloadConfirm = function() {
mw.confirmCloseWindow( {
test: () => {
if ( running && checkActualChanges() ) {
return txt.pending;
} else if ( editCount !== 0 ) {
return txt.editInProgress;
}
}
} );
};
/*
* Mark the disambiguation options as such
*/
const markDisamOptions = function() {
var optionPageTitles = [];
var optionMarkers = [];
getDisamOptions().each( function() {
var link = $( this );
var title = extractPageName( link );
const optionMarker = document.createElement( 'a' );
optionMarker.classList.add( 'disamassist-optionmarker' );
optionMarker.href = link.attr( 'href' );
optionMarker.setAttribute( 'data-title', title );
optionMarker.textContent = txt.optionMarker;
optionMarker.addEventListener( 'click', ( e ) => {
e.preventDefault();
chooseReplacement( title );
} );
addButtonEvents( optionMarker );
link.after( optionMarker );
optionMarkers.push( optionMarker );
optionPageTitles.push( title );
} );
// Now check the disambiguation options and display a different message for those that are
// actually the same as the target page where the links go, as choosing those options doesn't really
// accomplish anything (except bypassing redirects, which might be useful in some cases)
var targetPage = getTargetPage();
fetchRedirects( optionPageTitles.concat( targetPage ) ).done( function( redirects ) {
var endTargetPage = resolveRedirect( targetPage, redirects );
for ( var ii = 0; ii < optionPageTitles.length; ii++ ) {
var endOptionTitle = resolveRedirect( optionPageTitles[ii], redirects );
if ( isSamePage( optionPageTitles[ii], targetPage ) ) {
$( optionMarkers[ii] ).text( txt.targetOptionMarker ).addClass( 'disamassist-curroptionmarker');
} else if ( isSamePage( endOptionTitle, endTargetPage ) ) {
$( optionMarkers[ii] ).text( txt.redirectOptionMarker ).addClass( 'disamassist-curroptionmarker');
}
}
} ).fail( error );
};
/*
* Check whether intentional links to disambiguation pages can be explicitly marked
* as such in this wiki. If so, ensure that a "Foo (disambiguation)" page exists.
* Returns a jQuery promise
*/
const ensureDABExists = function() {
var dfd = new $.Deferred();
var title = getTitle();
// That concept doesn't exist in this wiki.
if ( !cfg.intentionalLinkOption ) {
dfd.resolve( false );
// "Foo (disambiguation)" exists: it's the current page.
} else if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
dfd.resolve( true );
} else {
var disamTitle = cfg.disamFormat.replace( '$1', title );
loadPage( disamTitle ).done( function( page ) {
// "Foo (disambiguation)" doesn't exist.
if ( page.missing ) {
// We try to create it
page.content = cfg.redirectToDisam.replace( '$1', title );
var summary = txt.redirectSummary.replace( '$1', title );
savePage( disamTitle, page, summary, false, true ).done( function() {
dfd.resolve( true );
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
// It does exist
} else {
dfd.resolve( true );
}
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
}
return dfd.promise();
};
/*
* Check whether the edit cooldown applies and sets editLimit accordingly.
* Returns a jQuery promise
*/
const checkEditLimit = function() {
var dfd = new $.Deferred();
if ( cfg.editCooldown <= 0 ) {
editLimit = false;
dfd.resolve();
} else {
fetchRights().done( function( rights ) {
editLimit = !rights?.includes( 'bot' );
} ).fail( function( description ) {
error( description );
editLimit = true;
} ).always( function() {
dfd.resolve();
} );
}
return dfd.promise();
};
/*
* Find and ask the user to fix all the incoming links to the disambiguation ("target")
* page from a single "origin" page
*/
const doPage = function() {
if ( pageChanges.length > cfg.historySize ) {
applyChange( pageChanges.shift() );
}
if ( links.length === 0 ) {
var targetPage = getTargetPage();
getBacklinks( targetPage ).done( function( backlinks, pageTitles ) {
var pending = {};
$.each( pendingSaves, function() {
pending[this[0]] = true;
} );
possibleBacklinkDestinations = $.grep( pageTitles, function( t, ii) {
if ( t == targetPage ) {
return true;
}
return removeDisam(t) != targetPage;
} );
titleRegex = new RegExp('\\[\\[([ _]*?(?:' + possibleBacklinkDestinations.join('|').replaceAll('(', '\\(').replaceAll(')','\\)').replaceAll(' ', '[ _]') + ')[ _]*?)(#.*?)?(?:\\|(.*?))?\\]\\]', 'gi');
// Only incoming links from pages we haven't seen yet and we aren't currently
// saving (displayedPages is reset when the tool is closed and opened again,
// while the list of pending changes isn't; if the edit cooldown is disabled,
// it will be empty)
links = $.grep( backlinks, function( el, ii ) {
return !displayedPages[el] && !pending[el];
} );
if ( links.length === 0 ) {
updateContext();
} else {
doPage();
}
} ).fail( error );
} else {
currentPageTitle = links.shift();
displayedPages[currentPageTitle] = true;
toggleActionButtons( false );
loadPage( currentPageTitle ).done( function( data ) {
currentPageParameters = data;
currentLink = null;
doLink();
} ).fail( error );
}
};
/*
* Find and ask the user to fix a single incoming link to the disambiguation ("target")
* page
*/
const doLink = function() {
if (!currentPageParameters.redirect) {
currentLink = extractLinkToPage( currentPageParameters.content,
possibleBacklinkDestinations, currentLink ? currentLink.end : 0 );
if ( currentLink ) {
updateContext();
} else {
doPage();
}
} else {
doPage();
}
};
/*
* Replace the target of a link with a different one
* pageTitle: New link target
* extra: Additional text after the link (optional)
* summary: Change summary (optional)
*/
const chooseReplacement = function( pageTitle, extra, summary ) {
if ( choosing ) {
choosing = false;
if ( !summary ) {
if ( pageTitle ) {
summary = txt.summaryChanged.replace( '$1', pageTitle );
} else {
summary = txt.summaryOmitted;
}
}
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
if ( pageTitle && ( pageTitle !== getTargetPage() || extra ) ) {
if ( currentPageParameters.redirect ) {
currentLink.description = pageTitle;
}
currentPageParameters.content =
replaceLink( currentPageParameters.content, pageTitle, currentLink, extra || '' );
}
doLink();
}
};
/*
* Replace the link with an explicit link to the disambiguation page
*/
const chooseIntentionalLink = function() {
const targetPage = getTargetPage();
let title = cfg.disamFormat.replace( '$1', targetPage );
if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
title = targetPage;
}
chooseReplacement( title, '', txt.summaryIntentional );
};
/*
* Prompt for an alternative link target and use it as a replacement
*/
const chooseTitleFromPrompt = function() {
var title = prompt( txt.titleAsTextPrompt );
if ( title !== null ) {
chooseReplacement( title );
}
};
/*
* Remove the current link, leaving the text unchanged
*/
const chooseLinkRemoval = function() {
if ( choosing ) {
var summary = txt.summaryRemoved;
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
currentPageParameters.content = removeLink( currentPageParameters.content, currentLink );
doLink();
}
};
/*
* Add a "disambiguation needed" template after the link
*/
const chooseDisamNeeded = function() {
chooseReplacement( currentLink.title, cfg.disamNeededText, txt.summaryHelpNeeded );
};
/*
* Undo the last change
*/
const undo = function() {
if ( pageChanges.length !== 0 ) {
var lastPage = pageChanges[pageChanges.length - 1];
if ( currentPageTitle !== lastPage.title ) {
links.unshift( currentPageTitle );
currentPageTitle = lastPage.title;
}
currentPageParameters = lastPage.page;
currentPageParameters.content = lastPage.contentBefore.pop();
currentLink = lastPage.links.pop();
lastPage.summary.pop();
if ( lastPage.contentBefore.length === 0 ) {
pageChanges.pop();
}
updateContext();
}
};
/*
* Omit the current link without making a change
*/
const omit = function() {
chooseReplacement( null );
};
/*
* Save all the pending changes and restart the tool.
*/
const refresh = function() {
saveAndEnd();
start();
};
/*
* Enable or disable the buttons that can perform actions on a page or change the current link.
* enabled: Whether to enable or disable the buttons
*/
const toggleActionButtons = function( enabled ) {
var affectedButtons = [ui.omitButton, ui.titleAsTextButton, ui.removeLinkButton,
ui.intentionalLinkButton, ui.disamNeededButton, ui.undoButton];
$.each( affectedButtons, function( ii, button ) {
button?.prop( 'disabled', !enabled );
} );
};
/*
* Show or hide the 'no more links' message
* show: Whether to show or hide the message
*/
const toggleFinishedMessage = function( show ) {
toggleActionButtons( !show );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.finishedMessage.toggle( show );
ui.pageTitleLine.toggle( !show );
ui.context.toggle( !show );
if ( show ) console.log(Date.now())
};
const togglePendingEditBox = function( show ) {
if ( pendingEditBox === null ) {
pendingEditBox = $( '<div>' ).addClass( 'disamassist-box disamassist-pendingeditbox' );
pendingEditBoxText = $( '<div>' );
pendingEditBox.append( pendingEditBoxText ).hide();
if ( editLimit ) {
pendingEditBox.append( $( '<div>' ).text( txt.pendingEditBoxLimited )
.addClass( 'disamassist-subtitle' ) );
}
$( '#mw-content-text' ).before( pendingEditBox );
updateEditCounter();
}
if ( show ) {
pendingEditBox.fadeIn();
} else {
pendingEditBox.fadeOut();
}
};
const notifyCompletion = function() {
var oldTitle = document.title;
document.title = txt.notifyCharacter + document.title;
$( document.body ).one( 'mousemove', function() {
document.title = oldTitle;
} );
};
/*
* Update the displayed information to match the current link
* or lack thereof
*/
const updateContext = function() {
updateEditCounter();
if ( !currentLink ) {
toggleFinishedMessage( true );
} else {
ui.pageTitleLine.html( txt.pageTitleLine.replace( '$1',
mw.util.getUrl( currentPageTitle, {redirect: 'no'} ) ).replace( '$2', mw.html.escape( currentPageTitle ) ) );
var context = extractContext( currentPageParameters.content, currentLink );
ui.context.empty()
.append( $( '<span>' ).text( context[0] ) )
.append( $( '<span>' ).text( context[1] ).addClass( 'disamassist-inclink' ) )
.append( $( '<span>' ).text( context[2] ) );
toggleFinishedMessage( false );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.removeLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.intentionalLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.disamNeededButton?.prop( 'disabled', currentPageParameters.redirect || currentLink.hasDisamTemplate );
choosing = true;
}
};
/*
* Update the count of pending changes
*/
const updateEditCounter = function() {
if ( ui.pendingEditCounter ) {
ui.pendingEditCounter.text( txt.pendingEditCounter.replace( '$1', editCount )
.replace( '$2', countActuallyChangedFullyCheckedPages() ) );
}
if ( pendingEditBox ) {
if ( editCount === 0 && !running ) {
togglePendingEditBox( false );
notifyCompletion();
}
var textContent = editCount;
if ( editLimit ) {
textContent = txt.pendingEditBoxTimeEstimation.replace( '$1', editCount )
.replace( '$2', secondsToHHMMSS( cfg.editCooldown * editCount ) );
}
pendingEditBoxText.text( txt.pendingEditBox.replace( '$1', textContent ) );
}
};
/*
* Apply the changes made to an "origin" page
* pageChange: Change that will be saved
*/
const applyChange = function( pageChange ) {
if ( pageChange.page.content !== pageChange.contentBefore[0] ) {
editCount++;
// Filter duplicates and join
var changeSummaries = pageChange.summary
.filter( ( val, i, arr ) => val && arr.indexOf( val ) === i )
.join( txt.summarySeparator );
var summary = txt.summary.replace( '$1', getTargetPage() ).replace( '$2', changeSummaries );
var save = editLimit ? saveWithCooldown : savePage;
save( pageChange.title, pageChange.page, summary, true, true ).always( function() {
if ( editCount > 0 ) {
editCount--;
}
updateEditCounter();
} ).fail( error );
updateEditCounter();
}
};
/*
* Save all the pending changes
*/
const applyAllChanges = function() {
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
applyChange( pageChanges[ii] );
}
pageChanges = [];
};
/*
* Record a new pending change
* pageTitle: Title of the page
* page: Content of the page
* oldContent: Content of the page before the change
* link: Link that has been changed
* summary: Change summary
*/
const addChange = function( pageTitle, page, oldContent, link, summary ) {
if ( ( pageChanges.length === 0 ) || ( pageChanges[pageChanges.length - 1].title !== pageTitle ) ) {
pageChanges.push( {
title: pageTitle,
page: page,
contentBefore: [],
links: [],
summary: []
} );
}
var lastPageChange = pageChanges[pageChanges.length - 1];
lastPageChange.contentBefore.push( oldContent );
lastPageChange.links.push( link );
lastPageChange.summary.push( summary );
};
/*
* Check whether actual changes are stored in the history array
*/
const checkActualChanges = function() {
return countActualChanges() !== 0;
};
/*
* Return the number of entries in the history array that represent actual changes
*/
const countActualChanges = function() {
var changeCount = 0;
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
if ( pageChanges[ii].page.content !== pageChanges[ii].contentBefore[0] ) {
changeCount++;
}
}
return changeCount;
};
/*
* Return the number of changed pages in the history array, ignoring the last entry
* if we aren't done with that page yet
*/
const countActuallyChangedFullyCheckedPages = function() {
var changeCount = countActualChanges();
if ( pageChanges.length !== 0 ) {
var lastChange = pageChanges[pageChanges.length - 1];
if ( lastChange.title === currentPageTitle && currentLink !== null
&& lastChange.page.content !== lastChange.contentBefore[0] ) {
changeCount--;
}
}
return changeCount;
};
/*
* Find the links to disambiguation options in a disambiguation page
*/
const getDisamOptions = function() {
return $( '#mw-content-text a' ).filter( function() {
return !!extractPageName( $( this ) );
} );
};
/*
* Save all the pending changes and close the tool
*/
const saveAndEnd = function() {
applyAllChanges();
end();
};
/*
* Close the tool
*/
const end = function() {
var currentToolUI = ui.display;
choosing = false;
running = false;
$( '.disamassist-optionmarker' ).remove();
currentToolUI.fadeOut( { complete: function() {
currentToolUI.remove();
if ( editCount !== 0 ) {
togglePendingEditBox( true );
}
} } );
};
/*
* Display an error message
*/
const error = function( errorDescription ) {
var errorBox = $( '<div>' ).addClass( 'disamassist-box disamassist-errorbox' );
errorBox.text( txt.error.replace( '$1', errorDescription ) );
errorBox.append( createButton( txt.dismissError, function() {
errorBox.fadeOut();
} ).addClass( 'disamassist-errorbutton' ) );
var uiIsInPlace = ui && $.contains( document.documentElement, ui.display[0] );
var nextElement = uiIsInPlace ? ui.display : $( '#mw-content-text' );
nextElement.before( errorBox );
errorBox.hide().fadeIn();
}
/*
* Change a link so that it points to the title
* text: The wikitext of the whole page
* title: The new destination of the link
* link: The link that will be modified
* extra: Text that will be added after the link (optional)
*/
const replaceLink = function( text, title, link, extra ) {
var newContent;
if ( link.type === 'sortnaam' ) {
newContent = '{{sortnaam|' + link.first + '|' + link.last + '|' + title + '}}';
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + ( extra || '' ) + linkEnd;
}
if ( isSamePage( title, link.description ) ) {
// [[B|A]] should be replaced with [[A]] rather than [[A|A]]
newContent = link.description;
} else {
newContent = title + '|' + link.description;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + '[[' + newContent + ']]' + link.afterDescription + ( extra || '' ) + linkEnd;
};
/*
* Remove a link from the text
* text: The wikitext of the whole page
* link: The link that will be removed
*/
const removeLink = function( text, link ) {
if ( link.type === 'sortnaam' ) {
var newContent = '{{sortnaam|' + link.first + '|' + link.last + '}}';
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + linkEnd;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + link.description + link.afterDescription + linkEnd;
};
/*
* Extract a link from a string in wiki format,
* starting from a given index. Return a link if one can be found,
* otherwise return null. The "link" includes "disambiguation needed"
* templates inmediately following the link proper
* text: Text from which the link will be extracted
* lastIndex: Index from which the search will start
*/
const extractLink = function( text, lastIndex ) {
// Zoek eerst naar gewone wikitlinks
titleRegex.lastIndex = lastIndex;
var wikiMatch = titleRegex.exec( text );
// Zoek naar sortnaam-templates (case-insensitief)
var sortnaamRegex = new RegExp('{{\\s*sortnaam\\s*\\|\\s*([^|]+?)\\s*\\|\\s*([^|]+?)\\s*(?:\\|\\s*([^|}]*?)\\s*)?}}', 'gi');
sortnaamRegex.lastIndex = lastIndex;
var sortnaamMatch = sortnaamRegex.exec( text );
var matchType = null;
var matchIndex = Infinity;
if ( wikiMatch !== null && wikiMatch.index >= lastIndex ) {
matchIndex = wikiMatch.index;
matchType = 'wiki';
}
if ( sortnaamMatch !== null && sortnaamMatch.index >= lastIndex && sortnaamMatch.index < matchIndex ) {
matchIndex = sortnaamMatch.index;
matchType = 'sortnaam';
}
if ( matchType === 'wiki' ) {
var possiblyAmbiguous = true;
var hasDisamTemplate = false;
var end = wikiMatch.index + 4 + wikiMatch[1].length + ( wikiMatch[3] ? 1 + wikiMatch[3].length : 0 );
var afterDescription = '';
var rest = text.substring( end );
var templateRegex = /^(\w*[.,:;?!)}\s]*){{\s*([^|{}]+?)\s*(?:\|[^{]*?)?}}/g;
var templateMatch = templateRegex.exec( rest );
if ( templateMatch !== null ) {
var templateTitle = getCanonicalTitle( templateMatch[2] );
if ( cfg.disamLinkTemplates?.includes( templateTitle ) ) {
end += templateMatch[0].length;
afterDescription = templateMatch[1].replace(/\s$/, '');
hasDisamTemplate = true;
} else if ( cfg.disamLinkIgnoreTemplates?.includes( templateTitle ) ) {
possiblyAmbiguous = false;
}
}
return {
type: 'wiki',
start: wikiMatch.index,
end: end,
possiblyAmbiguous: possiblyAmbiguous,
hasDisamTemplate: hasDisamTemplate,
title: wikiMatch[1],
description: wikiMatch[3] ? wikiMatch[3] : wikiMatch[1],
afterDescription: afterDescription
};
} else if ( matchType === 'sortnaam' ) {
var first = sortnaamMatch[1].trim();
var last = sortnaamMatch[2].trim();
var explicitTarget = sortnaamMatch[3] ? sortnaamMatch[3].trim() : '';
var target = explicitTarget !== '' ? explicitTarget : ( first + ' ' + last );
return {
type: 'sortnaam',
start: sortnaamMatch.index,
end: sortnaamMatch.index + sortnaamMatch[0].length,
possiblyAmbiguous: true,
hasDisamTemplate: false,
title: target,
description: sortnaamMatch[0],
first: first,
last: last,
explicitTarget: explicitTarget,
afterDescription: ''
};
}
return null;
};
/*
* Extract a link to one of a number of destination pages from a string
* ("text") in wiki format, starting from a given index ("lastIndex").
* "Disambiguation needed" templates are included as part of the links.
* text: Page in wiki format
* destinations: Array of page titles to look for
* lastIndex: Index from which the search will start
*/
const extractLinkToPage = function( text, destinations, lastIndex ) {
var link, title;
do {
link = extractLink( text, lastIndex );
if ( link !== null ) {
lastIndex = link.end;
title = getCanonicalTitle( link.title );
}
} while (
link !== null
&& ( !link.possiblyAmbiguous || !destinations?.includes( title ) )
);
return link;
};
/*
* Find the "target" page: either the one we are in or the "main" one found by extracting
* the title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const getTargetPage = function() {
var title = getTitle();
return forceSamePage ? title : removeDisam( title );
};
/*
* Get the page title, with the namespace prefix if any.
*/
const getTitle = function() {
return mw.config.get( 'wgPageName' ).replace(/_/g, ' ')
}
/*
* Extract a "main" title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const removeDisam = function( title ) {
var match = new RegExp( cfg.disamRegExp ).exec( title );
return match ? match[1] : title;
};
/*
* Check whether two page titles are the same
*/
const isSamePage = function( title1, title2 ) {
return getCanonicalTitle( title1 ) === getCanonicalTitle( title2 );
};
/*
* Return the 'canonical title' of a page
*/
const getCanonicalTitle = function( title ) {
try {
title = new mw.Title( title );
// Check that page name matches the current namespace
if ( title.getNamespaceId() !== mw.config.get( 'wgNamespaceNumber' ) ) {
return;
}
const fragment = title.getFragment();
return title.getPrefixedText() + ( fragment ? `#${ fragment }` : '' );
} catch ( ex ) {
// mw.Title seems to be buggy, and some valid titles are rejected
// FIXME: This may cause false negatives
}
};
/*
* Extract the context around a given link in a text string
*/
const extractContext = function( text, link ) {
var contextStart = link.start - cfg.radius;
var contextEnd = link.end + cfg.radius;
var contextPrev = text.substring( contextStart, link.start );
const ellipsis = '…';
if ( contextStart > 0 ) {
contextPrev = ellipsis + contextPrev;
}
var contextNext = text.substring( link.end, contextEnd );
if ( contextEnd < text.length ) {
contextNext = contextNext + ellipsis;
}
return [contextPrev, text.substring( link.start, link.end ), contextNext];
};
/*
* Extract the prefixed page name from a link
*/
const extractPageName = function( link ) {
var pageName = extractPageNameRaw( link );
if ( pageName ) {
return getCanonicalTitle( pageName );
}
return null;
};
/*
* Extract the page name from a link, as is
*/
const extractPageNameRaw = function( link ) {
// Ignore images and interwiki links
if ( link.hasClass( 'image' ) || link.hasClass( 'extiw' ) ) {
return null;
}
var href = link.attr( 'href' );
// Red links in old parser format
if ( link.hasClass( 'new' ) ) {
const oldRedLink = mw.util.getParamValue( 'title', href );
if ( oldRedLink !== null ) {
return oldRedLink;
}
}
// Check that external links are coming from this wiki
if ( href.indexOf( '//' ) === 0 || href.indexOf( 'https://' ) === 0 ) {
try {
const fullUrl = href.indexOf( '//' ) === 0 ? location.protocol + href : href;
let urlData = new URL( fullUrl );
if ( urlData.hostname !== location.hostname ) {
return null;
}
} catch( e ) {
return null;
}
}
var articlePathRegexp = new RegExp(
mw.config.get( 'wgArticlePath' ).replace( '$1', '([^?]*)' )
);
var result = articlePathRegexp.exec( href );
if ( Array.isArray( result ) && result.length > 1 ) {
return decodeURIComponent( result[ 1 ] );
}
return null;
};
/*
* Check whether this is a disambiguation page
*/
const isDisam = function() {
const categories = mw.config.get( 'wgCategories', [] );
return cfg.disamCategories.some( c => categories.includes( c ) );
};
const secondsToHHMMSS = function( totalSeconds ) {
var hhmmss = '';
var hours = Math.floor( totalSeconds / 3600 );
var minutes = Math.floor( totalSeconds % 3600 / 60 );
var seconds = Math.floor( totalSeconds % 3600 % 60 );
if ( hours >= 1 ) {
hhmmss = pad( hours, '0', 2 ) + ':';
}
hhmmss += pad( minutes, '0', 2 ) + ':' + pad( seconds, '0', 2 );
return hhmmss;
};
const pad = function( str, z, width ) {
str = str.toString();
if ( str.length >= width ) {
return str;
} else {
return new Array( width - str.length + 1 ).join( z ) + str;
}
}
/*
* Create a new button
* text: Text that will be displayed on the button
* onClick: Function that will be called when the button is clicked
*/
const createButton = function( text, onClick ) {
var button = $( '<button>', { 'type': 'button' } );
button.text( text );
button.addClass( 'disamassist-button' ).click( onClick );
return button;
};
/*
* Given a page title and an array of possible redirects {from, to} ("canonical titles"), find the page
* at the end of the redirect chain, if there is one. Otherwise, return the page title that was passed
*/
const resolveRedirect = function( pageTitle, possibleRedirects ) {
var appliedRedirect = true;
var visitedPages = {};
var currentPage = getCanonicalTitle( pageTitle );
while ( appliedRedirect ) {
appliedRedirect = false;
for ( var ii = 0; ii < possibleRedirects.length; ii++ ) {
if ( possibleRedirects[ii].from === currentPage ) {
if ( visitedPages[possibleRedirects[ii].to] ) {
// Redirect chain detected
return pageTitle;
}
visitedPages[currentPage] = true;
appliedRedirect = true;
currentPage = possibleRedirects[ii].to;
}
}
}
// No redirect rules applied for an iteration of the outer loop:
// no more redirects. We are done
return currentPage;
};
/*
* Fetch the incoming links to a page. Returns a jQuery promise
* (success - array of titles of pages that contain links to the target page and
* array of "canonical titles" of possible destinations of the backlinks (either
* the target page or redirects to the target page), failure - error description)
* page: Target page
*/
const getBacklinks = function( page ) {
var dfd = new $.Deferred();
api.get( {
'action': 'query',
'list': 'backlinks',
'bltitle': page,
'blredirect': true,
'bllimit': cfg.backlinkLimit,
'blnamespace': cfg.targetNamespaces,
} ).done( function( data ) {
// There might be duplicate entries in some corner cases. We don't care,
// since we are going to check later, anyway
var backlinks = [];
var linkTitles = [getCanonicalTitle( page )];
$.each( data.query.backlinks, function() {
backlinks.push( this.title );
if ( this.redirlinks ) {
linkTitles.push( this.title );
$.each( this.redirlinks, function() {
backlinks.push( this.title );
} );
}
} );
dfd.resolve( backlinks, linkTitles );
} ).fail( function( code, data ) {
dfd.reject( txt.getBacklinksError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download a list of redirects for some pages. Returns a jQuery callback (success -
* array of redirects ({from, to}), failure - error description )
* pageTitles: Array of page titles
*/
const fetchRedirects = function( pageTitles ) {
var dfd = new $.Deferred();
var currentTitles = pageTitles.slice( 0, cfg.queryTitleLimit );
var restTitles = pageTitles.slice( cfg.queryTitleLimit );
api.get( {
action: 'query',
titles: currentTitles,
redirects: true,
} ).done( function( data ) {
var theseRedirects = data.query.redirects ? data.query.redirects : [];
if ( restTitles.length !== 0 ) {
fetchRedirects( restTitles ).done( function( redirects ) {
dfd.resolve( theseRedirects.concat( redirects ) );
} ).fail( function( description ) {
dfd.reject( description );
} );
} else {
dfd.resolve( theseRedirects );
}
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRedirectsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download the list of user rights for the current user. Returns a
* jQuery promise (success - array of right names, error - error description)
*/
const fetchRights = function() {
var dfd = $.Deferred();
api.get( {
action: 'query',
meta: 'userinfo',
uiprop: 'rights',
} ).done( function( data ) {
dfd.resolve( data.query.userinfo.rights );
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRightsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Load the raw page text for a given title. Returns a jQuery promise (success - page
* content, failure - error description)
* pageTitle: Title of the page
*/
const loadPage = function( pageTitle ) {
var dfd = new $.Deferred();
api.get( {
action: 'query',
titles: pageTitle,
prop: 'revisions',
rvprop: 'timestamp|content',
meta: 'tokens',
type: 'csrf',
} ).done( function( data ) {
var pages = data.query.pages;
for ( var key in pages ) {
if ( pages.hasOwnProperty( key ) ) {
break;
}
}
var rawPage = data.query.pages[key];
var page = {};
page.missing = rawPage.missing !== undefined;
if ( rawPage.revisions ) {
page.content = rawPage.revisions[0]['*'];
// Assume anything with # at the start is a redirect
if ( page.content.match( /^#[^\s]*?\s*\[\[/ ) ) {
page.redirect = true;
}
page.baseTimeStamp = rawPage.revisions[0].timestamp;
} else {
page.content = '';
page.baseTimeStamp = null;
}
page.startTimeStamp = rawPage.starttimestamp;
page.editToken = data.query.tokens.csrftoken;
dfd.resolve( page );
} ).fail( function( code, data ) {
dfd.reject( txt.loadPageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
/*
* Register changes to a page, to be saved later. Returns a jQuery promise
* (success - no params, failure - error description). Takes the same parameters
* as savePage
*/
const saveWithCooldown = function() {
var deferred = new $.Deferred();
pendingSaves.push( {args: arguments, dfd: deferred} );
if ( !runningSaves ) {
checkAndSave();
}
return deferred.promise();
};
/*
* Save the first set of changes in the list of pending changes, providing that
* enough time has passed since the last edit
*/
const checkAndSave = function() {
if ( pendingSaves.length === 0 ) {
runningSaves = false;
return;
}
runningSaves = true;
var millisSinceLast = new Date().getTime() - lastEditMillis;
if ( millisSinceLast < cfg.editCooldown * 1000 ) {
setTimeout( checkAndSave, cfg.editCooldown * 1000 - millisSinceLast );
} else {
// The last edit started at least cfg.editCooldown seconds ago
var save = pendingSaves.shift();
savePage.apply( this, save.args ).done( function() {
checkAndSave();
save.dfd.resolve();
} ).fail( function( description ) {
checkAndSave();
save.dfd.reject( description );
} );
// We'll use the time since the last edit started
lastEditMillis = new Date().getTime();
}
};
/*
* Save the changes made to a page. Returns a jQuery promise (success - no params,
* failure - error description)
* pageTitle: Title of the page
* page: Page data
* summary: Summary of the changes made to the page
* minorEdit: Whether to mark the edit as 'minor'
* botEdit: Whether to mark the edit as 'bot'
*/
const savePage = function( pageTitle, page, summary, minorEdit, botEdit ) {
var dfd = new $.Deferred();
api.post( {
action: 'edit',
title: pageTitle,
token: page.editToken,
text: page.content,
basetimestamp: page.baseTimeStamp,
starttimestamp: page.startTimeStamp,
summary: summary,
watchlist: cfg.watch ? cfg.watch : 'nochange',
assertuser: mw.config.get( 'wgUserName' ),
minor: minorEdit,
bot: botEdit,
} ).done( function() {
dfd.resolve();
} ).fail( function( code, data ) {
dfd.reject( txt.savePageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
install();
} )();
// </nowiki>
o9wpwfi8bv7dow28110ftqwcxk4q52g
781172
781169
2026-10-27T15:36:55Z
Rots61
44293
Rots61 moved page [[User:Rots61/Disamassist-a.js]] to [[User:Rots61/DisamAssist-a.js]]
781169
javascript
text/javascript
/*
* DisamAssist: a tool for repairing links from articles to disambiguation pages.
*/
// <nowiki>
// @ts-nocheck
( () => {
var cfg = {};
var txt = {};
var ui;
var links, pageChanges;
var currentPageTitle, currentPageParameters, currentLink;
var possibleBacklinkDestinations;
var titleRegex;
var forceSamePage = false;
var running = false;
var choosing = false;
var canMarkIntentionalLinks = false;
var displayedPages = {};
var editCount = 0;
var editLimit;
var pendingSaves = [];
var pendingEditBox = null;
var pendingEditBoxText;
var lastEditMillis = 0;
var runningSaves = false;
var api;
const dependencies = [
'mediawiki.api',
'mediawiki.confirmCloseWindow',
'mediawiki.Title',
'mediawiki.util',
];
/**
* Add an item in Tools menu
* @param {string} text
* @param {string} id
* @param {(e: MouseEvent) => void} callback
*/
const disamPortletLink = ( text, id, callback ) => {
const portlet = mw.util.addPortletLink(
'p-tb',
cfg.helpPage ? mw.util.getUrl( cfg.helpPage ) : '#',
text,
id
);
const link = portlet?.querySelector( 'a' );
link?.addEventListener( 'click', callback );
addButtonEvents( link );
}
/*
* Entry point. Check whether we are in a disambiguation page. If so, add a link to start the tool
*/
const install = function() {
cfg = window.DisamAssist.cfg;
txt = window.DisamAssist.txt;
if ( mw.config.get( 'wgAction' ) === 'view' && isDisam() ) {
mw.loader.using( dependencies, () => {
api = new mw.Api();
mw.loader.load( 'https://ru.wikipedia.org/w/index.php?title=Участник:Stjn/DisamAssist.css&action=raw&ctype=text/css', 'text/css' );
// This is a " (disambiguation)" page
if ( new RegExp( cfg.disamRegExp ).exec( getTitle() ) ) {
disamPortletLink( txt.startMain, 't-disamAssist-main', startMain );
disamPortletLink( txt.startSame, 't-disamAssist-same', startSame );
} else {
disamPortletLink( txt.start, 't-disamAssist-page', start );
}
} );
}
};
/*
* Start the tool. Display the UI and begin looking for links to fix
*/
const start = function( e ) {
e?.preventDefault();
if ( !running ) {
console.log(Date.now())
running = true;
links = [];
pageChanges = [];
displayedPages = {};
ensureDABExists().then( function( canMark ) {
canMarkIntentionalLinks = canMark;
createUI();
addUnloadConfirm();
markDisamOptions();
checkEditLimit().then( function() {
togglePendingEditBox( false );
doPage();
} );
} );
}
};
/*
* Start DisamAssist. Disambiguate incoming links to the current page, regardless
* of the title.
*/
const startSame = function( e ) {
e.preventDefault();
forceSamePage = true;
start( e );
};
/*
* Start DisamAssist. If the page title ends with " (disambiguation)", disambiguate
* links to the primary topic article. Otherwise, disambiguate links to the current
* page.
*/
const startMain = function( e ) {
e.preventDefault();
forceSamePage = false;
start( e );
};
/*
* Create and show the user interface.
*/
const createUI = function() {
ui = {
display: $( '<div>' ).addClass( 'disamassist-box disamassist-mainbox' ),
finishedMessage: $( '<div>' ).text( txt.noMoreLinks ).hide(),
pageTitleLine: $( '<span>' ).addClass( 'disamassist-pagetitleline' ),
pendingEditCounter: $( '<div>').addClass( 'disamassist-editcounter' ),
context: $( '<pre>' ).addClass( 'disamassist-context' ),
undoButton: createButton( txt.undo, undo ),
omitButton: createButton( txt.omit, omit ),
endButton: createButton( txt.close, saveAndEnd ),
refreshButton: createButton( txt.refresh, refresh ),
titleAsTextButton: createButton( txt.titleAsText, chooseTitleFromPrompt ),
intentionalLinkButton: canMarkIntentionalLinks ? createButton( txt.intentionalLink, chooseIntentionalLink ) : null,
disamNeededButton: cfg.disamNeededText ? createButton( txt.disamNeeded, chooseDisamNeeded ) : null,
removeLinkButton: createButton( txt.removeLink, chooseLinkRemoval )
};
var top = $( '<div>' ).addClass( 'disamassist-top' )
.append( [ui.pageTitleLine, ui.pendingEditCounter] );
var leftButtons = $( '<div>' ).addClass( 'disamassist-leftbuttons' )
.append( [ui.titleAsTextButton, ui.removeLinkButton, ui.intentionalLinkButton, ui.disamNeededButton, ui.omitButton] );
var rightButtons = $( '<div>' ).addClass( 'disamassist-rightbuttons' )
.append( [ui.undoButton, ui.refreshButton, ui.endButton] );
var allButtons = $( '<div>' ).addClass( 'disamassist-allbuttons' )
.append( [leftButtons, rightButtons] );
ui.display.append( [top, ui.finishedMessage, ui.context, allButtons] );
updateEditCounter();
toggleActionButtons( false );
// Insert the UI in the page
$( '#mw-content-text' ).before( ui.display );
ui.display.hide().fadeIn();
};
/**
* Add button role and related keyboard event
* @param {Element} el
*/
const addButtonEvents = ( el ) => {
el?.setAttribute( 'role', 'button' );
el?.addEventListener( 'keydown', function( e ) {
if ( [ 'Enter', 'Space' ].includes( e.code ) ) {
e.preventDefault();
this.click();
}
} );
}
/*
* If there are pending changes, show a confirm dialog before closing
*/
const addUnloadConfirm = function() {
mw.confirmCloseWindow( {
test: () => {
if ( running && checkActualChanges() ) {
return txt.pending;
} else if ( editCount !== 0 ) {
return txt.editInProgress;
}
}
} );
};
/*
* Mark the disambiguation options as such
*/
const markDisamOptions = function() {
var optionPageTitles = [];
var optionMarkers = [];
getDisamOptions().each( function() {
var link = $( this );
var title = extractPageName( link );
const optionMarker = document.createElement( 'a' );
optionMarker.classList.add( 'disamassist-optionmarker' );
optionMarker.href = link.attr( 'href' );
optionMarker.setAttribute( 'data-title', title );
optionMarker.textContent = txt.optionMarker;
optionMarker.addEventListener( 'click', ( e ) => {
e.preventDefault();
chooseReplacement( title );
} );
addButtonEvents( optionMarker );
link.after( optionMarker );
optionMarkers.push( optionMarker );
optionPageTitles.push( title );
} );
// Now check the disambiguation options and display a different message for those that are
// actually the same as the target page where the links go, as choosing those options doesn't really
// accomplish anything (except bypassing redirects, which might be useful in some cases)
var targetPage = getTargetPage();
fetchRedirects( optionPageTitles.concat( targetPage ) ).done( function( redirects ) {
var endTargetPage = resolveRedirect( targetPage, redirects );
for ( var ii = 0; ii < optionPageTitles.length; ii++ ) {
var endOptionTitle = resolveRedirect( optionPageTitles[ii], redirects );
if ( isSamePage( optionPageTitles[ii], targetPage ) ) {
$( optionMarkers[ii] ).text( txt.targetOptionMarker ).addClass( 'disamassist-curroptionmarker');
} else if ( isSamePage( endOptionTitle, endTargetPage ) ) {
$( optionMarkers[ii] ).text( txt.redirectOptionMarker ).addClass( 'disamassist-curroptionmarker');
}
}
} ).fail( error );
};
/*
* Check whether intentional links to disambiguation pages can be explicitly marked
* as such in this wiki. If so, ensure that a "Foo (disambiguation)" page exists.
* Returns a jQuery promise
*/
const ensureDABExists = function() {
var dfd = new $.Deferred();
var title = getTitle();
// That concept doesn't exist in this wiki.
if ( !cfg.intentionalLinkOption ) {
dfd.resolve( false );
// "Foo (disambiguation)" exists: it's the current page.
} else if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
dfd.resolve( true );
} else {
var disamTitle = cfg.disamFormat.replace( '$1', title );
loadPage( disamTitle ).done( function( page ) {
// "Foo (disambiguation)" doesn't exist.
if ( page.missing ) {
// We try to create it
page.content = cfg.redirectToDisam.replace( '$1', title );
var summary = txt.redirectSummary.replace( '$1', title );
savePage( disamTitle, page, summary, false, true ).done( function() {
dfd.resolve( true );
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
// It does exist
} else {
dfd.resolve( true );
}
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
}
return dfd.promise();
};
/*
* Check whether the edit cooldown applies and sets editLimit accordingly.
* Returns a jQuery promise
*/
const checkEditLimit = function() {
var dfd = new $.Deferred();
if ( cfg.editCooldown <= 0 ) {
editLimit = false;
dfd.resolve();
} else {
fetchRights().done( function( rights ) {
editLimit = !rights?.includes( 'bot' );
} ).fail( function( description ) {
error( description );
editLimit = true;
} ).always( function() {
dfd.resolve();
} );
}
return dfd.promise();
};
/*
* Find and ask the user to fix all the incoming links to the disambiguation ("target")
* page from a single "origin" page
*/
const doPage = function() {
if ( pageChanges.length > cfg.historySize ) {
applyChange( pageChanges.shift() );
}
if ( links.length === 0 ) {
var targetPage = getTargetPage();
getBacklinks( targetPage ).done( function( backlinks, pageTitles ) {
var pending = {};
$.each( pendingSaves, function() {
pending[this[0]] = true;
} );
possibleBacklinkDestinations = $.grep( pageTitles, function( t, ii) {
if ( t == targetPage ) {
return true;
}
return removeDisam(t) != targetPage;
} );
titleRegex = new RegExp('\\[\\[([ _]*?(?:' + possibleBacklinkDestinations.join('|').replaceAll('(', '\\(').replaceAll(')','\\)').replaceAll(' ', '[ _]') + ')[ _]*?)(#.*?)?(?:\\|(.*?))?\\]\\]', 'gi');
// Only incoming links from pages we haven't seen yet and we aren't currently
// saving (displayedPages is reset when the tool is closed and opened again,
// while the list of pending changes isn't; if the edit cooldown is disabled,
// it will be empty)
links = $.grep( backlinks, function( el, ii ) {
return !displayedPages[el] && !pending[el];
} );
if ( links.length === 0 ) {
updateContext();
} else {
doPage();
}
} ).fail( error );
} else {
currentPageTitle = links.shift();
displayedPages[currentPageTitle] = true;
toggleActionButtons( false );
loadPage( currentPageTitle ).done( function( data ) {
currentPageParameters = data;
currentLink = null;
doLink();
} ).fail( error );
}
};
/*
* Find and ask the user to fix a single incoming link to the disambiguation ("target")
* page
*/
const doLink = function() {
if (!currentPageParameters.redirect) {
currentLink = extractLinkToPage( currentPageParameters.content,
possibleBacklinkDestinations, currentLink ? currentLink.end : 0 );
if ( currentLink ) {
updateContext();
} else {
doPage();
}
} else {
doPage();
}
};
/*
* Replace the target of a link with a different one
* pageTitle: New link target
* extra: Additional text after the link (optional)
* summary: Change summary (optional)
*/
const chooseReplacement = function( pageTitle, extra, summary ) {
if ( choosing ) {
choosing = false;
if ( !summary ) {
if ( pageTitle ) {
summary = txt.summaryChanged.replace( '$1', pageTitle );
} else {
summary = txt.summaryOmitted;
}
}
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
if ( pageTitle && ( pageTitle !== getTargetPage() || extra ) ) {
if ( currentPageParameters.redirect ) {
currentLink.description = pageTitle;
}
currentPageParameters.content =
replaceLink( currentPageParameters.content, pageTitle, currentLink, extra || '' );
}
doLink();
}
};
/*
* Replace the link with an explicit link to the disambiguation page
*/
const chooseIntentionalLink = function() {
const targetPage = getTargetPage();
let title = cfg.disamFormat.replace( '$1', targetPage );
if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
title = targetPage;
}
chooseReplacement( title, '', txt.summaryIntentional );
};
/*
* Prompt for an alternative link target and use it as a replacement
*/
const chooseTitleFromPrompt = function() {
var title = prompt( txt.titleAsTextPrompt );
if ( title !== null ) {
chooseReplacement( title );
}
};
/*
* Remove the current link, leaving the text unchanged
*/
const chooseLinkRemoval = function() {
if ( choosing ) {
var summary = txt.summaryRemoved;
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
currentPageParameters.content = removeLink( currentPageParameters.content, currentLink );
doLink();
}
};
/*
* Add a "disambiguation needed" template after the link
*/
const chooseDisamNeeded = function() {
chooseReplacement( currentLink.title, cfg.disamNeededText, txt.summaryHelpNeeded );
};
/*
* Undo the last change
*/
const undo = function() {
if ( pageChanges.length !== 0 ) {
var lastPage = pageChanges[pageChanges.length - 1];
if ( currentPageTitle !== lastPage.title ) {
links.unshift( currentPageTitle );
currentPageTitle = lastPage.title;
}
currentPageParameters = lastPage.page;
currentPageParameters.content = lastPage.contentBefore.pop();
currentLink = lastPage.links.pop();
lastPage.summary.pop();
if ( lastPage.contentBefore.length === 0 ) {
pageChanges.pop();
}
updateContext();
}
};
/*
* Omit the current link without making a change
*/
const omit = function() {
chooseReplacement( null );
};
/*
* Save all the pending changes and restart the tool.
*/
const refresh = function() {
saveAndEnd();
start();
};
/*
* Enable or disable the buttons that can perform actions on a page or change the current link.
* enabled: Whether to enable or disable the buttons
*/
const toggleActionButtons = function( enabled ) {
var affectedButtons = [ui.omitButton, ui.titleAsTextButton, ui.removeLinkButton,
ui.intentionalLinkButton, ui.disamNeededButton, ui.undoButton];
$.each( affectedButtons, function( ii, button ) {
button?.prop( 'disabled', !enabled );
} );
};
/*
* Show or hide the 'no more links' message
* show: Whether to show or hide the message
*/
const toggleFinishedMessage = function( show ) {
toggleActionButtons( !show );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.finishedMessage.toggle( show );
ui.pageTitleLine.toggle( !show );
ui.context.toggle( !show );
if ( show ) console.log(Date.now())
};
const togglePendingEditBox = function( show ) {
if ( pendingEditBox === null ) {
pendingEditBox = $( '<div>' ).addClass( 'disamassist-box disamassist-pendingeditbox' );
pendingEditBoxText = $( '<div>' );
pendingEditBox.append( pendingEditBoxText ).hide();
if ( editLimit ) {
pendingEditBox.append( $( '<div>' ).text( txt.pendingEditBoxLimited )
.addClass( 'disamassist-subtitle' ) );
}
$( '#mw-content-text' ).before( pendingEditBox );
updateEditCounter();
}
if ( show ) {
pendingEditBox.fadeIn();
} else {
pendingEditBox.fadeOut();
}
};
const notifyCompletion = function() {
var oldTitle = document.title;
document.title = txt.notifyCharacter + document.title;
$( document.body ).one( 'mousemove', function() {
document.title = oldTitle;
} );
};
/*
* Update the displayed information to match the current link
* or lack thereof
*/
const updateContext = function() {
updateEditCounter();
if ( !currentLink ) {
toggleFinishedMessage( true );
} else {
ui.pageTitleLine.html( txt.pageTitleLine.replace( '$1',
mw.util.getUrl( currentPageTitle, {redirect: 'no'} ) ).replace( '$2', mw.html.escape( currentPageTitle ) ) );
var context = extractContext( currentPageParameters.content, currentLink );
ui.context.empty()
.append( $( '<span>' ).text( context[0] ) )
.append( $( '<span>' ).text( context[1] ).addClass( 'disamassist-inclink' ) )
.append( $( '<span>' ).text( context[2] ) );
toggleFinishedMessage( false );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.removeLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.intentionalLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.disamNeededButton?.prop( 'disabled', currentPageParameters.redirect || currentLink.hasDisamTemplate );
choosing = true;
}
};
/*
* Update the count of pending changes
*/
const updateEditCounter = function() {
if ( ui.pendingEditCounter ) {
ui.pendingEditCounter.text( txt.pendingEditCounter.replace( '$1', editCount )
.replace( '$2', countActuallyChangedFullyCheckedPages() ) );
}
if ( pendingEditBox ) {
if ( editCount === 0 && !running ) {
togglePendingEditBox( false );
notifyCompletion();
}
var textContent = editCount;
if ( editLimit ) {
textContent = txt.pendingEditBoxTimeEstimation.replace( '$1', editCount )
.replace( '$2', secondsToHHMMSS( cfg.editCooldown * editCount ) );
}
pendingEditBoxText.text( txt.pendingEditBox.replace( '$1', textContent ) );
}
};
/*
* Apply the changes made to an "origin" page
* pageChange: Change that will be saved
*/
const applyChange = function( pageChange ) {
if ( pageChange.page.content !== pageChange.contentBefore[0] ) {
editCount++;
// Filter duplicates and join
var changeSummaries = pageChange.summary
.filter( ( val, i, arr ) => val && arr.indexOf( val ) === i )
.join( txt.summarySeparator );
var summary = txt.summary.replace( '$1', getTargetPage() ).replace( '$2', changeSummaries );
var save = editLimit ? saveWithCooldown : savePage;
save( pageChange.title, pageChange.page, summary, true, true ).always( function() {
if ( editCount > 0 ) {
editCount--;
}
updateEditCounter();
} ).fail( error );
updateEditCounter();
}
};
/*
* Save all the pending changes
*/
const applyAllChanges = function() {
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
applyChange( pageChanges[ii] );
}
pageChanges = [];
};
/*
* Record a new pending change
* pageTitle: Title of the page
* page: Content of the page
* oldContent: Content of the page before the change
* link: Link that has been changed
* summary: Change summary
*/
const addChange = function( pageTitle, page, oldContent, link, summary ) {
if ( ( pageChanges.length === 0 ) || ( pageChanges[pageChanges.length - 1].title !== pageTitle ) ) {
pageChanges.push( {
title: pageTitle,
page: page,
contentBefore: [],
links: [],
summary: []
} );
}
var lastPageChange = pageChanges[pageChanges.length - 1];
lastPageChange.contentBefore.push( oldContent );
lastPageChange.links.push( link );
lastPageChange.summary.push( summary );
};
/*
* Check whether actual changes are stored in the history array
*/
const checkActualChanges = function() {
return countActualChanges() !== 0;
};
/*
* Return the number of entries in the history array that represent actual changes
*/
const countActualChanges = function() {
var changeCount = 0;
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
if ( pageChanges[ii].page.content !== pageChanges[ii].contentBefore[0] ) {
changeCount++;
}
}
return changeCount;
};
/*
* Return the number of changed pages in the history array, ignoring the last entry
* if we aren't done with that page yet
*/
const countActuallyChangedFullyCheckedPages = function() {
var changeCount = countActualChanges();
if ( pageChanges.length !== 0 ) {
var lastChange = pageChanges[pageChanges.length - 1];
if ( lastChange.title === currentPageTitle && currentLink !== null
&& lastChange.page.content !== lastChange.contentBefore[0] ) {
changeCount--;
}
}
return changeCount;
};
/*
* Find the links to disambiguation options in a disambiguation page
*/
const getDisamOptions = function() {
return $( '#mw-content-text a' ).filter( function() {
return !!extractPageName( $( this ) );
} );
};
/*
* Save all the pending changes and close the tool
*/
const saveAndEnd = function() {
applyAllChanges();
end();
};
/*
* Close the tool
*/
const end = function() {
var currentToolUI = ui.display;
choosing = false;
running = false;
$( '.disamassist-optionmarker' ).remove();
currentToolUI.fadeOut( { complete: function() {
currentToolUI.remove();
if ( editCount !== 0 ) {
togglePendingEditBox( true );
}
} } );
};
/*
* Display an error message
*/
const error = function( errorDescription ) {
var errorBox = $( '<div>' ).addClass( 'disamassist-box disamassist-errorbox' );
errorBox.text( txt.error.replace( '$1', errorDescription ) );
errorBox.append( createButton( txt.dismissError, function() {
errorBox.fadeOut();
} ).addClass( 'disamassist-errorbutton' ) );
var uiIsInPlace = ui && $.contains( document.documentElement, ui.display[0] );
var nextElement = uiIsInPlace ? ui.display : $( '#mw-content-text' );
nextElement.before( errorBox );
errorBox.hide().fadeIn();
}
/*
* Change a link so that it points to the title
* text: The wikitext of the whole page
* title: The new destination of the link
* link: The link that will be modified
* extra: Text that will be added after the link (optional)
*/
const replaceLink = function( text, title, link, extra ) {
var newContent;
if ( link.type === 'sortnaam' ) {
newContent = '{{sortnaam|' + link.first + '|' + link.last + '|' + title + '}}';
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + ( extra || '' ) + linkEnd;
}
if ( isSamePage( title, link.description ) ) {
// [[B|A]] should be replaced with [[A]] rather than [[A|A]]
newContent = link.description;
} else {
newContent = title + '|' + link.description;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + '[[' + newContent + ']]' + link.afterDescription + ( extra || '' ) + linkEnd;
};
/*
* Remove a link from the text
* text: The wikitext of the whole page
* link: The link that will be removed
*/
const removeLink = function( text, link ) {
if ( link.type === 'sortnaam' ) {
var newContent = '{{sortnaam|' + link.first + '|' + link.last + '}}';
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + linkEnd;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + link.description + link.afterDescription + linkEnd;
};
/*
* Extract a link from a string in wiki format,
* starting from a given index. Return a link if one can be found,
* otherwise return null. The "link" includes "disambiguation needed"
* templates inmediately following the link proper
* text: Text from which the link will be extracted
* lastIndex: Index from which the search will start
*/
const extractLink = function( text, lastIndex ) {
// Zoek eerst naar gewone wikitlinks
titleRegex.lastIndex = lastIndex;
var wikiMatch = titleRegex.exec( text );
// Zoek naar sortnaam-templates (case-insensitief)
var sortnaamRegex = new RegExp('{{\\s*sortnaam\\s*\\|\\s*([^|]+?)\\s*\\|\\s*([^|]+?)\\s*(?:\\|\\s*([^|}]*?)\\s*)?}}', 'gi');
sortnaamRegex.lastIndex = lastIndex;
var sortnaamMatch = sortnaamRegex.exec( text );
var matchType = null;
var matchIndex = Infinity;
if ( wikiMatch !== null && wikiMatch.index >= lastIndex ) {
matchIndex = wikiMatch.index;
matchType = 'wiki';
}
if ( sortnaamMatch !== null && sortnaamMatch.index >= lastIndex && sortnaamMatch.index < matchIndex ) {
matchIndex = sortnaamMatch.index;
matchType = 'sortnaam';
}
if ( matchType === 'wiki' ) {
var possiblyAmbiguous = true;
var hasDisamTemplate = false;
var end = wikiMatch.index + 4 + wikiMatch[1].length + ( wikiMatch[3] ? 1 + wikiMatch[3].length : 0 );
var afterDescription = '';
var rest = text.substring( end );
var templateRegex = /^(\w*[.,:;?!)}\s]*){{\s*([^|{}]+?)\s*(?:\|[^{]*?)?}}/g;
var templateMatch = templateRegex.exec( rest );
if ( templateMatch !== null ) {
var templateTitle = getCanonicalTitle( templateMatch[2] );
if ( cfg.disamLinkTemplates?.includes( templateTitle ) ) {
end += templateMatch[0].length;
afterDescription = templateMatch[1].replace(/\s$/, '');
hasDisamTemplate = true;
} else if ( cfg.disamLinkIgnoreTemplates?.includes( templateTitle ) ) {
possiblyAmbiguous = false;
}
}
return {
type: 'wiki',
start: wikiMatch.index,
end: end,
possiblyAmbiguous: possiblyAmbiguous,
hasDisamTemplate: hasDisamTemplate,
title: wikiMatch[1],
description: wikiMatch[3] ? wikiMatch[3] : wikiMatch[1],
afterDescription: afterDescription
};
} else if ( matchType === 'sortnaam' ) {
var first = sortnaamMatch[1].trim();
var last = sortnaamMatch[2].trim();
var explicitTarget = sortnaamMatch[3] ? sortnaamMatch[3].trim() : '';
var target = explicitTarget !== '' ? explicitTarget : ( first + ' ' + last );
return {
type: 'sortnaam',
start: sortnaamMatch.index,
end: sortnaamMatch.index + sortnaamMatch[0].length,
possiblyAmbiguous: true,
hasDisamTemplate: false,
title: target,
description: sortnaamMatch[0],
first: first,
last: last,
explicitTarget: explicitTarget,
afterDescription: ''
};
}
return null;
};
/*
* Extract a link to one of a number of destination pages from a string
* ("text") in wiki format, starting from a given index ("lastIndex").
* "Disambiguation needed" templates are included as part of the links.
* text: Page in wiki format
* destinations: Array of page titles to look for
* lastIndex: Index from which the search will start
*/
const extractLinkToPage = function( text, destinations, lastIndex ) {
var link, title;
do {
link = extractLink( text, lastIndex );
if ( link !== null ) {
lastIndex = link.end;
title = getCanonicalTitle( link.title );
}
} while (
link !== null
&& ( !link.possiblyAmbiguous || !destinations?.includes( title ) )
);
return link;
};
/*
* Find the "target" page: either the one we are in or the "main" one found by extracting
* the title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const getTargetPage = function() {
var title = getTitle();
return forceSamePage ? title : removeDisam( title );
};
/*
* Get the page title, with the namespace prefix if any.
*/
const getTitle = function() {
return mw.config.get( 'wgPageName' ).replace(/_/g, ' ')
}
/*
* Extract a "main" title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const removeDisam = function( title ) {
var match = new RegExp( cfg.disamRegExp ).exec( title );
return match ? match[1] : title;
};
/*
* Check whether two page titles are the same
*/
const isSamePage = function( title1, title2 ) {
return getCanonicalTitle( title1 ) === getCanonicalTitle( title2 );
};
/*
* Return the 'canonical title' of a page
*/
const getCanonicalTitle = function( title ) {
try {
title = new mw.Title( title );
// Check that page name matches the current namespace
if ( title.getNamespaceId() !== mw.config.get( 'wgNamespaceNumber' ) ) {
return;
}
const fragment = title.getFragment();
return title.getPrefixedText() + ( fragment ? `#${ fragment }` : '' );
} catch ( ex ) {
// mw.Title seems to be buggy, and some valid titles are rejected
// FIXME: This may cause false negatives
}
};
/*
* Extract the context around a given link in a text string
*/
const extractContext = function( text, link ) {
var contextStart = link.start - cfg.radius;
var contextEnd = link.end + cfg.radius;
var contextPrev = text.substring( contextStart, link.start );
const ellipsis = '…';
if ( contextStart > 0 ) {
contextPrev = ellipsis + contextPrev;
}
var contextNext = text.substring( link.end, contextEnd );
if ( contextEnd < text.length ) {
contextNext = contextNext + ellipsis;
}
return [contextPrev, text.substring( link.start, link.end ), contextNext];
};
/*
* Extract the prefixed page name from a link
*/
const extractPageName = function( link ) {
var pageName = extractPageNameRaw( link );
if ( pageName ) {
return getCanonicalTitle( pageName );
}
return null;
};
/*
* Extract the page name from a link, as is
*/
const extractPageNameRaw = function( link ) {
// Ignore images and interwiki links
if ( link.hasClass( 'image' ) || link.hasClass( 'extiw' ) ) {
return null;
}
var href = link.attr( 'href' );
// Red links in old parser format
if ( link.hasClass( 'new' ) ) {
const oldRedLink = mw.util.getParamValue( 'title', href );
if ( oldRedLink !== null ) {
return oldRedLink;
}
}
// Check that external links are coming from this wiki
if ( href.indexOf( '//' ) === 0 || href.indexOf( 'https://' ) === 0 ) {
try {
const fullUrl = href.indexOf( '//' ) === 0 ? location.protocol + href : href;
let urlData = new URL( fullUrl );
if ( urlData.hostname !== location.hostname ) {
return null;
}
} catch( e ) {
return null;
}
}
var articlePathRegexp = new RegExp(
mw.config.get( 'wgArticlePath' ).replace( '$1', '([^?]*)' )
);
var result = articlePathRegexp.exec( href );
if ( Array.isArray( result ) && result.length > 1 ) {
return decodeURIComponent( result[ 1 ] );
}
return null;
};
/*
* Check whether this is a disambiguation page
*/
const isDisam = function() {
const categories = mw.config.get( 'wgCategories', [] );
return cfg.disamCategories.some( c => categories.includes( c ) );
};
const secondsToHHMMSS = function( totalSeconds ) {
var hhmmss = '';
var hours = Math.floor( totalSeconds / 3600 );
var minutes = Math.floor( totalSeconds % 3600 / 60 );
var seconds = Math.floor( totalSeconds % 3600 % 60 );
if ( hours >= 1 ) {
hhmmss = pad( hours, '0', 2 ) + ':';
}
hhmmss += pad( minutes, '0', 2 ) + ':' + pad( seconds, '0', 2 );
return hhmmss;
};
const pad = function( str, z, width ) {
str = str.toString();
if ( str.length >= width ) {
return str;
} else {
return new Array( width - str.length + 1 ).join( z ) + str;
}
}
/*
* Create a new button
* text: Text that will be displayed on the button
* onClick: Function that will be called when the button is clicked
*/
const createButton = function( text, onClick ) {
var button = $( '<button>', { 'type': 'button' } );
button.text( text );
button.addClass( 'disamassist-button' ).click( onClick );
return button;
};
/*
* Given a page title and an array of possible redirects {from, to} ("canonical titles"), find the page
* at the end of the redirect chain, if there is one. Otherwise, return the page title that was passed
*/
const resolveRedirect = function( pageTitle, possibleRedirects ) {
var appliedRedirect = true;
var visitedPages = {};
var currentPage = getCanonicalTitle( pageTitle );
while ( appliedRedirect ) {
appliedRedirect = false;
for ( var ii = 0; ii < possibleRedirects.length; ii++ ) {
if ( possibleRedirects[ii].from === currentPage ) {
if ( visitedPages[possibleRedirects[ii].to] ) {
// Redirect chain detected
return pageTitle;
}
visitedPages[currentPage] = true;
appliedRedirect = true;
currentPage = possibleRedirects[ii].to;
}
}
}
// No redirect rules applied for an iteration of the outer loop:
// no more redirects. We are done
return currentPage;
};
/*
* Fetch the incoming links to a page. Returns a jQuery promise
* (success - array of titles of pages that contain links to the target page and
* array of "canonical titles" of possible destinations of the backlinks (either
* the target page or redirects to the target page), failure - error description)
* page: Target page
*/
const getBacklinks = function( page ) {
var dfd = new $.Deferred();
api.get( {
'action': 'query',
'list': 'backlinks',
'bltitle': page,
'blredirect': true,
'bllimit': cfg.backlinkLimit,
'blnamespace': cfg.targetNamespaces,
} ).done( function( data ) {
// There might be duplicate entries in some corner cases. We don't care,
// since we are going to check later, anyway
var backlinks = [];
var linkTitles = [getCanonicalTitle( page )];
$.each( data.query.backlinks, function() {
backlinks.push( this.title );
if ( this.redirlinks ) {
linkTitles.push( this.title );
$.each( this.redirlinks, function() {
backlinks.push( this.title );
} );
}
} );
dfd.resolve( backlinks, linkTitles );
} ).fail( function( code, data ) {
dfd.reject( txt.getBacklinksError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download a list of redirects for some pages. Returns a jQuery callback (success -
* array of redirects ({from, to}), failure - error description )
* pageTitles: Array of page titles
*/
const fetchRedirects = function( pageTitles ) {
var dfd = new $.Deferred();
var currentTitles = pageTitles.slice( 0, cfg.queryTitleLimit );
var restTitles = pageTitles.slice( cfg.queryTitleLimit );
api.get( {
action: 'query',
titles: currentTitles,
redirects: true,
} ).done( function( data ) {
var theseRedirects = data.query.redirects ? data.query.redirects : [];
if ( restTitles.length !== 0 ) {
fetchRedirects( restTitles ).done( function( redirects ) {
dfd.resolve( theseRedirects.concat( redirects ) );
} ).fail( function( description ) {
dfd.reject( description );
} );
} else {
dfd.resolve( theseRedirects );
}
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRedirectsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download the list of user rights for the current user. Returns a
* jQuery promise (success - array of right names, error - error description)
*/
const fetchRights = function() {
var dfd = $.Deferred();
api.get( {
action: 'query',
meta: 'userinfo',
uiprop: 'rights',
} ).done( function( data ) {
dfd.resolve( data.query.userinfo.rights );
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRightsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Load the raw page text for a given title. Returns a jQuery promise (success - page
* content, failure - error description)
* pageTitle: Title of the page
*/
const loadPage = function( pageTitle ) {
var dfd = new $.Deferred();
api.get( {
action: 'query',
titles: pageTitle,
prop: 'revisions',
rvprop: 'timestamp|content',
meta: 'tokens',
type: 'csrf',
} ).done( function( data ) {
var pages = data.query.pages;
for ( var key in pages ) {
if ( pages.hasOwnProperty( key ) ) {
break;
}
}
var rawPage = data.query.pages[key];
var page = {};
page.missing = rawPage.missing !== undefined;
if ( rawPage.revisions ) {
page.content = rawPage.revisions[0]['*'];
// Assume anything with # at the start is a redirect
if ( page.content.match( /^#[^\s]*?\s*\[\[/ ) ) {
page.redirect = true;
}
page.baseTimeStamp = rawPage.revisions[0].timestamp;
} else {
page.content = '';
page.baseTimeStamp = null;
}
page.startTimeStamp = rawPage.starttimestamp;
page.editToken = data.query.tokens.csrftoken;
dfd.resolve( page );
} ).fail( function( code, data ) {
dfd.reject( txt.loadPageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
/*
* Register changes to a page, to be saved later. Returns a jQuery promise
* (success - no params, failure - error description). Takes the same parameters
* as savePage
*/
const saveWithCooldown = function() {
var deferred = new $.Deferred();
pendingSaves.push( {args: arguments, dfd: deferred} );
if ( !runningSaves ) {
checkAndSave();
}
return deferred.promise();
};
/*
* Save the first set of changes in the list of pending changes, providing that
* enough time has passed since the last edit
*/
const checkAndSave = function() {
if ( pendingSaves.length === 0 ) {
runningSaves = false;
return;
}
runningSaves = true;
var millisSinceLast = new Date().getTime() - lastEditMillis;
if ( millisSinceLast < cfg.editCooldown * 1000 ) {
setTimeout( checkAndSave, cfg.editCooldown * 1000 - millisSinceLast );
} else {
// The last edit started at least cfg.editCooldown seconds ago
var save = pendingSaves.shift();
savePage.apply( this, save.args ).done( function() {
checkAndSave();
save.dfd.resolve();
} ).fail( function( description ) {
checkAndSave();
save.dfd.reject( description );
} );
// We'll use the time since the last edit started
lastEditMillis = new Date().getTime();
}
};
/*
* Save the changes made to a page. Returns a jQuery promise (success - no params,
* failure - error description)
* pageTitle: Title of the page
* page: Page data
* summary: Summary of the changes made to the page
* minorEdit: Whether to mark the edit as 'minor'
* botEdit: Whether to mark the edit as 'bot'
*/
const savePage = function( pageTitle, page, summary, minorEdit, botEdit ) {
var dfd = new $.Deferred();
api.post( {
action: 'edit',
title: pageTitle,
token: page.editToken,
text: page.content,
basetimestamp: page.baseTimeStamp,
starttimestamp: page.startTimeStamp,
summary: summary,
watchlist: cfg.watch ? cfg.watch : 'nochange',
assertuser: mw.config.get( 'wgUserName' ),
minor: minorEdit,
bot: botEdit,
} ).done( function() {
dfd.resolve();
} ).fail( function( code, data ) {
dfd.reject( txt.savePageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
install();
} )();
// </nowiki>
o9wpwfi8bv7dow28110ftqwcxk4q52g
781186
781172
2026-10-27T15:51:27Z
Rots61
44293
781186
javascript
text/javascript
/*
* DisamAssist: a tool for repairing links from articles to disambiguation pages.
*/
// <nowiki>
// @ts-nocheck
( () => {
var cfg = {};
var txt = {};
var ui;
var links, pageChanges;
var currentPageTitle, currentPageParameters, currentLink;
var possibleBacklinkDestinations;
var titleRegex;
var forceSamePage = false;
var running = false;
var choosing = false;
var canMarkIntentionalLinks = false;
var displayedPages = {};
var editCount = 0;
var editLimit;
var pendingSaves = [];
var pendingEditBox = null;
var pendingEditBoxText;
var lastEditMillis = 0;
var runningSaves = false;
var api;
const dependencies = [
'mediawiki.api',
'mediawiki.confirmCloseWindow',
'mediawiki.Title',
'mediawiki.util',
];
/**
* Add an item in Tools menu
* @param {string} text
* @param {string} id
* @param {(e: MouseEvent) => void} callback
*/
const disamPortletLink = ( text, id, callback ) => {
const portlet = mw.util.addPortletLink(
'p-tb',
cfg.helpPage ? mw.util.getUrl( cfg.helpPage ) : '#',
text,
id
);
const link = portlet?.querySelector( 'a' );
link?.addEventListener( 'click', callback );
addButtonEvents( link );
}
/*
* Entry point. Check whether we are in a disambiguation page. If so, add a link to start the tool
*/
const install = function() {
cfg = window.DisamAssist.cfg;
txt = window.DisamAssist.txt;
if ( mw.config.get( 'wgAction' ) === 'view' && isDisam() ) {
mw.loader.using( dependencies, () => {
api = new mw.Api();
mw.loader.load( 'https://ru.wikipedia.org/w/index.php?title=Участник:Stjn/DisamAssist.css&action=raw&ctype=text/css', 'text/css' );
// This is a " (disambiguation)" page
if ( new RegExp( cfg.disamRegExp ).exec( getTitle() ) ) {
disamPortletLink( txt.startMain, 't-disamAssist-main', startMain );
disamPortletLink( txt.startSame, 't-disamAssist-same', startSame );
} else {
disamPortletLink( txt.start, 't-disamAssist-page', start );
}
} );
}
};
/*
* Start the tool. Display the UI and begin looking for links to fix
*/
const start = function( e ) {
e?.preventDefault();
if ( !running ) {
console.log(Date.now())
running = true;
links = [];
pageChanges = [];
displayedPages = {};
ensureDABExists().then( function( canMark ) {
canMarkIntentionalLinks = canMark;
createUI();
addUnloadConfirm();
markDisamOptions();
checkEditLimit().then( function() {
togglePendingEditBox( false );
doPage();
} );
} );
}
};
/*
* Start DisamAssist. Disambiguate incoming links to the current page, regardless
* of the title.
*/
const startSame = function( e ) {
e.preventDefault();
forceSamePage = true;
start( e );
};
/*
* Start DisamAssist. If the page title ends with " (disambiguation)", disambiguate
* links to the primary topic article. Otherwise, disambiguate links to the current
* page.
*/
const startMain = function( e ) {
e.preventDefault();
forceSamePage = false;
start( e );
};
/*
* Create and show the user interface.
*/
const createUI = function() {
ui = {
display: $( '<div>' ).addClass( 'disamassist-box disamassist-mainbox' ),
finishedMessage: $( '<div>' ).text( txt.noMoreLinks ).hide(),
pageTitleLine: $( '<span>' ).addClass( 'disamassist-pagetitleline' ),
pendingEditCounter: $( '<div>').addClass( 'disamassist-editcounter' ),
context: $( '<pre>' ).addClass( 'disamassist-context' ),
undoButton: createButton( txt.undo, undo ),
omitButton: createButton( txt.omit, omit ),
endButton: createButton( txt.close, saveAndEnd ),
refreshButton: createButton( txt.refresh, refresh ),
titleAsTextButton: createButton( txt.titleAsText, chooseTitleFromPrompt ),
intentionalLinkButton: canMarkIntentionalLinks ? createButton( txt.intentionalLink, chooseIntentionalLink ) : null,
disamNeededButton: cfg.disamNeededText ? createButton( txt.disamNeeded, chooseDisamNeeded ) : null,
removeLinkButton: createButton( txt.removeLink, chooseLinkRemoval )
};
var top = $( '<div>' ).addClass( 'disamassist-top' )
.append( [ui.pageTitleLine, ui.pendingEditCounter] );
var leftButtons = $( '<div>' ).addClass( 'disamassist-leftbuttons' )
.append( [ui.titleAsTextButton, ui.removeLinkButton, ui.intentionalLinkButton, ui.disamNeededButton, ui.omitButton] );
var rightButtons = $( '<div>' ).addClass( 'disamassist-rightbuttons' )
.append( [ui.undoButton, ui.refreshButton, ui.endButton] );
var allButtons = $( '<div>' ).addClass( 'disamassist-allbuttons' )
.append( [leftButtons, rightButtons] );
ui.display.append( [top, ui.finishedMessage, ui.context, allButtons] );
updateEditCounter();
toggleActionButtons( false );
// Insert the UI in the page
$( '#mw-content-text' ).before( ui.display );
ui.display.hide().fadeIn();
};
/**
* Add button role and related keyboard event
* @param {Element} el
*/
const addButtonEvents = ( el ) => {
el?.setAttribute( 'role', 'button' );
el?.addEventListener( 'keydown', function( e ) {
if ( [ 'Enter', 'Space' ].includes( e.code ) ) {
e.preventDefault();
this.click();
}
} );
}
/*
* If there are pending changes, show a confirm dialog before closing
*/
const addUnloadConfirm = function() {
mw.confirmCloseWindow( {
test: () => {
if ( running && checkActualChanges() ) {
return txt.pending;
} else if ( editCount !== 0 ) {
return txt.editInProgress;
}
}
} );
};
/*
* Mark the disambiguation options as such
*/
const markDisamOptions = function() {
var optionPageTitles = [];
var optionMarkers = [];
getDisamOptions().each( function() {
var link = $( this );
var title = extractPageName( link );
const optionMarker = document.createElement( 'a' );
optionMarker.classList.add( 'disamassist-optionmarker' );
optionMarker.href = link.attr( 'href' );
optionMarker.setAttribute( 'data-title', title );
optionMarker.textContent = txt.optionMarker;
optionMarker.addEventListener( 'click', ( e ) => {
e.preventDefault();
chooseReplacement( title );
} );
addButtonEvents( optionMarker );
link.after( optionMarker );
optionMarkers.push( optionMarker );
optionPageTitles.push( title );
} );
// Now check the disambiguation options and display a different message for those that are
// actually the same as the target page where the links go, as choosing those options doesn't really
// accomplish anything (except bypassing redirects, which might be useful in some cases)
var targetPage = getTargetPage();
fetchRedirects( optionPageTitles.concat( targetPage ) ).done( function( redirects ) {
var endTargetPage = resolveRedirect( targetPage, redirects );
for ( var ii = 0; ii < optionPageTitles.length; ii++ ) {
var endOptionTitle = resolveRedirect( optionPageTitles[ii], redirects );
if ( isSamePage( optionPageTitles[ii], targetPage ) ) {
$( optionMarkers[ii] ).text( txt.targetOptionMarker ).addClass( 'disamassist-curroptionmarker');
} else if ( isSamePage( endOptionTitle, endTargetPage ) ) {
$( optionMarkers[ii] ).text( txt.redirectOptionMarker ).addClass( 'disamassist-curroptionmarker');
}
}
} ).fail( error );
};
/*
* Check whether intentional links to disambiguation pages can be explicitly marked
* as such in this wiki. If so, ensure that a "Foo (disambiguation)" page exists.
* Returns a jQuery promise
*/
const ensureDABExists = function() {
var dfd = new $.Deferred();
var title = getTitle();
// That concept doesn't exist in this wiki.
if ( !cfg.intentionalLinkOption ) {
dfd.resolve( false );
// "Foo (disambiguation)" exists: it's the current page.
} else if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
dfd.resolve( true );
} else {
var disamTitle = cfg.disamFormat.replace( '$1', title );
loadPage( disamTitle ).done( function( page ) {
// "Foo (disambiguation)" doesn't exist.
if ( page.missing ) {
// We try to create it
page.content = cfg.redirectToDisam.replace( '$1', title );
var summary = txt.redirectSummary.replace( '$1', title );
savePage( disamTitle, page, summary, false, true ).done( function() {
dfd.resolve( true );
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
// It does exist
} else {
dfd.resolve( true );
}
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
}
return dfd.promise();
};
/*
* Check whether the edit cooldown applies and sets editLimit accordingly.
* Returns a jQuery promise
*/
const checkEditLimit = function() {
var dfd = new $.Deferred();
if ( cfg.editCooldown <= 0 ) {
editLimit = false;
dfd.resolve();
} else {
fetchRights().done( function( rights ) {
editLimit = !rights?.includes( 'bot' );
} ).fail( function( description ) {
error( description );
editLimit = true;
} ).always( function() {
dfd.resolve();
} );
}
return dfd.promise();
};
/*
* Find and ask the user to fix all the incoming links to the disambiguation ("target")
* page from a single "origin" page
*/
const doPage = function() {
if ( pageChanges.length > cfg.historySize ) {
applyChange( pageChanges.shift() );
}
if ( links.length === 0 ) {
var targetPage = getTargetPage();
getBacklinks( targetPage ).done( function( backlinks, pageTitles ) {
var pending = {};
$.each( pendingSaves, function() {
pending[this[0]] = true;
} );
possibleBacklinkDestinations = $.grep( pageTitles, function( t, ii) {
if ( t == targetPage ) {
return true;
}
return removeDisam(t) != targetPage;
} );
titleRegex = new RegExp('\\[\\[([ _]*?(?:' + possibleBacklinkDestinations.join('|').replaceAll('(', '\\(').replaceAll(')','\\)').replaceAll(' ', '[ _]') + ')[ _]*?)(#.*?)?(?:\\|(.*?))?\\]\\]', 'gi');
// Only incoming links from pages we haven't seen yet and we aren't currently
// saving (displayedPages is reset when the tool is closed and opened again,
// while the list of pending changes isn't; if the edit cooldown is disabled,
// it will be empty)
links = $.grep( backlinks, function( el, ii ) {
return !displayedPages[el] && !pending[el];
} );
if ( links.length === 0 ) {
updateContext();
} else {
doPage();
}
} ).fail( error );
} else {
currentPageTitle = links.shift();
displayedPages[currentPageTitle] = true;
toggleActionButtons( false );
loadPage( currentPageTitle ).done( function( data ) {
currentPageParameters = data;
currentLink = null;
doLink();
} ).fail( error );
}
};
/*
* Find and ask the user to fix a single incoming link to the disambiguation ("target")
* page
*/
const doLink = function() {
if (!currentPageParameters.redirect) {
currentLink = extractLinkToPage( currentPageParameters.content,
possibleBacklinkDestinations, currentLink ? currentLink.end : 0 );
if ( currentLink ) {
updateContext();
} else {
doPage();
}
} else {
doPage();
}
};
/*
* Replace the target of a link with a different one
* pageTitle: New link target
* extra: Additional text after the link (optional)
* summary: Change summary (optional)
*/
const chooseReplacement = function( pageTitle, extra, summary ) {
if ( choosing ) {
choosing = false;
if ( !summary ) {
if ( pageTitle ) {
summary = txt.summaryChanged.replace( '$1', pageTitle );
} else {
summary = txt.summaryOmitted;
}
}
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
if ( pageTitle && ( pageTitle !== getTargetPage() || extra ) ) {
if ( currentPageParameters.redirect ) {
currentLink.description = pageTitle;
}
currentPageParameters.content =
replaceLink( currentPageParameters.content, pageTitle, currentLink, extra || '' );
}
doLink();
}
};
/*
* Replace the link with an explicit link to the disambiguation page
*/
const chooseIntentionalLink = function() {
const targetPage = getTargetPage();
let title = cfg.disamFormat.replace( '$1', targetPage );
if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
title = targetPage;
}
chooseReplacement( title, '', txt.summaryIntentional );
};
/*
* Prompt for an alternative link target and use it as a replacement
*/
const chooseTitleFromPrompt = function() {
var title = prompt( txt.titleAsTextPrompt );
if ( title !== null ) {
chooseReplacement( title );
}
};
/*
* Remove the current link, leaving the text unchanged
*/
const chooseLinkRemoval = function() {
if ( choosing ) {
var summary = txt.summaryRemoved;
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
currentPageParameters.content = removeLink( currentPageParameters.content, currentLink );
doLink();
}
};
/*
* Add a "disambiguation needed" template after the link
*/
const chooseDisamNeeded = function() {
chooseReplacement( currentLink.title, cfg.disamNeededText, txt.summaryHelpNeeded );
};
/*
* Undo the last change
*/
const undo = function() {
if ( pageChanges.length !== 0 ) {
var lastPage = pageChanges[pageChanges.length - 1];
if ( currentPageTitle !== lastPage.title ) {
links.unshift( currentPageTitle );
currentPageTitle = lastPage.title;
}
currentPageParameters = lastPage.page;
currentPageParameters.content = lastPage.contentBefore.pop();
currentLink = lastPage.links.pop();
lastPage.summary.pop();
if ( lastPage.contentBefore.length === 0 ) {
pageChanges.pop();
}
updateContext();
}
};
/*
* Omit the current link without making a change
*/
const omit = function() {
chooseReplacement( null );
};
/*
* Save all the pending changes and restart the tool.
*/
const refresh = function() {
saveAndEnd();
start();
};
/*
* Enable or disable the buttons that can perform actions on a page or change the current link.
* enabled: Whether to enable or disable the buttons
*/
const toggleActionButtons = function( enabled ) {
var affectedButtons = [ui.omitButton, ui.titleAsTextButton, ui.removeLinkButton,
ui.intentionalLinkButton, ui.disamNeededButton, ui.undoButton];
$.each( affectedButtons, function( ii, button ) {
button?.prop( 'disabled', !enabled );
} );
};
/*
* Show or hide the 'no more links' message
* show: Whether to show or hide the message
*/
const toggleFinishedMessage = function( show ) {
toggleActionButtons( !show );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.finishedMessage.toggle( show );
ui.pageTitleLine.toggle( !show );
ui.context.toggle( !show );
if ( show ) console.log(Date.now())
};
const togglePendingEditBox = function( show ) {
if ( pendingEditBox === null ) {
pendingEditBox = $( '<div>' ).addClass( 'disamassist-box disamassist-pendingeditbox' );
pendingEditBoxText = $( '<div>' );
pendingEditBox.append( pendingEditBoxText ).hide();
if ( editLimit ) {
pendingEditBox.append( $( '<div>' ).text( txt.pendingEditBoxLimited )
.addClass( 'disamassist-subtitle' ) );
}
$( '#mw-content-text' ).before( pendingEditBox );
updateEditCounter();
}
if ( show ) {
pendingEditBox.fadeIn();
} else {
pendingEditBox.fadeOut();
}
};
const notifyCompletion = function() {
var oldTitle = document.title;
document.title = txt.notifyCharacter + document.title;
$( document.body ).one( 'mousemove', function() {
document.title = oldTitle;
} );
};
/*
* Update the displayed information to match the current link
* or lack thereof
*/
const updateContext = function() {
updateEditCounter();
if ( !currentLink ) {
toggleFinishedMessage( true );
} else {
ui.pageTitleLine.html( txt.pageTitleLine.replace( '$1',
mw.util.getUrl( currentPageTitle, {redirect: 'no'} ) ).replace( '$2', mw.html.escape( currentPageTitle ) ) );
var context = extractContext( currentPageParameters.content, currentLink );
ui.context.empty()
.append( $( '<span>' ).text( context[0] ) )
.append( $( '<span>' ).text( context[1] ).addClass( 'disamassist-inclink' ) )
.append( $( '<span>' ).text( context[2] ) );
toggleFinishedMessage( false );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.removeLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.intentionalLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.disamNeededButton?.prop( 'disabled', currentPageParameters.redirect || currentLink.hasDisamTemplate );
choosing = true;
}
};
/*
* Update the count of pending changes
*/
const updateEditCounter = function() {
if ( ui.pendingEditCounter ) {
ui.pendingEditCounter.text( txt.pendingEditCounter.replace( '$1', editCount )
.replace( '$2', countActuallyChangedFullyCheckedPages() ) );
}
if ( pendingEditBox ) {
if ( editCount === 0 && !running ) {
togglePendingEditBox( false );
notifyCompletion();
}
var textContent = editCount;
if ( editLimit ) {
textContent = txt.pendingEditBoxTimeEstimation.replace( '$1', editCount )
.replace( '$2', secondsToHHMMSS( cfg.editCooldown * editCount ) );
}
pendingEditBoxText.text( txt.pendingEditBox.replace( '$1', textContent ) );
}
};
/*
* Apply the changes made to an "origin" page
* pageChange: Change that will be saved
*/
const applyChange = function( pageChange ) {
if ( pageChange.page.content !== pageChange.contentBefore[0] ) {
editCount++;
// Filter duplicates and join
var changeSummaries = pageChange.summary
.filter( ( val, i, arr ) => val && arr.indexOf( val ) === i )
.join( txt.summarySeparator );
var summary = txt.summary.replace( '$1', getTargetPage() ).replace( '$2', changeSummaries );
var save = editLimit ? saveWithCooldown : savePage;
save( pageChange.title, pageChange.page, summary, true, true ).always( function() {
if ( editCount > 0 ) {
editCount--;
}
updateEditCounter();
} ).fail( error );
updateEditCounter();
}
};
/*
* Save all the pending changes
*/
const applyAllChanges = function() {
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
applyChange( pageChanges[ii] );
}
pageChanges = [];
};
/*
* Record a new pending change
* pageTitle: Title of the page
* page: Content of the page
* oldContent: Content of the page before the change
* link: Link that has been changed
* summary: Change summary
*/
const addChange = function( pageTitle, page, oldContent, link, summary ) {
if ( ( pageChanges.length === 0 ) || ( pageChanges[pageChanges.length - 1].title !== pageTitle ) ) {
pageChanges.push( {
title: pageTitle,
page: page,
contentBefore: [],
links: [],
summary: []
} );
}
var lastPageChange = pageChanges[pageChanges.length - 1];
lastPageChange.contentBefore.push( oldContent );
lastPageChange.links.push( link );
lastPageChange.summary.push( summary );
};
/*
* Check whether actual changes are stored in the history array
*/
const checkActualChanges = function() {
return countActualChanges() !== 0;
};
/*
* Return the number of entries in the history array that represent actual changes
*/
const countActualChanges = function() {
var changeCount = 0;
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
if ( pageChanges[ii].page.content !== pageChanges[ii].contentBefore[0] ) {
changeCount++;
}
}
return changeCount;
};
/*
* Return the number of changed pages in the history array, ignoring the last entry
* if we aren't done with that page yet
*/
const countActuallyChangedFullyCheckedPages = function() {
var changeCount = countActualChanges();
if ( pageChanges.length !== 0 ) {
var lastChange = pageChanges[pageChanges.length - 1];
if ( lastChange.title === currentPageTitle && currentLink !== null
&& lastChange.page.content !== lastChange.contentBefore[0] ) {
changeCount--;
}
}
return changeCount;
};
/*
* Find the links to disambiguation options in a disambiguation page
*/
const getDisamOptions = function() {
return $( '#mw-content-text a' ).filter( function() {
return !!extractPageName( $( this ) );
} );
};
/*
* Save all the pending changes and close the tool
*/
const saveAndEnd = function() {
applyAllChanges();
end();
};
/*
* Close the tool
*/
const end = function() {
var currentToolUI = ui.display;
choosing = false;
running = false;
$( '.disamassist-optionmarker' ).remove();
currentToolUI.fadeOut( { complete: function() {
currentToolUI.remove();
if ( editCount !== 0 ) {
togglePendingEditBox( true );
}
} } );
};
/*
* Display an error message
*/
const error = function( errorDescription ) {
var errorBox = $( '<div>' ).addClass( 'disamassist-box disamassist-errorbox' );
errorBox.text( txt.error.replace( '$1', errorDescription ) );
errorBox.append( createButton( txt.dismissError, function() {
errorBox.fadeOut();
} ).addClass( 'disamassist-errorbutton' ) );
var uiIsInPlace = ui && $.contains( document.documentElement, ui.display[0] );
var nextElement = uiIsInPlace ? ui.display : $( '#mw-content-text' );
nextElement.before( errorBox );
errorBox.hide().fadeIn();
}
/*
* Change a link so that it points to the title
* text: The wikitext of the whole page
* title: The new destination of the link
* link: The link that will be modified
* extra: Text that will be added after the link (optional)
*/
const replaceLink = function( text, title, link, extra ) {
var anchor = link.anchor || '';
var newContent;
if ( link.type === 'sortnaam' ) {
newContent = '{{sortnaam|' + link.first + '|' + link.last + '|' + title + anchor + '}}';
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + ( extra || '' ) + linkEnd;
}
if ( isSamePage( title, link.description ) ) {
newContent = link.description + anchor;
} else {
newContent = title + anchor + '|' + link.description;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + '[[' + newContent + ']]' + link.afterDescription + ( extra || '' ) + linkEnd;
};
/*
* Remove a link from the text
* text: The wikitext of the whole page
* link: The link that will be removed
*/
const removeLink = function( text, link ) {
var anchor = link.anchor || '';
if ( link.type === 'sortnaam' ) {
// Als er een derde parameter of anker was, laten we de derde parameter weg of behouden we alleen de basis
var newContent = '{{sortnaam|' + link.first + '|' + link.last + '}}' + anchor;
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + linkEnd;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + link.description + anchor + link.afterDescription + linkEnd;
};
/*
* Extract a link from a string in wiki format,
* starting from a given index. Return a link if one can be found,
* otherwise return null. The "link" includes "disambiguation needed"
* templates inmediately following the link proper
* text: Text from which the link will be extracted
* lastIndex: Index from which the search will start
*/
const extractLink = function( text, lastIndex ) {
// 1. Zoek naar gewone wikitlinks (inclusief optioneel anker #...)
titleRegex.lastIndex = lastIndex;
var wikiMatch = titleRegex.exec( text );
// 2. Zoek naar sortnaam-templates (inclusief optioneel anker na de derde parameter of achter de template)
var sortnaamRegex = new RegExp('{{\\s*sortnaam\\s*\\|\\s*([^|]+?)\\s*\\|\\s*([^|]+?)\\s*(?:\\|\\s*([^|#}]*?)\\s*)?(#[^|}]*)?}}', 'gi');
sortnaamRegex.lastIndex = lastIndex;
var sortnaamMatch = sortnaamRegex.exec( text );
var matchType = null;
var matchIndex = Infinity;
if ( wikiMatch !== null && wikiMatch.index >= lastIndex ) {
matchIndex = wikiMatch.index;
matchType = 'wiki';
}
if ( sortnaamMatch !== null && sortnaamMatch.index >= lastIndex && sortnaamMatch.index < matchIndex ) {
matchIndex = sortnaamMatch.index;
matchType = 'sortnaam';
}
if ( matchType === 'wiki' ) {
var possiblyAmbiguous = true;
var hasDisamTemplate = false;
// wikiMatch[2] bevat het anker als dat in de regex is opgenomen, of we vangen het op via de matchlengte
var fullMatchStr = wikiMatch[0];
var anchor = wikiMatch[2] ? wikiMatch[2] : '';
var end = wikiMatch.index + fullMatchStr.length;
var afterDescription = '';
var rest = text.substring( end );
var templateRegex = /^(\w*[.,:;?!)}\s]*){{\s*([^|{}]+?)\s*(?:\|[^{]*?)?}}/g;
var templateMatch = templateRegex.exec( rest );
if ( templateMatch !== null ) {
var templateTitle = getCanonicalTitle( templateMatch[2] );
if ( cfg.disamLinkTemplates?.includes( templateTitle ) ) {
end += templateMatch[0].length;
afterDescription = templateMatch[1].replace(/\s$/, '');
hasDisamTemplate = true;
} else if ( cfg.disamLinkIgnoreTemplates?.includes( templateTitle ) ) {
possiblyAmbiguous = false;
}
}
return {
type: 'wiki',
start: wikiMatch.index,
end: end,
possiblyAmbiguous: possiblyAmbiguous,
hasDisamTemplate: hasDisamTemplate,
title: wikiMatch[1],
description: wikiMatch[3] ? wikiMatch[3] : wikiMatch[1],
anchor: anchor,
afterDescription: afterDescription
};
} else if ( matchType === 'sortnaam' ) {
var first = sortnaamMatch[1].trim();
var last = sortnaamMatch[2].trim();
var explicitTarget = sortnaamMatch[3] ? sortnaamMatch[3].trim() : '';
var anchor = sortnaamMatch[4] ? sortnaamMatch[4].trim() : '';
var target = explicitTarget !== '' ? explicitTarget : ( first + ' ' + last );
return {
type: 'sortnaam',
start: sortnaamMatch.index,
end: sortnaamMatch.index + sortnaamMatch[0].length,
possiblyAmbiguous: true,
hasDisamTemplate: false,
title: target,
description: sortnaamMatch[0],
first: first,
last: last,
explicitTarget: explicitTarget,
anchor: anchor,
afterDescription: ''
};
}
return null;
};
/*
* Extract a link to one of a number of destination pages from a string
* ("text") in wiki format, starting from a given index ("lastIndex").
* "Disambiguation needed" templates are included as part of the links.
* text: Page in wiki format
* destinations: Array of page titles to look for
* lastIndex: Index from which the search will start
*/
const extractLinkToPage = function( text, destinations, lastIndex ) {
var link, title;
do {
link = extractLink( text, lastIndex );
if ( link !== null ) {
lastIndex = link.end;
title = getCanonicalTitle( link.title );
}
} while (
link !== null
&& ( !link.possiblyAmbiguous || !destinations?.includes( title ) )
);
return link;
};
/*
* Find the "target" page: either the one we are in or the "main" one found by extracting
* the title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const getTargetPage = function() {
var title = getTitle();
return forceSamePage ? title : removeDisam( title );
};
/*
* Get the page title, with the namespace prefix if any.
*/
const getTitle = function() {
return mw.config.get( 'wgPageName' ).replace(/_/g, ' ')
}
/*
* Extract a "main" title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const removeDisam = function( title ) {
var match = new RegExp( cfg.disamRegExp ).exec( title );
return match ? match[1] : title;
};
/*
* Check whether two page titles are the same
*/
const isSamePage = function( title1, title2 ) {
return getCanonicalTitle( title1 ) === getCanonicalTitle( title2 );
};
/*
* Return the 'canonical title' of a page
*/
const getCanonicalTitle = function( title ) {
try {
title = new mw.Title( title );
// Check that page name matches the current namespace
if ( title.getNamespaceId() !== mw.config.get( 'wgNamespaceNumber' ) ) {
return;
}
const fragment = title.getFragment();
return title.getPrefixedText() + ( fragment ? `#${ fragment }` : '' );
} catch ( ex ) {
// mw.Title seems to be buggy, and some valid titles are rejected
// FIXME: This may cause false negatives
}
};
/*
* Extract the context around a given link in a text string
*/
const extractContext = function( text, link ) {
var contextStart = link.start - cfg.radius;
var contextEnd = link.end + cfg.radius;
var contextPrev = text.substring( contextStart, link.start );
const ellipsis = '…';
if ( contextStart > 0 ) {
contextPrev = ellipsis + contextPrev;
}
var contextNext = text.substring( link.end, contextEnd );
if ( contextEnd < text.length ) {
contextNext = contextNext + ellipsis;
}
return [contextPrev, text.substring( link.start, link.end ), contextNext];
};
/*
* Extract the prefixed page name from a link
*/
const extractPageName = function( link ) {
var pageName = extractPageNameRaw( link );
if ( pageName ) {
return getCanonicalTitle( pageName );
}
return null;
};
/*
* Extract the page name from a link, as is
*/
const extractPageNameRaw = function( link ) {
// Ignore images and interwiki links
if ( link.hasClass( 'image' ) || link.hasClass( 'extiw' ) ) {
return null;
}
var href = link.attr( 'href' );
// Red links in old parser format
if ( link.hasClass( 'new' ) ) {
const oldRedLink = mw.util.getParamValue( 'title', href );
if ( oldRedLink !== null ) {
return oldRedLink;
}
}
// Check that external links are coming from this wiki
if ( href.indexOf( '//' ) === 0 || href.indexOf( 'https://' ) === 0 ) {
try {
const fullUrl = href.indexOf( '//' ) === 0 ? location.protocol + href : href;
let urlData = new URL( fullUrl );
if ( urlData.hostname !== location.hostname ) {
return null;
}
} catch( e ) {
return null;
}
}
var articlePathRegexp = new RegExp(
mw.config.get( 'wgArticlePath' ).replace( '$1', '([^?]*)' )
);
var result = articlePathRegexp.exec( href );
if ( Array.isArray( result ) && result.length > 1 ) {
return decodeURIComponent( result[ 1 ] );
}
return null;
};
/*
* Check whether this is a disambiguation page
*/
const isDisam = function() {
const categories = mw.config.get( 'wgCategories', [] );
return cfg.disamCategories.some( c => categories.includes( c ) );
};
const secondsToHHMMSS = function( totalSeconds ) {
var hhmmss = '';
var hours = Math.floor( totalSeconds / 3600 );
var minutes = Math.floor( totalSeconds % 3600 / 60 );
var seconds = Math.floor( totalSeconds % 3600 % 60 );
if ( hours >= 1 ) {
hhmmss = pad( hours, '0', 2 ) + ':';
}
hhmmss += pad( minutes, '0', 2 ) + ':' + pad( seconds, '0', 2 );
return hhmmss;
};
const pad = function( str, z, width ) {
str = str.toString();
if ( str.length >= width ) {
return str;
} else {
return new Array( width - str.length + 1 ).join( z ) + str;
}
}
/*
* Create a new button
* text: Text that will be displayed on the button
* onClick: Function that will be called when the button is clicked
*/
const createButton = function( text, onClick ) {
var button = $( '<button>', { 'type': 'button' } );
button.text( text );
button.addClass( 'disamassist-button' ).click( onClick );
return button;
};
/*
* Given a page title and an array of possible redirects {from, to} ("canonical titles"), find the page
* at the end of the redirect chain, if there is one. Otherwise, return the page title that was passed
*/
const resolveRedirect = function( pageTitle, possibleRedirects ) {
var appliedRedirect = true;
var visitedPages = {};
var currentPage = getCanonicalTitle( pageTitle );
while ( appliedRedirect ) {
appliedRedirect = false;
for ( var ii = 0; ii < possibleRedirects.length; ii++ ) {
if ( possibleRedirects[ii].from === currentPage ) {
if ( visitedPages[possibleRedirects[ii].to] ) {
// Redirect chain detected
return pageTitle;
}
visitedPages[currentPage] = true;
appliedRedirect = true;
currentPage = possibleRedirects[ii].to;
}
}
}
// No redirect rules applied for an iteration of the outer loop:
// no more redirects. We are done
return currentPage;
};
/*
* Fetch the incoming links to a page. Returns a jQuery promise
* (success - array of titles of pages that contain links to the target page and
* array of "canonical titles" of possible destinations of the backlinks (either
* the target page or redirects to the target page), failure - error description)
* page: Target page
*/
const getBacklinks = function( page ) {
var dfd = new $.Deferred();
api.get( {
'action': 'query',
'list': 'backlinks',
'bltitle': page,
'blredirect': true,
'bllimit': cfg.backlinkLimit,
'blnamespace': cfg.targetNamespaces,
} ).done( function( data ) {
// There might be duplicate entries in some corner cases. We don't care,
// since we are going to check later, anyway
var backlinks = [];
var linkTitles = [getCanonicalTitle( page )];
$.each( data.query.backlinks, function() {
backlinks.push( this.title );
if ( this.redirlinks ) {
linkTitles.push( this.title );
$.each( this.redirlinks, function() {
backlinks.push( this.title );
} );
}
} );
dfd.resolve( backlinks, linkTitles );
} ).fail( function( code, data ) {
dfd.reject( txt.getBacklinksError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download a list of redirects for some pages. Returns a jQuery callback (success -
* array of redirects ({from, to}), failure - error description )
* pageTitles: Array of page titles
*/
const fetchRedirects = function( pageTitles ) {
var dfd = new $.Deferred();
var currentTitles = pageTitles.slice( 0, cfg.queryTitleLimit );
var restTitles = pageTitles.slice( cfg.queryTitleLimit );
api.get( {
action: 'query',
titles: currentTitles,
redirects: true,
} ).done( function( data ) {
var theseRedirects = data.query.redirects ? data.query.redirects : [];
if ( restTitles.length !== 0 ) {
fetchRedirects( restTitles ).done( function( redirects ) {
dfd.resolve( theseRedirects.concat( redirects ) );
} ).fail( function( description ) {
dfd.reject( description );
} );
} else {
dfd.resolve( theseRedirects );
}
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRedirectsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download the list of user rights for the current user. Returns a
* jQuery promise (success - array of right names, error - error description)
*/
const fetchRights = function() {
var dfd = $.Deferred();
api.get( {
action: 'query',
meta: 'userinfo',
uiprop: 'rights',
} ).done( function( data ) {
dfd.resolve( data.query.userinfo.rights );
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRightsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Load the raw page text for a given title. Returns a jQuery promise (success - page
* content, failure - error description)
* pageTitle: Title of the page
*/
const loadPage = function( pageTitle ) {
var dfd = new $.Deferred();
api.get( {
action: 'query',
titles: pageTitle,
prop: 'revisions',
rvprop: 'timestamp|content',
meta: 'tokens',
type: 'csrf',
} ).done( function( data ) {
var pages = data.query.pages;
for ( var key in pages ) {
if ( pages.hasOwnProperty( key ) ) {
break;
}
}
var rawPage = data.query.pages[key];
var page = {};
page.missing = rawPage.missing !== undefined;
if ( rawPage.revisions ) {
page.content = rawPage.revisions[0]['*'];
// Assume anything with # at the start is a redirect
if ( page.content.match( /^#[^\s]*?\s*\[\[/ ) ) {
page.redirect = true;
}
page.baseTimeStamp = rawPage.revisions[0].timestamp;
} else {
page.content = '';
page.baseTimeStamp = null;
}
page.startTimeStamp = rawPage.starttimestamp;
page.editToken = data.query.tokens.csrftoken;
dfd.resolve( page );
} ).fail( function( code, data ) {
dfd.reject( txt.loadPageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
/*
* Register changes to a page, to be saved later. Returns a jQuery promise
* (success - no params, failure - error description). Takes the same parameters
* as savePage
*/
const saveWithCooldown = function() {
var deferred = new $.Deferred();
pendingSaves.push( {args: arguments, dfd: deferred} );
if ( !runningSaves ) {
checkAndSave();
}
return deferred.promise();
};
/*
* Save the first set of changes in the list of pending changes, providing that
* enough time has passed since the last edit
*/
const checkAndSave = function() {
if ( pendingSaves.length === 0 ) {
runningSaves = false;
return;
}
runningSaves = true;
var millisSinceLast = new Date().getTime() - lastEditMillis;
if ( millisSinceLast < cfg.editCooldown * 1000 ) {
setTimeout( checkAndSave, cfg.editCooldown * 1000 - millisSinceLast );
} else {
// The last edit started at least cfg.editCooldown seconds ago
var save = pendingSaves.shift();
savePage.apply( this, save.args ).done( function() {
checkAndSave();
save.dfd.resolve();
} ).fail( function( description ) {
checkAndSave();
save.dfd.reject( description );
} );
// We'll use the time since the last edit started
lastEditMillis = new Date().getTime();
}
};
/*
* Save the changes made to a page. Returns a jQuery promise (success - no params,
* failure - error description)
* pageTitle: Title of the page
* page: Page data
* summary: Summary of the changes made to the page
* minorEdit: Whether to mark the edit as 'minor'
* botEdit: Whether to mark the edit as 'bot'
*/
const savePage = function( pageTitle, page, summary, minorEdit, botEdit ) {
var dfd = new $.Deferred();
api.post( {
action: 'edit',
title: pageTitle,
token: page.editToken,
text: page.content,
basetimestamp: page.baseTimeStamp,
starttimestamp: page.startTimeStamp,
summary: summary,
watchlist: cfg.watch ? cfg.watch : 'nochange',
assertuser: mw.config.get( 'wgUserName' ),
minor: minorEdit,
bot: botEdit,
} ).done( function() {
dfd.resolve();
} ).fail( function( code, data ) {
dfd.reject( txt.savePageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
install();
} )();
// </nowiki>
hm77g6qklg9y3s1msf851l8ak2ujh4j
781189
781186
2026-10-27T15:56:57Z
Rots61
44293
781189
javascript
text/javascript
/*
* DisamAssist: a tool for repairing links from articles to disambiguation pages.
*/
// <nowiki>
// @ts-nocheck
( () => {
var cfg = {};
var txt = {};
var ui;
var links, pageChanges;
var currentPageTitle, currentPageParameters, currentLink;
var possibleBacklinkDestinations;
var titleRegex;
var forceSamePage = false;
var running = false;
var choosing = false;
var canMarkIntentionalLinks = false;
var displayedPages = {};
var editCount = 0;
var editLimit;
var pendingSaves = [];
var pendingEditBox = null;
var pendingEditBoxText;
var lastEditMillis = 0;
var runningSaves = false;
var api;
const dependencies = [
'mediawiki.api',
'mediawiki.confirmCloseWindow',
'mediawiki.Title',
'mediawiki.util',
];
/**
* Add an item in Tools menu
* @param {string} text
* @param {string} id
* @param {(e: MouseEvent) => void} callback
*/
const disamPortletLink = ( text, id, callback ) => {
const portlet = mw.util.addPortletLink(
'p-tb',
cfg.helpPage ? mw.util.getUrl( cfg.helpPage ) : '#',
text,
id
);
const link = portlet?.querySelector( 'a' );
link?.addEventListener( 'click', callback );
addButtonEvents( link );
}
/*
* Entry point. Check whether we are in a disambiguation page. If so, add a link to start the tool
*/
const install = function() {
cfg = window.DisamAssist.cfg;
txt = window.DisamAssist.txt;
if ( mw.config.get( 'wgAction' ) === 'view' && isDisam() ) {
mw.loader.using( dependencies, () => {
api = new mw.Api();
mw.loader.load( 'https://ru.wikipedia.org/w/index.php?title=Участник:Stjn/DisamAssist.css&action=raw&ctype=text/css', 'text/css' );
// This is a " (disambiguation)" page
if ( new RegExp( cfg.disamRegExp ).exec( getTitle() ) ) {
disamPortletLink( txt.startMain, 't-disamAssist-main', startMain );
disamPortletLink( txt.startSame, 't-disamAssist-same', startSame );
} else {
disamPortletLink( txt.start, 't-disamAssist-page', start );
}
} );
}
};
/*
* Start the tool. Display the UI and begin looking for links to fix
*/
const start = function( e ) {
e?.preventDefault();
if ( !running ) {
console.log(Date.now())
running = true;
links = [];
pageChanges = [];
displayedPages = {};
ensureDABExists().then( function( canMark ) {
canMarkIntentionalLinks = canMark;
createUI();
addUnloadConfirm();
markDisamOptions();
checkEditLimit().then( function() {
togglePendingEditBox( false );
doPage();
} );
} );
}
};
/*
* Start DisamAssist. Disambiguate incoming links to the current page, regardless
* of the title.
*/
const startSame = function( e ) {
e.preventDefault();
forceSamePage = true;
start( e );
};
/*
* Start DisamAssist. If the page title ends with " (disambiguation)", disambiguate
* links to the primary topic article. Otherwise, disambiguate links to the current
* page.
*/
const startMain = function( e ) {
e.preventDefault();
forceSamePage = false;
start( e );
};
/*
* Create and show the user interface.
*/
const createUI = function() {
ui = {
display: $( '<div>' ).addClass( 'disamassist-box disamassist-mainbox' ),
finishedMessage: $( '<div>' ).text( txt.noMoreLinks ).hide(),
pageTitleLine: $( '<span>' ).addClass( 'disamassist-pagetitleline' ),
pendingEditCounter: $( '<div>').addClass( 'disamassist-editcounter' ),
context: $( '<pre>' ).addClass( 'disamassist-context' ),
undoButton: createButton( txt.undo, undo ),
omitButton: createButton( txt.omit, omit ),
endButton: createButton( txt.close, saveAndEnd ),
refreshButton: createButton( txt.refresh, refresh ),
titleAsTextButton: createButton( txt.titleAsText, chooseTitleFromPrompt ),
intentionalLinkButton: canMarkIntentionalLinks ? createButton( txt.intentionalLink, chooseIntentionalLink ) : null,
disamNeededButton: cfg.disamNeededText ? createButton( txt.disamNeeded, chooseDisamNeeded ) : null,
removeLinkButton: createButton( txt.removeLink, chooseLinkRemoval )
};
var top = $( '<div>' ).addClass( 'disamassist-top' )
.append( [ui.pageTitleLine, ui.pendingEditCounter] );
var leftButtons = $( '<div>' ).addClass( 'disamassist-leftbuttons' )
.append( [ui.titleAsTextButton, ui.removeLinkButton, ui.intentionalLinkButton, ui.disamNeededButton, ui.omitButton] );
var rightButtons = $( '<div>' ).addClass( 'disamassist-rightbuttons' )
.append( [ui.undoButton, ui.refreshButton, ui.endButton] );
var allButtons = $( '<div>' ).addClass( 'disamassist-allbuttons' )
.append( [leftButtons, rightButtons] );
ui.display.append( [top, ui.finishedMessage, ui.context, allButtons] );
updateEditCounter();
toggleActionButtons( false );
// Insert the UI in the page
$( '#mw-content-text' ).before( ui.display );
ui.display.hide().fadeIn();
};
/**
* Add button role and related keyboard event
* @param {Element} el
*/
const addButtonEvents = ( el ) => {
el?.setAttribute( 'role', 'button' );
el?.addEventListener( 'keydown', function( e ) {
if ( [ 'Enter', 'Space' ].includes( e.code ) ) {
e.preventDefault();
this.click();
}
} );
}
/*
* If there are pending changes, show a confirm dialog before closing
*/
const addUnloadConfirm = function() {
mw.confirmCloseWindow( {
test: () => {
if ( running && checkActualChanges() ) {
return txt.pending;
} else if ( editCount !== 0 ) {
return txt.editInProgress;
}
}
} );
};
/*
* Mark the disambiguation options as such
*/
const markDisamOptions = function() {
var optionPageTitles = [];
var optionMarkers = [];
getDisamOptions().each( function() {
var link = $( this );
var title = extractPageName( link );
const optionMarker = document.createElement( 'a' );
optionMarker.classList.add( 'disamassist-optionmarker' );
optionMarker.href = link.attr( 'href' );
optionMarker.setAttribute( 'data-title', title );
optionMarker.textContent = txt.optionMarker;
optionMarker.addEventListener( 'click', ( e ) => {
e.preventDefault();
chooseReplacement( title );
} );
addButtonEvents( optionMarker );
link.after( optionMarker );
optionMarkers.push( optionMarker );
optionPageTitles.push( title );
} );
// Now check the disambiguation options and display a different message for those that are
// actually the same as the target page where the links go, as choosing those options doesn't really
// accomplish anything (except bypassing redirects, which might be useful in some cases)
var targetPage = getTargetPage();
fetchRedirects( optionPageTitles.concat( targetPage ) ).done( function( redirects ) {
var endTargetPage = resolveRedirect( targetPage, redirects );
for ( var ii = 0; ii < optionPageTitles.length; ii++ ) {
var endOptionTitle = resolveRedirect( optionPageTitles[ii], redirects );
if ( isSamePage( optionPageTitles[ii], targetPage ) ) {
$( optionMarkers[ii] ).text( txt.targetOptionMarker ).addClass( 'disamassist-curroptionmarker');
} else if ( isSamePage( endOptionTitle, endTargetPage ) ) {
$( optionMarkers[ii] ).text( txt.redirectOptionMarker ).addClass( 'disamassist-curroptionmarker');
}
}
} ).fail( error );
};
/*
* Check whether intentional links to disambiguation pages can be explicitly marked
* as such in this wiki. If so, ensure that a "Foo (disambiguation)" page exists.
* Returns a jQuery promise
*/
const ensureDABExists = function() {
var dfd = new $.Deferred();
var title = getTitle();
// That concept doesn't exist in this wiki.
if ( !cfg.intentionalLinkOption ) {
dfd.resolve( false );
// "Foo (disambiguation)" exists: it's the current page.
} else if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
dfd.resolve( true );
} else {
var disamTitle = cfg.disamFormat.replace( '$1', title );
loadPage( disamTitle ).done( function( page ) {
// "Foo (disambiguation)" doesn't exist.
if ( page.missing ) {
// We try to create it
page.content = cfg.redirectToDisam.replace( '$1', title );
var summary = txt.redirectSummary.replace( '$1', title );
savePage( disamTitle, page, summary, false, true ).done( function() {
dfd.resolve( true );
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
// It does exist
} else {
dfd.resolve( true );
}
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
}
return dfd.promise();
};
/*
* Check whether the edit cooldown applies and sets editLimit accordingly.
* Returns a jQuery promise
*/
const checkEditLimit = function() {
var dfd = new $.Deferred();
if ( cfg.editCooldown <= 0 ) {
editLimit = false;
dfd.resolve();
} else {
fetchRights().done( function( rights ) {
editLimit = !rights?.includes( 'bot' );
} ).fail( function( description ) {
error( description );
editLimit = true;
} ).always( function() {
dfd.resolve();
} );
}
return dfd.promise();
};
/*
* Find and ask the user to fix all the incoming links to the disambiguation ("target")
* page from a single "origin" page
*/
const doPage = function() {
if ( pageChanges.length > cfg.historySize ) {
applyChange( pageChanges.shift() );
}
if ( links.length === 0 ) {
var targetPage = getTargetPage();
getBacklinks( targetPage ).done( function( backlinks, pageTitles ) {
var pending = {};
$.each( pendingSaves, function() {
pending[this[0]] = true;
} );
possibleBacklinkDestinations = $.grep( pageTitles, function( t, ii) {
if ( t == targetPage ) {
return true;
}
return removeDisam(t) != targetPage;
} );
titleRegex = new RegExp('\\[\\[([ _]*?(?:' + possibleBacklinkDestinations.join('|').replaceAll('(', '\\(').replaceAll(')','\\)').replaceAll(' ', '[ _]') + ')[ _]*?)(#.*?)?(?:\\|(.*?))?\\]\\]', 'gi');
// Only incoming links from pages we haven't seen yet and we aren't currently
// saving (displayedPages is reset when the tool is closed and opened again,
// while the list of pending changes isn't; if the edit cooldown is disabled,
// it will be empty)
links = $.grep( backlinks, function( el, ii ) {
return !displayedPages[el] && !pending[el];
} );
if ( links.length === 0 ) {
updateContext();
} else {
doPage();
}
} ).fail( error );
} else {
currentPageTitle = links.shift();
displayedPages[currentPageTitle] = true;
toggleActionButtons( false );
loadPage( currentPageTitle ).done( function( data ) {
currentPageParameters = data;
currentLink = null;
doLink();
} ).fail( error );
}
};
/*
* Find and ask the user to fix a single incoming link to the disambiguation ("target")
* page
*/
const doLink = function() {
if (!currentPageParameters.redirect) {
currentLink = extractLinkToPage( currentPageParameters.content,
possibleBacklinkDestinations, currentLink ? currentLink.end : 0 );
if ( currentLink ) {
updateContext();
} else {
doPage();
}
} else {
doPage();
}
};
/*
* Replace the target of a link with a different one
* pageTitle: New link target
* extra: Additional text after the link (optional)
* summary: Change summary (optional)
*/
const chooseReplacement = function( pageTitle, extra, summary ) {
if ( choosing ) {
choosing = false;
if ( !summary ) {
if ( pageTitle ) {
summary = txt.summaryChanged.replace( '$1', pageTitle );
} else {
summary = txt.summaryOmitted;
}
}
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
if ( pageTitle && ( pageTitle !== getTargetPage() || extra ) ) {
if ( currentPageParameters.redirect ) {
currentLink.description = pageTitle;
}
currentPageParameters.content =
replaceLink( currentPageParameters.content, pageTitle, currentLink, extra || '' );
}
doLink();
}
};
/*
* Replace the link with an explicit link to the disambiguation page
*/
const chooseIntentionalLink = function() {
const targetPage = getTargetPage();
let title = cfg.disamFormat.replace( '$1', targetPage );
if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
title = targetPage;
}
chooseReplacement( title, '', txt.summaryIntentional );
};
/*
* Prompt for an alternative link target and use it as a replacement
*/
const chooseTitleFromPrompt = function() {
var title = prompt( txt.titleAsTextPrompt );
if ( title !== null ) {
chooseReplacement( title );
}
};
/*
* Remove the current link, leaving the text unchanged
*/
const chooseLinkRemoval = function() {
if ( choosing ) {
var summary = txt.summaryRemoved;
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
currentPageParameters.content = removeLink( currentPageParameters.content, currentLink );
doLink();
}
};
/*
* Add a "disambiguation needed" template after the link
*/
const chooseDisamNeeded = function() {
chooseReplacement( currentLink.title, cfg.disamNeededText, txt.summaryHelpNeeded );
};
/*
* Undo the last change
*/
const undo = function() {
if ( pageChanges.length !== 0 ) {
var lastPage = pageChanges[pageChanges.length - 1];
if ( currentPageTitle !== lastPage.title ) {
links.unshift( currentPageTitle );
currentPageTitle = lastPage.title;
}
currentPageParameters = lastPage.page;
currentPageParameters.content = lastPage.contentBefore.pop();
currentLink = lastPage.links.pop();
lastPage.summary.pop();
if ( lastPage.contentBefore.length === 0 ) {
pageChanges.pop();
}
updateContext();
}
};
/*
* Omit the current link without making a change
*/
const omit = function() {
chooseReplacement( null );
};
/*
* Save all the pending changes and restart the tool.
*/
const refresh = function() {
saveAndEnd();
start();
};
/*
* Enable or disable the buttons that can perform actions on a page or change the current link.
* enabled: Whether to enable or disable the buttons
*/
const toggleActionButtons = function( enabled ) {
var affectedButtons = [ui.omitButton, ui.titleAsTextButton, ui.removeLinkButton,
ui.intentionalLinkButton, ui.disamNeededButton, ui.undoButton];
$.each( affectedButtons, function( ii, button ) {
button?.prop( 'disabled', !enabled );
} );
};
/*
* Show or hide the 'no more links' message
* show: Whether to show or hide the message
*/
const toggleFinishedMessage = function( show ) {
toggleActionButtons( !show );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.finishedMessage.toggle( show );
ui.pageTitleLine.toggle( !show );
ui.context.toggle( !show );
if ( show ) console.log(Date.now())
};
const togglePendingEditBox = function( show ) {
if ( pendingEditBox === null ) {
pendingEditBox = $( '<div>' ).addClass( 'disamassist-box disamassist-pendingeditbox' );
pendingEditBoxText = $( '<div>' );
pendingEditBox.append( pendingEditBoxText ).hide();
if ( editLimit ) {
pendingEditBox.append( $( '<div>' ).text( txt.pendingEditBoxLimited )
.addClass( 'disamassist-subtitle' ) );
}
$( '#mw-content-text' ).before( pendingEditBox );
updateEditCounter();
}
if ( show ) {
pendingEditBox.fadeIn();
} else {
pendingEditBox.fadeOut();
}
};
const notifyCompletion = function() {
var oldTitle = document.title;
document.title = txt.notifyCharacter + document.title;
$( document.body ).one( 'mousemove', function() {
document.title = oldTitle;
} );
};
/*
* Update the displayed information to match the current link
* or lack thereof
*/
const updateContext = function() {
updateEditCounter();
if ( !currentLink ) {
toggleFinishedMessage( true );
} else {
ui.pageTitleLine.html( txt.pageTitleLine.replace( '$1',
mw.util.getUrl( currentPageTitle, {redirect: 'no'} ) ).replace( '$2', mw.html.escape( currentPageTitle ) ) );
var context = extractContext( currentPageParameters.content, currentLink );
ui.context.empty()
.append( $( '<span>' ).text( context[0] ) )
.append( $( '<span>' ).text( context[1] ).addClass( 'disamassist-inclink' ) )
.append( $( '<span>' ).text( context[2] ) );
toggleFinishedMessage( false );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.removeLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.intentionalLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.disamNeededButton?.prop( 'disabled', currentPageParameters.redirect || currentLink.hasDisamTemplate );
choosing = true;
}
};
/*
* Update the count of pending changes
*/
const updateEditCounter = function() {
if ( ui.pendingEditCounter ) {
ui.pendingEditCounter.text( txt.pendingEditCounter.replace( '$1', editCount )
.replace( '$2', countActuallyChangedFullyCheckedPages() ) );
}
if ( pendingEditBox ) {
if ( editCount === 0 && !running ) {
togglePendingEditBox( false );
notifyCompletion();
}
var textContent = editCount;
if ( editLimit ) {
textContent = txt.pendingEditBoxTimeEstimation.replace( '$1', editCount )
.replace( '$2', secondsToHHMMSS( cfg.editCooldown * editCount ) );
}
pendingEditBoxText.text( txt.pendingEditBox.replace( '$1', textContent ) );
}
};
/*
* Apply the changes made to an "origin" page
* pageChange: Change that will be saved
*/
const applyChange = function( pageChange ) {
if ( pageChange.page.content !== pageChange.contentBefore[0] ) {
editCount++;
// Filter duplicates and join
var changeSummaries = pageChange.summary
.filter( ( val, i, arr ) => val && arr.indexOf( val ) === i )
.join( txt.summarySeparator );
var summary = txt.summary.replace( '$1', getTargetPage() ).replace( '$2', changeSummaries );
var save = editLimit ? saveWithCooldown : savePage;
save( pageChange.title, pageChange.page, summary, true, true ).always( function() {
if ( editCount > 0 ) {
editCount--;
}
updateEditCounter();
} ).fail( error );
updateEditCounter();
}
};
/*
* Save all the pending changes
*/
const applyAllChanges = function() {
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
applyChange( pageChanges[ii] );
}
pageChanges = [];
};
/*
* Record a new pending change
* pageTitle: Title of the page
* page: Content of the page
* oldContent: Content of the page before the change
* link: Link that has been changed
* summary: Change summary
*/
const addChange = function( pageTitle, page, oldContent, link, summary ) {
if ( ( pageChanges.length === 0 ) || ( pageChanges[pageChanges.length - 1].title !== pageTitle ) ) {
pageChanges.push( {
title: pageTitle,
page: page,
contentBefore: [],
links: [],
summary: []
} );
}
var lastPageChange = pageChanges[pageChanges.length - 1];
lastPageChange.contentBefore.push( oldContent );
lastPageChange.links.push( link );
lastPageChange.summary.push( summary );
};
/*
* Check whether actual changes are stored in the history array
*/
const checkActualChanges = function() {
return countActualChanges() !== 0;
};
/*
* Return the number of entries in the history array that represent actual changes
*/
const countActualChanges = function() {
var changeCount = 0;
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
if ( pageChanges[ii].page.content !== pageChanges[ii].contentBefore[0] ) {
changeCount++;
}
}
return changeCount;
};
/*
* Return the number of changed pages in the history array, ignoring the last entry
* if we aren't done with that page yet
*/
const countActuallyChangedFullyCheckedPages = function() {
var changeCount = countActualChanges();
if ( pageChanges.length !== 0 ) {
var lastChange = pageChanges[pageChanges.length - 1];
if ( lastChange.title === currentPageTitle && currentLink !== null
&& lastChange.page.content !== lastChange.contentBefore[0] ) {
changeCount--;
}
}
return changeCount;
};
/*
* Find the links to disambiguation options in a disambiguation page
*/
const getDisamOptions = function() {
return $( '#mw-content-text a' ).filter( function() {
return !!extractPageName( $( this ) );
} );
};
/*
* Save all the pending changes and close the tool
*/
const saveAndEnd = function() {
applyAllChanges();
end();
};
/*
* Close the tool
*/
const end = function() {
var currentToolUI = ui.display;
choosing = false;
running = false;
$( '.disamassist-optionmarker' ).remove();
currentToolUI.fadeOut( { complete: function() {
currentToolUI.remove();
if ( editCount !== 0 ) {
togglePendingEditBox( true );
}
} } );
};
/*
* Display an error message
*/
const error = function( errorDescription ) {
var errorBox = $( '<div>' ).addClass( 'disamassist-box disamassist-errorbox' );
errorBox.text( txt.error.replace( '$1', errorDescription ) );
errorBox.append( createButton( txt.dismissError, function() {
errorBox.fadeOut();
} ).addClass( 'disamassist-errorbutton' ) );
var uiIsInPlace = ui && $.contains( document.documentElement, ui.display[0] );
var nextElement = uiIsInPlace ? ui.display : $( '#mw-content-text' );
nextElement.before( errorBox );
errorBox.hide().fadeIn();
}
/*
* Change a link so that it points to the title
* text: The wikitext of the whole page
* title: The new destination of the link
* link: The link that will be modified
* extra: Text that will be added after the link (optional)
*/
const replaceLink = function( text, title, link, extra ) {
var anchor = link.anchor || '';
var newContent;
if ( link.type === 'sortnaam' ) {
newContent = '{{sortnaam|' + link.first + '|' + link.last + '|' + title + anchor + '}}';
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + ( extra || '' ) + linkEnd;
}
if ( isSamePage( title, link.description ) ) {
newContent = link.description + anchor;
} else {
newContent = title + anchor + '|' + link.description;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + '[[' + newContent + ']]' + link.afterDescription + ( extra || '' ) + linkEnd;
};
/*
* Remove a link from the text
* text: The wikitext of the whole page
* link: The link that will be removed
*/
const removeLink = function( text, link ) {
var anchor = link.anchor || '';
if ( link.type === 'sortnaam' ) {
var newContent = '{{sortnaam|' + link.first + '|' + link.last + '}}' + anchor;
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + linkEnd;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + link.description + anchor + link.afterDescription + linkEnd;
};
/*
* Extract a link from a string in wiki format,
* starting from a given index. Return a link if one can be found,
* otherwise return null. The "link" includes "disambiguation needed"
* templates inmediately following the link proper
* text: Text from which the link will be extracted
* lastIndex: Index from which the search will start
*/
const extractLink = function( text, lastIndex ) {
// 1. Zoek naar gewone wikitlinks (inclusief optioneel anker #...)
titleRegex.lastIndex = lastIndex;
var wikiMatch = titleRegex.exec( text );
// 2. Zoek naar sortnaam-templates (vangt de derde parameter inclusief eventueel anker op)
var sortnaamRegex = new RegExp('{{\\s*sortnaam\\s*\\|\\s*([^|]+?)\\s*\\|\\s*([^|]+?)\\s*(?:\\|\\s*([^}]*?)\\s*)?}}', 'gi');
sortnaamRegex.lastIndex = lastIndex;
var sortnaamMatch = sortnaamRegex.exec( text );
var matchType = null;
var matchIndex = Infinity;
if ( wikiMatch !== null && wikiMatch.index >= lastIndex ) {
matchIndex = wikiMatch.index;
matchType = 'wiki';
}
if ( sortnaamMatch !== null && sortnaamMatch.index >= lastIndex && sortnaamMatch.index < matchIndex ) {
matchIndex = sortnaamMatch.index;
matchType = 'sortnaam';
}
if ( matchType === 'wiki' ) {
var possiblyAmbiguous = true;
var hasDisamTemplate = false;
var fullMatchStr = wikiMatch[0];
var anchor = wikiMatch[2] ? wikiMatch[2] : '';
var end = wikiMatch.index + fullMatchStr.length;
var afterDescription = '';
var rest = text.substring( end );
var templateRegex = /^(\w*[.,:;?!)}\s]*){{\s*([^|{}]+?)\s*(?:\|[^{]*?)?}}/g;
var templateMatch = templateRegex.exec( rest );
if ( templateMatch !== null ) {
var templateTitle = getCanonicalTitle( templateMatch[2] );
if ( cfg.disamLinkTemplates?.includes( templateTitle ) ) {
end += templateMatch[0].length;
afterDescription = templateMatch[1].replace(/\s$/, '');
hasDisamTemplate = true;
} else if ( cfg.disamLinkIgnoreTemplates?.includes( templateTitle ) ) {
possiblyAmbiguous = false;
}
}
return {
type: 'wiki',
start: wikiMatch.index,
end: end,
possiblyAmbiguous: possiblyAmbiguous,
hasDisamTemplate: hasDisamTemplate,
title: wikiMatch[1],
description: wikiMatch[3] ? wikiMatch[3] : wikiMatch[1],
anchor: anchor,
afterDescription: afterDescription
};
} else if ( matchType === 'sortnaam' ) {
var first = sortnaamMatch[1].trim();
var last = sortnaamMatch[2].trim();
var rawParam3 = sortnaamMatch[3] ? sortnaamMatch[3].trim() : '';
var explicitTarget = rawParam3;
var anchor = '';
var hashIndex = rawParam3.indexOf( '#' );
if ( hashIndex !== -1 ) {
explicitTarget = rawParam3.substring( 0, hashIndex ).trim();
anchor = rawParam3.substring( hashIndex );
}
var target = explicitTarget !== '' ? explicitTarget : ( first + ' ' + last );
return {
type: 'sortnaam',
start: sortnaamMatch.index,
end: sortnaamMatch.index + sortnaamMatch[0].length,
possiblyAmbiguous: true,
hasDisamTemplate: false,
title: target,
description: sortnaamMatch[0],
first: first,
last: last,
explicitTarget: explicitTarget,
anchor: anchor,
afterDescription: ''
};
}
return null;
};
/*
* Extract a link to one of a number of destination pages from a string
* ("text") in wiki format, starting from a given index ("lastIndex").
* "Disambiguation needed" templates are included as part of the links.
* text: Page in wiki format
* destinations: Array of page titles to look for
* lastIndex: Index from which the search will start
*/
const extractLinkToPage = function( text, destinations, lastIndex ) {
var link, title;
do {
link = extractLink( text, lastIndex );
if ( link !== null ) {
lastIndex = link.end;
title = getCanonicalTitle( link.title );
}
} while (
link !== null
&& ( !link.possiblyAmbiguous || !destinations?.includes( title ) )
);
return link;
};
/*
* Find the "target" page: either the one we are in or the "main" one found by extracting
* the title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const getTargetPage = function() {
var title = getTitle();
return forceSamePage ? title : removeDisam( title );
};
/*
* Get the page title, with the namespace prefix if any.
*/
const getTitle = function() {
return mw.config.get( 'wgPageName' ).replace(/_/g, ' ')
}
/*
* Extract a "main" title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const removeDisam = function( title ) {
var match = new RegExp( cfg.disamRegExp ).exec( title );
return match ? match[1] : title;
};
/*
* Check whether two page titles are the same
*/
const isSamePage = function( title1, title2 ) {
return getCanonicalTitle( title1 ) === getCanonicalTitle( title2 );
};
/*
* Return the 'canonical title' of a page
*/
const getCanonicalTitle = function( title ) {
try {
title = new mw.Title( title );
// Check that page name matches the current namespace
if ( title.getNamespaceId() !== mw.config.get( 'wgNamespaceNumber' ) ) {
return;
}
const fragment = title.getFragment();
return title.getPrefixedText() + ( fragment ? `#${ fragment }` : '' );
} catch ( ex ) {
// mw.Title seems to be buggy, and some valid titles are rejected
// FIXME: This may cause false negatives
}
};
/*
* Extract the context around a given link in a text string
*/
const extractContext = function( text, link ) {
var contextStart = link.start - cfg.radius;
var contextEnd = link.end + cfg.radius;
var contextPrev = text.substring( contextStart, link.start );
const ellipsis = '…';
if ( contextStart > 0 ) {
contextPrev = ellipsis + contextPrev;
}
var contextNext = text.substring( link.end, contextEnd );
if ( contextEnd < text.length ) {
contextNext = contextNext + ellipsis;
}
return [contextPrev, text.substring( link.start, link.end ), contextNext];
};
/*
* Extract the prefixed page name from a link
*/
const extractPageName = function( link ) {
var pageName = extractPageNameRaw( link );
if ( pageName ) {
return getCanonicalTitle( pageName );
}
return null;
};
/*
* Extract the page name from a link, as is
*/
const extractPageNameRaw = function( link ) {
// Ignore images and interwiki links
if ( link.hasClass( 'image' ) || link.hasClass( 'extiw' ) ) {
return null;
}
var href = link.attr( 'href' );
// Red links in old parser format
if ( link.hasClass( 'new' ) ) {
const oldRedLink = mw.util.getParamValue( 'title', href );
if ( oldRedLink !== null ) {
return oldRedLink;
}
}
// Check that external links are coming from this wiki
if ( href.indexOf( '//' ) === 0 || href.indexOf( 'https://' ) === 0 ) {
try {
const fullUrl = href.indexOf( '//' ) === 0 ? location.protocol + href : href;
let urlData = new URL( fullUrl );
if ( urlData.hostname !== location.hostname ) {
return null;
}
} catch( e ) {
return null;
}
}
var articlePathRegexp = new RegExp(
mw.config.get( 'wgArticlePath' ).replace( '$1', '([^?]*)' )
);
var result = articlePathRegexp.exec( href );
if ( Array.isArray( result ) && result.length > 1 ) {
return decodeURIComponent( result[ 1 ] );
}
return null;
};
/*
* Check whether this is a disambiguation page
*/
const isDisam = function() {
const categories = mw.config.get( 'wgCategories', [] );
return cfg.disamCategories.some( c => categories.includes( c ) );
};
const secondsToHHMMSS = function( totalSeconds ) {
var hhmmss = '';
var hours = Math.floor( totalSeconds / 3600 );
var minutes = Math.floor( totalSeconds % 3600 / 60 );
var seconds = Math.floor( totalSeconds % 3600 % 60 );
if ( hours >= 1 ) {
hhmmss = pad( hours, '0', 2 ) + ':';
}
hhmmss += pad( minutes, '0', 2 ) + ':' + pad( seconds, '0', 2 );
return hhmmss;
};
const pad = function( str, z, width ) {
str = str.toString();
if ( str.length >= width ) {
return str;
} else {
return new Array( width - str.length + 1 ).join( z ) + str;
}
}
/*
* Create a new button
* text: Text that will be displayed on the button
* onClick: Function that will be called when the button is clicked
*/
const createButton = function( text, onClick ) {
var button = $( '<button>', { 'type': 'button' } );
button.text( text );
button.addClass( 'disamassist-button' ).click( onClick );
return button;
};
/*
* Given a page title and an array of possible redirects {from, to} ("canonical titles"), find the page
* at the end of the redirect chain, if there is one. Otherwise, return the page title that was passed
*/
const resolveRedirect = function( pageTitle, possibleRedirects ) {
var appliedRedirect = true;
var visitedPages = {};
var currentPage = getCanonicalTitle( pageTitle );
while ( appliedRedirect ) {
appliedRedirect = false;
for ( var ii = 0; ii < possibleRedirects.length; ii++ ) {
if ( possibleRedirects[ii].from === currentPage ) {
if ( visitedPages[possibleRedirects[ii].to] ) {
// Redirect chain detected
return pageTitle;
}
visitedPages[currentPage] = true;
appliedRedirect = true;
currentPage = possibleRedirects[ii].to;
}
}
}
// No redirect rules applied for an iteration of the outer loop:
// no more redirects. We are done
return currentPage;
};
/*
* Fetch the incoming links to a page. Returns a jQuery promise
* (success - array of titles of pages that contain links to the target page and
* array of "canonical titles" of possible destinations of the backlinks (either
* the target page or redirects to the target page), failure - error description)
* page: Target page
*/
const getBacklinks = function( page ) {
var dfd = new $.Deferred();
api.get( {
'action': 'query',
'list': 'backlinks',
'bltitle': page,
'blredirect': true,
'bllimit': cfg.backlinkLimit,
'blnamespace': cfg.targetNamespaces,
} ).done( function( data ) {
// There might be duplicate entries in some corner cases. We don't care,
// since we are going to check later, anyway
var backlinks = [];
var linkTitles = [getCanonicalTitle( page )];
$.each( data.query.backlinks, function() {
backlinks.push( this.title );
if ( this.redirlinks ) {
linkTitles.push( this.title );
$.each( this.redirlinks, function() {
backlinks.push( this.title );
} );
}
} );
dfd.resolve( backlinks, linkTitles );
} ).fail( function( code, data ) {
dfd.reject( txt.getBacklinksError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download a list of redirects for some pages. Returns a jQuery callback (success -
* array of redirects ({from, to}), failure - error description )
* pageTitles: Array of page titles
*/
const fetchRedirects = function( pageTitles ) {
var dfd = new $.Deferred();
var currentTitles = pageTitles.slice( 0, cfg.queryTitleLimit );
var restTitles = pageTitles.slice( cfg.queryTitleLimit );
api.get( {
action: 'query',
titles: currentTitles,
redirects: true,
} ).done( function( data ) {
var theseRedirects = data.query.redirects ? data.query.redirects : [];
if ( restTitles.length !== 0 ) {
fetchRedirects( restTitles ).done( function( redirects ) {
dfd.resolve( theseRedirects.concat( redirects ) );
} ).fail( function( description ) {
dfd.reject( description );
} );
} else {
dfd.resolve( theseRedirects );
}
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRedirectsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download the list of user rights for the current user. Returns a
* jQuery promise (success - array of right names, error - error description)
*/
const fetchRights = function() {
var dfd = $.Deferred();
api.get( {
action: 'query',
meta: 'userinfo',
uiprop: 'rights',
} ).done( function( data ) {
dfd.resolve( data.query.userinfo.rights );
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRightsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Load the raw page text for a given title. Returns a jQuery promise (success - page
* content, failure - error description)
* pageTitle: Title of the page
*/
const loadPage = function( pageTitle ) {
var dfd = new $.Deferred();
api.get( {
action: 'query',
titles: pageTitle,
prop: 'revisions',
rvprop: 'timestamp|content',
meta: 'tokens',
type: 'csrf',
} ).done( function( data ) {
var pages = data.query.pages;
for ( var key in pages ) {
if ( pages.hasOwnProperty( key ) ) {
break;
}
}
var rawPage = data.query.pages[key];
var page = {};
page.missing = rawPage.missing !== undefined;
if ( rawPage.revisions ) {
page.content = rawPage.revisions[0]['*'];
// Assume anything with # at the start is a redirect
if ( page.content.match( /^#[^\s]*?\s*\[\[/ ) ) {
page.redirect = true;
}
page.baseTimeStamp = rawPage.revisions[0].timestamp;
} else {
page.content = '';
page.baseTimeStamp = null;
}
page.startTimeStamp = rawPage.starttimestamp;
page.editToken = data.query.tokens.csrftoken;
dfd.resolve( page );
} ).fail( function( code, data ) {
dfd.reject( txt.loadPageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
/*
* Register changes to a page, to be saved later. Returns a jQuery promise
* (success - no params, failure - error description). Takes the same parameters
* as savePage
*/
const saveWithCooldown = function() {
var deferred = new $.Deferred();
pendingSaves.push( {args: arguments, dfd: deferred} );
if ( !runningSaves ) {
checkAndSave();
}
return deferred.promise();
};
/*
* Save the first set of changes in the list of pending changes, providing that
* enough time has passed since the last edit
*/
const checkAndSave = function() {
if ( pendingSaves.length === 0 ) {
runningSaves = false;
return;
}
runningSaves = true;
var millisSinceLast = new Date().getTime() - lastEditMillis;
if ( millisSinceLast < cfg.editCooldown * 1000 ) {
setTimeout( checkAndSave, cfg.editCooldown * 1000 - millisSinceLast );
} else {
// The last edit started at least cfg.editCooldown seconds ago
var save = pendingSaves.shift();
savePage.apply( this, save.args ).done( function() {
checkAndSave();
save.dfd.resolve();
} ).fail( function( description ) {
checkAndSave();
save.dfd.reject( description );
} );
// We'll use the time since the last edit started
lastEditMillis = new Date().getTime();
}
};
/*
* Save the changes made to a page. Returns a jQuery promise (success - no params,
* failure - error description)
* pageTitle: Title of the page
* page: Page data
* summary: Summary of the changes made to the page
* minorEdit: Whether to mark the edit as 'minor'
* botEdit: Whether to mark the edit as 'bot'
*/
const savePage = function( pageTitle, page, summary, minorEdit, botEdit ) {
var dfd = new $.Deferred();
api.post( {
action: 'edit',
title: pageTitle,
token: page.editToken,
text: page.content,
basetimestamp: page.baseTimeStamp,
starttimestamp: page.startTimeStamp,
summary: summary,
watchlist: cfg.watch ? cfg.watch : 'nochange',
assertuser: mw.config.get( 'wgUserName' ),
minor: minorEdit,
bot: botEdit,
} ).done( function() {
dfd.resolve();
} ).fail( function( code, data ) {
dfd.reject( txt.savePageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
install();
} )();
// </nowiki>
2e1cyze6meb7k5po90v2skuskqh3fzz
781191
781189
2026-10-27T16:00:33Z
Rots61
44293
781191
javascript
text/javascript
/*
* DisamAssist: a tool for repairing links from articles to disambiguation pages.
*/
// <nowiki>
// @ts-nocheck
( () => {
var cfg = {};
var txt = {};
var ui;
var links, pageChanges;
var currentPageTitle, currentPageParameters, currentLink;
var possibleBacklinkDestinations;
var titleRegex;
var forceSamePage = false;
var running = false;
var choosing = false;
var canMarkIntentionalLinks = false;
var displayedPages = {};
var editCount = 0;
var editLimit;
var pendingSaves = [];
var pendingEditBox = null;
var pendingEditBoxText;
var lastEditMillis = 0;
var runningSaves = false;
var api;
const dependencies = [
'mediawiki.api',
'mediawiki.confirmCloseWindow',
'mediawiki.Title',
'mediawiki.util',
];
/**
* Add an item in Tools menu
* @param {string} text
* @param {string} id
* @param {(e: MouseEvent) => void} callback
*/
const disamPortletLink = ( text, id, callback ) => {
const portlet = mw.util.addPortletLink(
'p-tb',
cfg.helpPage ? mw.util.getUrl( cfg.helpPage ) : '#',
text,
id
);
const link = portlet?.querySelector( 'a' );
link?.addEventListener( 'click', callback );
addButtonEvents( link );
}
/*
* Entry point. Check whether we are in a disambiguation page. If so, add a link to start the tool
*/
const install = function() {
cfg = window.DisamAssist.cfg;
txt = window.DisamAssist.txt;
if ( mw.config.get( 'wgAction' ) === 'view' && isDisam() ) {
mw.loader.using( dependencies, () => {
api = new mw.Api();
mw.loader.load( 'https://ru.wikipedia.org/w/index.php?title=Участник:Stjn/DisamAssist.css&action=raw&ctype=text/css', 'text/css' );
// This is a " (disambiguation)" page
if ( new RegExp( cfg.disamRegExp ).exec( getTitle() ) ) {
disamPortletLink( txt.startMain, 't-disamAssist-main', startMain );
disamPortletLink( txt.startSame, 't-disamAssist-same', startSame );
} else {
disamPortletLink( txt.start, 't-disamAssist-page', start );
}
} );
}
};
/*
* Start the tool. Display the UI and begin looking for links to fix
*/
const start = function( e ) {
e?.preventDefault();
if ( !running ) {
console.log(Date.now())
running = true;
links = [];
pageChanges = [];
displayedPages = {};
ensureDABExists().then( function( canMark ) {
canMarkIntentionalLinks = canMark;
createUI();
addUnloadConfirm();
markDisamOptions();
checkEditLimit().then( function() {
togglePendingEditBox( false );
doPage();
} );
} );
}
};
/*
* Start DisamAssist. Disambiguate incoming links to the current page, regardless
* of the title.
*/
const startSame = function( e ) {
e.preventDefault();
forceSamePage = true;
start( e );
};
/*
* Start DisamAssist. If the page title ends with " (disambiguation)", disambiguate
* links to the primary topic article. Otherwise, disambiguate links to the current
* page.
*/
const startMain = function( e ) {
e.preventDefault();
forceSamePage = false;
start( e );
};
/*
* Create and show the user interface.
*/
const createUI = function() {
ui = {
display: $( '<div>' ).addClass( 'disamassist-box disamassist-mainbox' ),
finishedMessage: $( '<div>' ).text( txt.noMoreLinks ).hide(),
pageTitleLine: $( '<span>' ).addClass( 'disamassist-pagetitleline' ),
pendingEditCounter: $( '<div>').addClass( 'disamassist-editcounter' ),
context: $( '<pre>' ).addClass( 'disamassist-context' ),
undoButton: createButton( txt.undo, undo ),
omitButton: createButton( txt.omit, omit ),
endButton: createButton( txt.close, saveAndEnd ),
refreshButton: createButton( txt.refresh, refresh ),
titleAsTextButton: createButton( txt.titleAsText, chooseTitleFromPrompt ),
intentionalLinkButton: canMarkIntentionalLinks ? createButton( txt.intentionalLink, chooseIntentionalLink ) : null,
disamNeededButton: cfg.disamNeededText ? createButton( txt.disamNeeded, chooseDisamNeeded ) : null,
removeLinkButton: createButton( txt.removeLink, chooseLinkRemoval )
};
var top = $( '<div>' ).addClass( 'disamassist-top' )
.append( [ui.pageTitleLine, ui.pendingEditCounter] );
var leftButtons = $( '<div>' ).addClass( 'disamassist-leftbuttons' )
.append( [ui.titleAsTextButton, ui.removeLinkButton, ui.intentionalLinkButton, ui.disamNeededButton, ui.omitButton] );
var rightButtons = $( '<div>' ).addClass( 'disamassist-rightbuttons' )
.append( [ui.undoButton, ui.refreshButton, ui.endButton] );
var allButtons = $( '<div>' ).addClass( 'disamassist-allbuttons' )
.append( [leftButtons, rightButtons] );
ui.display.append( [top, ui.finishedMessage, ui.context, allButtons] );
updateEditCounter();
toggleActionButtons( false );
// Insert the UI in the page
$( '#mw-content-text' ).before( ui.display );
ui.display.hide().fadeIn();
};
/**
* Add button role and related keyboard event
* @param {Element} el
*/
const addButtonEvents = ( el ) => {
el?.setAttribute( 'role', 'button' );
el?.addEventListener( 'keydown', function( e ) {
if ( [ 'Enter', 'Space' ].includes( e.code ) ) {
e.preventDefault();
this.click();
}
} );
}
/*
* If there are pending changes, show a confirm dialog before closing
*/
const addUnloadConfirm = function() {
mw.confirmCloseWindow( {
test: () => {
if ( running && checkActualChanges() ) {
return txt.pending;
} else if ( editCount !== 0 ) {
return txt.editInProgress;
}
}
} );
};
/*
* Mark the disambiguation options as such
*/
const markDisamOptions = function() {
var optionPageTitles = [];
var optionMarkers = [];
getDisamOptions().each( function() {
var link = $( this );
var title = extractPageName( link );
const optionMarker = document.createElement( 'a' );
optionMarker.classList.add( 'disamassist-optionmarker' );
optionMarker.href = link.attr( 'href' );
optionMarker.setAttribute( 'data-title', title );
optionMarker.textContent = txt.optionMarker;
optionMarker.addEventListener( 'click', ( e ) => {
e.preventDefault();
chooseReplacement( title );
} );
addButtonEvents( optionMarker );
link.after( optionMarker );
optionMarkers.push( optionMarker );
optionPageTitles.push( title );
} );
// Now check the disambiguation options and display a different message for those that are
// actually the same as the target page where the links go, as choosing those options doesn't really
// accomplish anything (except bypassing redirects, which might be useful in some cases)
var targetPage = getTargetPage();
fetchRedirects( optionPageTitles.concat( targetPage ) ).done( function( redirects ) {
var endTargetPage = resolveRedirect( targetPage, redirects );
for ( var ii = 0; ii < optionPageTitles.length; ii++ ) {
var endOptionTitle = resolveRedirect( optionPageTitles[ii], redirects );
if ( isSamePage( optionPageTitles[ii], targetPage ) ) {
$( optionMarkers[ii] ).text( txt.targetOptionMarker ).addClass( 'disamassist-curroptionmarker');
} else if ( isSamePage( endOptionTitle, endTargetPage ) ) {
$( optionMarkers[ii] ).text( txt.redirectOptionMarker ).addClass( 'disamassist-curroptionmarker');
}
}
} ).fail( error );
};
/*
* Check whether intentional links to disambiguation pages can be explicitly marked
* as such in this wiki. If so, ensure that a "Foo (disambiguation)" page exists.
* Returns a jQuery promise
*/
const ensureDABExists = function() {
var dfd = new $.Deferred();
var title = getTitle();
// That concept doesn't exist in this wiki.
if ( !cfg.intentionalLinkOption ) {
dfd.resolve( false );
// "Foo (disambiguation)" exists: it's the current page.
} else if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
dfd.resolve( true );
} else {
var disamTitle = cfg.disamFormat.replace( '$1', title );
loadPage( disamTitle ).done( function( page ) {
// "Foo (disambiguation)" doesn't exist.
if ( page.missing ) {
// We try to create it
page.content = cfg.redirectToDisam.replace( '$1', title );
var summary = txt.redirectSummary.replace( '$1', title );
savePage( disamTitle, page, summary, false, true ).done( function() {
dfd.resolve( true );
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
// It does exist
} else {
dfd.resolve( true );
}
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
}
return dfd.promise();
};
/*
* Check whether the edit cooldown applies and sets editLimit accordingly.
* Returns a jQuery promise
*/
const checkEditLimit = function() {
var dfd = new $.Deferred();
if ( cfg.editCooldown <= 0 ) {
editLimit = false;
dfd.resolve();
} else {
fetchRights().done( function( rights ) {
editLimit = !rights?.includes( 'bot' );
} ).fail( function( description ) {
error( description );
editLimit = true;
} ).always( function() {
dfd.resolve();
} );
}
return dfd.promise();
};
/*
* Find and ask the user to fix all the incoming links to the disambiguation ("target")
* page from a single "origin" page
*/
const doPage = function() {
if ( pageChanges.length > cfg.historySize ) {
applyChange( pageChanges.shift() );
}
if ( links.length === 0 ) {
var targetPage = getTargetPage();
getBacklinks( targetPage ).done( function( backlinks, pageTitles ) {
var pending = {};
$.each( pendingSaves, function() {
pending[this[0]] = true;
} );
possibleBacklinkDestinations = $.grep( pageTitles, function( t, ii) {
if ( t == targetPage ) {
return true;
}
return removeDisam(t) != targetPage;
} );
titleRegex = new RegExp('\\[\\[([ _]*?(?:' + possibleBacklinkDestinations.join('|').replaceAll('(', '\\(').replaceAll(')','\\)').replaceAll(' ', '[ _]') + ')[ _]*?)(#.*?)?(?:\\|(.*?))?\\]\\]', 'gi');
// Only incoming links from pages we haven't seen yet and we aren't currently
// saving (displayedPages is reset when the tool is closed and opened again,
// while the list of pending changes isn't; if the edit cooldown is disabled,
// it will be empty)
links = $.grep( backlinks, function( el, ii ) {
return !displayedPages[el] && !pending[el];
} );
if ( links.length === 0 ) {
updateContext();
} else {
doPage();
}
} ).fail( error );
} else {
currentPageTitle = links.shift();
displayedPages[currentPageTitle] = true;
toggleActionButtons( false );
loadPage( currentPageTitle ).done( function( data ) {
currentPageParameters = data;
currentLink = null;
doLink();
} ).fail( error );
}
};
/*
* Find and ask the user to fix a single incoming link to the disambiguation ("target")
* page
*/
const doLink = function() {
if (!currentPageParameters.redirect) {
currentLink = extractLinkToPage( currentPageParameters.content,
possibleBacklinkDestinations, currentLink ? currentLink.end : 0 );
if ( currentLink ) {
updateContext();
} else {
doPage();
}
} else {
doPage();
}
};
/*
* Replace the target of a link with a different one
* pageTitle: New link target
* extra: Additional text after the link (optional)
* summary: Change summary (optional)
*/
const chooseReplacement = function( pageTitle, extra, summary ) {
if ( choosing ) {
choosing = false;
if ( !summary ) {
if ( pageTitle ) {
summary = txt.summaryChanged.replace( '$1', pageTitle );
} else {
summary = txt.summaryOmitted;
}
}
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
if ( pageTitle && ( pageTitle !== getTargetPage() || extra ) ) {
if ( currentPageParameters.redirect ) {
currentLink.description = pageTitle;
}
currentPageParameters.content =
replaceLink( currentPageParameters.content, pageTitle, currentLink, extra || '' );
}
doLink();
}
};
/*
* Replace the link with an explicit link to the disambiguation page
*/
const chooseIntentionalLink = function() {
const targetPage = getTargetPage();
let title = cfg.disamFormat.replace( '$1', targetPage );
if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
title = targetPage;
}
chooseReplacement( title, '', txt.summaryIntentional );
};
/*
* Prompt for an alternative link target and use it as a replacement
*/
const chooseTitleFromPrompt = function() {
var title = prompt( txt.titleAsTextPrompt );
if ( title !== null ) {
chooseReplacement( title );
}
};
/*
* Remove the current link, leaving the text unchanged
*/
const chooseLinkRemoval = function() {
if ( choosing ) {
var summary = txt.summaryRemoved;
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
currentPageParameters.content = removeLink( currentPageParameters.content, currentLink );
doLink();
}
};
/*
* Add a "disambiguation needed" template after the link
*/
const chooseDisamNeeded = function() {
chooseReplacement( currentLink.title, cfg.disamNeededText, txt.summaryHelpNeeded );
};
/*
* Undo the last change
*/
const undo = function() {
if ( pageChanges.length !== 0 ) {
var lastPage = pageChanges[pageChanges.length - 1];
if ( currentPageTitle !== lastPage.title ) {
links.unshift( currentPageTitle );
currentPageTitle = lastPage.title;
}
currentPageParameters = lastPage.page;
currentPageParameters.content = lastPage.contentBefore.pop();
currentLink = lastPage.links.pop();
lastPage.summary.pop();
if ( lastPage.contentBefore.length === 0 ) {
pageChanges.pop();
}
updateContext();
}
};
/*
* Omit the current link without making a change
*/
const omit = function() {
chooseReplacement( null );
};
/*
* Save all the pending changes and restart the tool.
*/
const refresh = function() {
saveAndEnd();
start();
};
/*
* Enable or disable the buttons that can perform actions on a page or change the current link.
* enabled: Whether to enable or disable the buttons
*/
const toggleActionButtons = function( enabled ) {
var affectedButtons = [ui.omitButton, ui.titleAsTextButton, ui.removeLinkButton,
ui.intentionalLinkButton, ui.disamNeededButton, ui.undoButton];
$.each( affectedButtons, function( ii, button ) {
button?.prop( 'disabled', !enabled );
} );
};
/*
* Show or hide the 'no more links' message
* show: Whether to show or hide the message
*/
const toggleFinishedMessage = function( show ) {
toggleActionButtons( !show );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.finishedMessage.toggle( show );
ui.pageTitleLine.toggle( !show );
ui.context.toggle( !show );
if ( show ) console.log(Date.now())
};
const togglePendingEditBox = function( show ) {
if ( pendingEditBox === null ) {
pendingEditBox = $( '<div>' ).addClass( 'disamassist-box disamassist-pendingeditbox' );
pendingEditBoxText = $( '<div>' );
pendingEditBox.append( pendingEditBoxText ).hide();
if ( editLimit ) {
pendingEditBox.append( $( '<div>' ).text( txt.pendingEditBoxLimited )
.addClass( 'disamassist-subtitle' ) );
}
$( '#mw-content-text' ).before( pendingEditBox );
updateEditCounter();
}
if ( show ) {
pendingEditBox.fadeIn();
} else {
pendingEditBox.fadeOut();
}
};
const notifyCompletion = function() {
var oldTitle = document.title;
document.title = txt.notifyCharacter + document.title;
$( document.body ).one( 'mousemove', function() {
document.title = oldTitle;
} );
};
/*
* Update the displayed information to match the current link
* or lack thereof
*/
const updateContext = function() {
updateEditCounter();
if ( !currentLink ) {
toggleFinishedMessage( true );
} else {
ui.pageTitleLine.html( txt.pageTitleLine.replace( '$1',
mw.util.getUrl( currentPageTitle, {redirect: 'no'} ) ).replace( '$2', mw.html.escape( currentPageTitle ) ) );
var context = extractContext( currentPageParameters.content, currentLink );
ui.context.empty()
.append( $( '<span>' ).text( context[0] ) )
.append( $( '<span>' ).text( context[1] ).addClass( 'disamassist-inclink' ) )
.append( $( '<span>' ).text( context[2] ) );
toggleFinishedMessage( false );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.removeLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.intentionalLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.disamNeededButton?.prop( 'disabled', currentPageParameters.redirect || currentLink.hasDisamTemplate );
choosing = true;
}
};
/*
* Update the count of pending changes
*/
const updateEditCounter = function() {
if ( ui.pendingEditCounter ) {
ui.pendingEditCounter.text( txt.pendingEditCounter.replace( '$1', editCount )
.replace( '$2', countActuallyChangedFullyCheckedPages() ) );
}
if ( pendingEditBox ) {
if ( editCount === 0 && !running ) {
togglePendingEditBox( false );
notifyCompletion();
}
var textContent = editCount;
if ( editLimit ) {
textContent = txt.pendingEditBoxTimeEstimation.replace( '$1', editCount )
.replace( '$2', secondsToHHMMSS( cfg.editCooldown * editCount ) );
}
pendingEditBoxText.text( txt.pendingEditBox.replace( '$1', textContent ) );
}
};
/*
* Apply the changes made to an "origin" page
* pageChange: Change that will be saved
*/
const applyChange = function( pageChange ) {
if ( pageChange.page.content !== pageChange.contentBefore[0] ) {
editCount++;
// Filter duplicates and join
var changeSummaries = pageChange.summary
.filter( ( val, i, arr ) => val && arr.indexOf( val ) === i )
.join( txt.summarySeparator );
var summary = txt.summary.replace( '$1', getTargetPage() ).replace( '$2', changeSummaries );
var save = editLimit ? saveWithCooldown : savePage;
save( pageChange.title, pageChange.page, summary, true, true ).always( function() {
if ( editCount > 0 ) {
editCount--;
}
updateEditCounter();
} ).fail( error );
updateEditCounter();
}
};
/*
* Save all the pending changes
*/
const applyAllChanges = function() {
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
applyChange( pageChanges[ii] );
}
pageChanges = [];
};
/*
* Record a new pending change
* pageTitle: Title of the page
* page: Content of the page
* oldContent: Content of the page before the change
* link: Link that has been changed
* summary: Change summary
*/
const addChange = function( pageTitle, page, oldContent, link, summary ) {
if ( ( pageChanges.length === 0 ) || ( pageChanges[pageChanges.length - 1].title !== pageTitle ) ) {
pageChanges.push( {
title: pageTitle,
page: page,
contentBefore: [],
links: [],
summary: []
} );
}
var lastPageChange = pageChanges[pageChanges.length - 1];
lastPageChange.contentBefore.push( oldContent );
lastPageChange.links.push( link );
lastPageChange.summary.push( summary );
};
/*
* Check whether actual changes are stored in the history array
*/
const checkActualChanges = function() {
return countActualChanges() !== 0;
};
/*
* Return the number of entries in the history array that represent actual changes
*/
const countActualChanges = function() {
var changeCount = 0;
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
if ( pageChanges[ii].page.content !== pageChanges[ii].contentBefore[0] ) {
changeCount++;
}
}
return changeCount;
};
/*
* Return the number of changed pages in the history array, ignoring the last entry
* if we aren't done with that page yet
*/
const countActuallyChangedFullyCheckedPages = function() {
var changeCount = countActualChanges();
if ( pageChanges.length !== 0 ) {
var lastChange = pageChanges[pageChanges.length - 1];
if ( lastChange.title === currentPageTitle && currentLink !== null
&& lastChange.page.content !== lastChange.contentBefore[0] ) {
changeCount--;
}
}
return changeCount;
};
/*
* Find the links to disambiguation options in a disambiguation page
*/
const getDisamOptions = function() {
return $( '#mw-content-text a' ).filter( function() {
return !!extractPageName( $( this ) );
} );
};
/*
* Save all the pending changes and close the tool
*/
const saveAndEnd = function() {
applyAllChanges();
end();
};
/*
* Close the tool
*/
const end = function() {
var currentToolUI = ui.display;
choosing = false;
running = false;
$( '.disamassist-optionmarker' ).remove();
currentToolUI.fadeOut( { complete: function() {
currentToolUI.remove();
if ( editCount !== 0 ) {
togglePendingEditBox( true );
}
} } );
};
/*
* Display an error message
*/
const error = function( errorDescription ) {
var errorBox = $( '<div>' ).addClass( 'disamassist-box disamassist-errorbox' );
errorBox.text( txt.error.replace( '$1', errorDescription ) );
errorBox.append( createButton( txt.dismissError, function() {
errorBox.fadeOut();
} ).addClass( 'disamassist-errorbutton' ) );
var uiIsInPlace = ui && $.contains( document.documentElement, ui.display[0] );
var nextElement = uiIsInPlace ? ui.display : $( '#mw-content-text' );
nextElement.before( errorBox );
errorBox.hide().fadeIn();
}
/*
* Change a link so that it points to the title
* text: The wikitext of the whole page
* title: The new destination of the link
* link: The link that will be modified
* extra: Text that will be added after the link (optional)
*/
const replaceLink = function( text, title, link, extra ) {
var anchor = link.anchor || '';
var newContent;
if ( link.type === 'sortnaam' ) {
newContent = '{{sortnaam|' + link.first + '|' + link.last + '|' + title + anchor + '}}';
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + ( extra || '' ) + linkEnd;
}
if ( !link.hasExplicitDescription ) {
// Geen pipe aanwezig (bijv. [[appel#gezondheid]]), behoud schone linkvorm
newContent = title + anchor;
} else if ( isSamePage( title, link.description ) ) {
newContent = link.description + anchor;
} else {
newContent = title + anchor + '|' + link.description;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + '[[' + newContent + ']]' + link.afterDescription + ( extra || '' ) + linkEnd;
};
/*
* Remove a link from the text
* text: The wikitext of the whole page
* link: The link that will be removed
*/
const removeLink = function( text, link ) {
var anchor = link.anchor || '';
if ( link.type === 'sortnaam' ) {
var newContent = '{{sortnaam|' + link.first + '|' + link.last + '}}' + anchor;
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + linkEnd;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + link.description + anchor + link.afterDescription + linkEnd;
};
/*
* Extract a link from a string in wiki format,
* starting from a given index. Return a link if one can be found,
* otherwise return null. The "link" includes "disambiguation needed"
* templates inmediately following the link proper
* text: Text from which the link will be extracted
* lastIndex: Index from which the search will start
*/
const extractLink = function( text, lastIndex ) {
// 1. Zoek naar gewone wikitlinks (inclusief optioneel anker #...)
titleRegex.lastIndex = lastIndex;
var wikiMatch = titleRegex.exec( text );
// 2. Zoek naar sortnaam-templates
var sortnaamRegex = new RegExp('{{\\s*sortnaam\\s*\\|\\s*([^|]+?)\\s*\\|\\s*([^|]+?)\\s*(?:\\|\\s*([^}]*?)\\s*)?}}', 'gi');
sortnaamRegex.lastIndex = lastIndex;
var sortnaamMatch = sortnaamRegex.exec( text );
var matchType = null;
var matchIndex = Infinity;
if ( wikiMatch !== null && wikiMatch.index >= lastIndex ) {
matchIndex = wikiMatch.index;
matchType = 'wiki';
}
if ( sortnaamMatch !== null && sortnaamMatch.index >= lastIndex && sortnaamMatch.index < matchIndex ) {
matchIndex = sortnaamMatch.index;
matchType = 'sortnaam';
}
if ( matchType === 'wiki' ) {
var possiblyAmbiguous = true;
var hasDisamTemplate = false;
var title = wikiMatch[1];
var anchor = wikiMatch[2] ? wikiMatch[2] : '';
var hasExplicitDescription = wikiMatch[3] !== undefined;
var description = hasExplicitDescription ? wikiMatch[3] : title;
// Correcte berekening van de lengte inclusief anker en eventuele beschrijving
var innerLength = title.length + anchor.length + ( hasExplicitDescription ? 1 + description.length : 0 );
var end = wikiMatch.index + 4 + innerLength;
var afterDescription = '';
var rest = text.substring( end );
var templateRegex = /^(\w*[.,:;?!)}\s]*){{\s*([^|{}]+?)\s*(?:\|[^{]*?)?}}/g;
var templateMatch = templateRegex.exec( rest );
if ( templateMatch !== null ) {
var templateTitle = getCanonicalTitle( templateMatch[2] );
if ( cfg.disamLinkTemplates?.includes( templateTitle ) ) {
end += templateMatch[0].length;
afterDescription = templateMatch[1].replace(/\s$/, '');
hasDisamTemplate = true;
} else if ( cfg.disamLinkIgnoreTemplates?.includes( templateTitle ) ) {
possiblyAmbiguous = false;
}
}
return {
type: 'wiki',
start: wikiMatch.index,
end: end,
possiblyAmbiguous: possiblyAmbiguous,
hasDisamTemplate: hasDisamTemplate,
title: title,
description: description,
hasExplicitDescription: hasExplicitDescription,
anchor: anchor,
afterDescription: afterDescription
};
} else if ( matchType === 'sortnaam' ) {
var first = sortnaamMatch[1].trim();
var last = sortnaamMatch[2].trim();
var rawParam3 = sortnaamMatch[3] ? sortnaamMatch[3].trim() : '';
var explicitTarget = rawParam3;
var anchor = '';
var hashIndex = rawParam3.indexOf( '#' );
if ( hashIndex !== -1 ) {
explicitTarget = rawParam3.substring( 0, hashIndex ).trim();
anchor = rawParam3.substring( hashIndex );
}
var target = explicitTarget !== '' ? explicitTarget : ( first + ' ' + last );
return {
type: 'sortnaam',
start: sortnaamMatch.index,
end: sortnaamMatch.index + sortnaamMatch[0].length,
possiblyAmbiguous: true,
hasDisamTemplate: false,
title: target,
description: sortnaamMatch[0],
first: first,
last: last,
explicitTarget: explicitTarget,
anchor: anchor,
afterDescription: ''
};
}
return null;
};
/*
* Extract a link to one of a number of destination pages from a string
* ("text") in wiki format, starting from a given index ("lastIndex").
* "Disambiguation needed" templates are included as part of the links.
* text: Page in wiki format
* destinations: Array of page titles to look for
* lastIndex: Index from which the search will start
*/
const extractLinkToPage = function( text, destinations, lastIndex ) {
var link, title;
do {
link = extractLink( text, lastIndex );
if ( link !== null ) {
lastIndex = link.end;
title = getCanonicalTitle( link.title );
}
} while (
link !== null
&& ( !link.possiblyAmbiguous || !destinations?.includes( title ) )
);
return link;
};
/*
* Find the "target" page: either the one we are in or the "main" one found by extracting
* the title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const getTargetPage = function() {
var title = getTitle();
return forceSamePage ? title : removeDisam( title );
};
/*
* Get the page title, with the namespace prefix if any.
*/
const getTitle = function() {
return mw.config.get( 'wgPageName' ).replace(/_/g, ' ')
}
/*
* Extract a "main" title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const removeDisam = function( title ) {
var match = new RegExp( cfg.disamRegExp ).exec( title );
return match ? match[1] : title;
};
/*
* Check whether two page titles are the same
*/
const isSamePage = function( title1, title2 ) {
return getCanonicalTitle( title1 ) === getCanonicalTitle( title2 );
};
/*
* Return the 'canonical title' of a page
*/
const getCanonicalTitle = function( title ) {
try {
title = new mw.Title( title );
// Check that page name matches the current namespace
if ( title.getNamespaceId() !== mw.config.get( 'wgNamespaceNumber' ) ) {
return;
}
const fragment = title.getFragment();
return title.getPrefixedText() + ( fragment ? `#${ fragment }` : '' );
} catch ( ex ) {
// mw.Title seems to be buggy, and some valid titles are rejected
// FIXME: This may cause false negatives
}
};
/*
* Extract the context around a given link in a text string
*/
const extractContext = function( text, link ) {
var contextStart = link.start - cfg.radius;
var contextEnd = link.end + cfg.radius;
var contextPrev = text.substring( contextStart, link.start );
const ellipsis = '…';
if ( contextStart > 0 ) {
contextPrev = ellipsis + contextPrev;
}
var contextNext = text.substring( link.end, contextEnd );
if ( contextEnd < text.length ) {
contextNext = contextNext + ellipsis;
}
return [contextPrev, text.substring( link.start, link.end ), contextNext];
};
/*
* Extract the prefixed page name from a link
*/
const extractPageName = function( link ) {
var pageName = extractPageNameRaw( link );
if ( pageName ) {
return getCanonicalTitle( pageName );
}
return null;
};
/*
* Extract the page name from a link, as is
*/
const extractPageNameRaw = function( link ) {
// Ignore images and interwiki links
if ( link.hasClass( 'image' ) || link.hasClass( 'extiw' ) ) {
return null;
}
var href = link.attr( 'href' );
// Red links in old parser format
if ( link.hasClass( 'new' ) ) {
const oldRedLink = mw.util.getParamValue( 'title', href );
if ( oldRedLink !== null ) {
return oldRedLink;
}
}
// Check that external links are coming from this wiki
if ( href.indexOf( '//' ) === 0 || href.indexOf( 'https://' ) === 0 ) {
try {
const fullUrl = href.indexOf( '//' ) === 0 ? location.protocol + href : href;
let urlData = new URL( fullUrl );
if ( urlData.hostname !== location.hostname ) {
return null;
}
} catch( e ) {
return null;
}
}
var articlePathRegexp = new RegExp(
mw.config.get( 'wgArticlePath' ).replace( '$1', '([^?]*)' )
);
var result = articlePathRegexp.exec( href );
if ( Array.isArray( result ) && result.length > 1 ) {
return decodeURIComponent( result[ 1 ] );
}
return null;
};
/*
* Check whether this is a disambiguation page
*/
const isDisam = function() {
const categories = mw.config.get( 'wgCategories', [] );
return cfg.disamCategories.some( c => categories.includes( c ) );
};
const secondsToHHMMSS = function( totalSeconds ) {
var hhmmss = '';
var hours = Math.floor( totalSeconds / 3600 );
var minutes = Math.floor( totalSeconds % 3600 / 60 );
var seconds = Math.floor( totalSeconds % 3600 % 60 );
if ( hours >= 1 ) {
hhmmss = pad( hours, '0', 2 ) + ':';
}
hhmmss += pad( minutes, '0', 2 ) + ':' + pad( seconds, '0', 2 );
return hhmmss;
};
const pad = function( str, z, width ) {
str = str.toString();
if ( str.length >= width ) {
return str;
} else {
return new Array( width - str.length + 1 ).join( z ) + str;
}
}
/*
* Create a new button
* text: Text that will be displayed on the button
* onClick: Function that will be called when the button is clicked
*/
const createButton = function( text, onClick ) {
var button = $( '<button>', { 'type': 'button' } );
button.text( text );
button.addClass( 'disamassist-button' ).click( onClick );
return button;
};
/*
* Given a page title and an array of possible redirects {from, to} ("canonical titles"), find the page
* at the end of the redirect chain, if there is one. Otherwise, return the page title that was passed
*/
const resolveRedirect = function( pageTitle, possibleRedirects ) {
var appliedRedirect = true;
var visitedPages = {};
var currentPage = getCanonicalTitle( pageTitle );
while ( appliedRedirect ) {
appliedRedirect = false;
for ( var ii = 0; ii < possibleRedirects.length; ii++ ) {
if ( possibleRedirects[ii].from === currentPage ) {
if ( visitedPages[possibleRedirects[ii].to] ) {
// Redirect chain detected
return pageTitle;
}
visitedPages[currentPage] = true;
appliedRedirect = true;
currentPage = possibleRedirects[ii].to;
}
}
}
// No redirect rules applied for an iteration of the outer loop:
// no more redirects. We are done
return currentPage;
};
/*
* Fetch the incoming links to a page. Returns a jQuery promise
* (success - array of titles of pages that contain links to the target page and
* array of "canonical titles" of possible destinations of the backlinks (either
* the target page or redirects to the target page), failure - error description)
* page: Target page
*/
const getBacklinks = function( page ) {
var dfd = new $.Deferred();
api.get( {
'action': 'query',
'list': 'backlinks',
'bltitle': page,
'blredirect': true,
'bllimit': cfg.backlinkLimit,
'blnamespace': cfg.targetNamespaces,
} ).done( function( data ) {
// There might be duplicate entries in some corner cases. We don't care,
// since we are going to check later, anyway
var backlinks = [];
var linkTitles = [getCanonicalTitle( page )];
$.each( data.query.backlinks, function() {
backlinks.push( this.title );
if ( this.redirlinks ) {
linkTitles.push( this.title );
$.each( this.redirlinks, function() {
backlinks.push( this.title );
} );
}
} );
dfd.resolve( backlinks, linkTitles );
} ).fail( function( code, data ) {
dfd.reject( txt.getBacklinksError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download a list of redirects for some pages. Returns a jQuery callback (success -
* array of redirects ({from, to}), failure - error description )
* pageTitles: Array of page titles
*/
const fetchRedirects = function( pageTitles ) {
var dfd = new $.Deferred();
var currentTitles = pageTitles.slice( 0, cfg.queryTitleLimit );
var restTitles = pageTitles.slice( cfg.queryTitleLimit );
api.get( {
action: 'query',
titles: currentTitles,
redirects: true,
} ).done( function( data ) {
var theseRedirects = data.query.redirects ? data.query.redirects : [];
if ( restTitles.length !== 0 ) {
fetchRedirects( restTitles ).done( function( redirects ) {
dfd.resolve( theseRedirects.concat( redirects ) );
} ).fail( function( description ) {
dfd.reject( description );
} );
} else {
dfd.resolve( theseRedirects );
}
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRedirectsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download the list of user rights for the current user. Returns a
* jQuery promise (success - array of right names, error - error description)
*/
const fetchRights = function() {
var dfd = $.Deferred();
api.get( {
action: 'query',
meta: 'userinfo',
uiprop: 'rights',
} ).done( function( data ) {
dfd.resolve( data.query.userinfo.rights );
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRightsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Load the raw page text for a given title. Returns a jQuery promise (success - page
* content, failure - error description)
* pageTitle: Title of the page
*/
const loadPage = function( pageTitle ) {
var dfd = new $.Deferred();
api.get( {
action: 'query',
titles: pageTitle,
prop: 'revisions',
rvprop: 'timestamp|content',
meta: 'tokens',
type: 'csrf',
} ).done( function( data ) {
var pages = data.query.pages;
for ( var key in pages ) {
if ( pages.hasOwnProperty( key ) ) {
break;
}
}
var rawPage = data.query.pages[key];
var page = {};
page.missing = rawPage.missing !== undefined;
if ( rawPage.revisions ) {
page.content = rawPage.revisions[0]['*'];
// Assume anything with # at the start is a redirect
if ( page.content.match( /^#[^\s]*?\s*\[\[/ ) ) {
page.redirect = true;
}
page.baseTimeStamp = rawPage.revisions[0].timestamp;
} else {
page.content = '';
page.baseTimeStamp = null;
}
page.startTimeStamp = rawPage.starttimestamp;
page.editToken = data.query.tokens.csrftoken;
dfd.resolve( page );
} ).fail( function( code, data ) {
dfd.reject( txt.loadPageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
/*
* Register changes to a page, to be saved later. Returns a jQuery promise
* (success - no params, failure - error description). Takes the same parameters
* as savePage
*/
const saveWithCooldown = function() {
var deferred = new $.Deferred();
pendingSaves.push( {args: arguments, dfd: deferred} );
if ( !runningSaves ) {
checkAndSave();
}
return deferred.promise();
};
/*
* Save the first set of changes in the list of pending changes, providing that
* enough time has passed since the last edit
*/
const checkAndSave = function() {
if ( pendingSaves.length === 0 ) {
runningSaves = false;
return;
}
runningSaves = true;
var millisSinceLast = new Date().getTime() - lastEditMillis;
if ( millisSinceLast < cfg.editCooldown * 1000 ) {
setTimeout( checkAndSave, cfg.editCooldown * 1000 - millisSinceLast );
} else {
// The last edit started at least cfg.editCooldown seconds ago
var save = pendingSaves.shift();
savePage.apply( this, save.args ).done( function() {
checkAndSave();
save.dfd.resolve();
} ).fail( function( description ) {
checkAndSave();
save.dfd.reject( description );
} );
// We'll use the time since the last edit started
lastEditMillis = new Date().getTime();
}
};
/*
* Save the changes made to a page. Returns a jQuery promise (success - no params,
* failure - error description)
* pageTitle: Title of the page
* page: Page data
* summary: Summary of the changes made to the page
* minorEdit: Whether to mark the edit as 'minor'
* botEdit: Whether to mark the edit as 'bot'
*/
const savePage = function( pageTitle, page, summary, minorEdit, botEdit ) {
var dfd = new $.Deferred();
api.post( {
action: 'edit',
title: pageTitle,
token: page.editToken,
text: page.content,
basetimestamp: page.baseTimeStamp,
starttimestamp: page.startTimeStamp,
summary: summary,
watchlist: cfg.watch ? cfg.watch : 'nochange',
assertuser: mw.config.get( 'wgUserName' ),
minor: minorEdit,
bot: botEdit,
} ).done( function() {
dfd.resolve();
} ).fail( function( code, data ) {
dfd.reject( txt.savePageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
install();
} )();
// </nowiki>
ikbxthh93rm1aqg0sbls4iai625c624
781192
781191
2026-10-27T16:02:29Z
Rots61
44293
781192
javascript
text/javascript
/*
* DisamAssist: a tool for repairing links from articles to disambiguation pages.
*/
// <nowiki>
// @ts-nocheck
( () => {
var cfg = {};
var txt = {};
var ui;
var links, pageChanges;
var currentPageTitle, currentPageParameters, currentLink;
var possibleBacklinkDestinations;
var titleRegex;
var forceSamePage = false;
var running = false;
var choosing = false;
var canMarkIntentionalLinks = false;
var displayedPages = {};
var editCount = 0;
var editLimit;
var pendingSaves = [];
var pendingEditBox = null;
var pendingEditBoxText;
var lastEditMillis = 0;
var runningSaves = false;
var api;
const dependencies = [
'mediawiki.api',
'mediawiki.confirmCloseWindow',
'mediawiki.Title',
'mediawiki.util',
];
/**
* Add an item in Tools menu
* @param {string} text
* @param {string} id
* @param {(e: MouseEvent) => void} callback
*/
const disamPortletLink = ( text, id, callback ) => {
const portlet = mw.util.addPortletLink(
'p-tb',
cfg.helpPage ? mw.util.getUrl( cfg.helpPage ) : '#',
text,
id
);
const link = portlet?.querySelector( 'a' );
link?.addEventListener( 'click', callback );
addButtonEvents( link );
}
/*
* Entry point. Check whether we are in a disambiguation page. If so, add a link to start the tool
*/
const install = function() {
cfg = window.DisamAssist.cfg;
txt = window.DisamAssist.txt;
if ( mw.config.get( 'wgAction' ) === 'view' && isDisam() ) {
mw.loader.using( dependencies, () => {
api = new mw.Api();
mw.loader.load( 'https://ru.wikipedia.org/w/index.php?title=Участник:Stjn/DisamAssist.css&action=raw&ctype=text/css', 'text/css' );
// This is a " (disambiguation)" page
if ( new RegExp( cfg.disamRegExp ).exec( getTitle() ) ) {
disamPortletLink( txt.startMain, 't-disamAssist-main', startMain );
disamPortletLink( txt.startSame, 't-disamAssist-same', startSame );
} else {
disamPortletLink( txt.start, 't-disamAssist-page', start );
}
} );
}
};
/*
* Start the tool. Display the UI and begin looking for links to fix
*/
const start = function( e ) {
e?.preventDefault();
if ( !running ) {
console.log(Date.now())
running = true;
links = [];
pageChanges = [];
displayedPages = {};
ensureDABExists().then( function( canMark ) {
canMarkIntentionalLinks = canMark;
createUI();
addUnloadConfirm();
markDisamOptions();
checkEditLimit().then( function() {
togglePendingEditBox( false );
doPage();
} );
} );
}
};
/*
* Start DisamAssist. Disambiguate incoming links to the current page, regardless
* of the title.
*/
const startSame = function( e ) {
e.preventDefault();
forceSamePage = true;
start( e );
};
/*
* Start DisamAssist. If the page title ends with " (disambiguation)", disambiguate
* links to the primary topic article. Otherwise, disambiguate links to the current
* page.
*/
const startMain = function( e ) {
e.preventDefault();
forceSamePage = false;
start( e );
};
/*
* Create and show the user interface.
*/
const createUI = function() {
ui = {
display: $( '<div>' ).addClass( 'disamassist-box disamassist-mainbox' ),
finishedMessage: $( '<div>' ).text( txt.noMoreLinks ).hide(),
pageTitleLine: $( '<span>' ).addClass( 'disamassist-pagetitleline' ),
pendingEditCounter: $( '<div>').addClass( 'disamassist-editcounter' ),
context: $( '<pre>' ).addClass( 'disamassist-context' ),
undoButton: createButton( txt.undo, undo ),
omitButton: createButton( txt.omit, omit ),
endButton: createButton( txt.close, saveAndEnd ),
refreshButton: createButton( txt.refresh, refresh ),
titleAsTextButton: createButton( txt.titleAsText, chooseTitleFromPrompt ),
intentionalLinkButton: canMarkIntentionalLinks ? createButton( txt.intentionalLink, chooseIntentionalLink ) : null,
disamNeededButton: cfg.disamNeededText ? createButton( txt.disamNeeded, chooseDisamNeeded ) : null,
removeLinkButton: createButton( txt.removeLink, chooseLinkRemoval )
};
var top = $( '<div>' ).addClass( 'disamassist-top' )
.append( [ui.pageTitleLine, ui.pendingEditCounter] );
var leftButtons = $( '<div>' ).addClass( 'disamassist-leftbuttons' )
.append( [ui.titleAsTextButton, ui.removeLinkButton, ui.intentionalLinkButton, ui.disamNeededButton, ui.omitButton] );
var rightButtons = $( '<div>' ).addClass( 'disamassist-rightbuttons' )
.append( [ui.undoButton, ui.refreshButton, ui.endButton] );
var allButtons = $( '<div>' ).addClass( 'disamassist-allbuttons' )
.append( [leftButtons, rightButtons] );
ui.display.append( [top, ui.finishedMessage, ui.context, allButtons] );
updateEditCounter();
toggleActionButtons( false );
// Insert the UI in the page
$( '#mw-content-text' ).before( ui.display );
ui.display.hide().fadeIn();
};
/**
* Add button role and related keyboard event
* @param {Element} el
*/
const addButtonEvents = ( el ) => {
el?.setAttribute( 'role', 'button' );
el?.addEventListener( 'keydown', function( e ) {
if ( [ 'Enter', 'Space' ].includes( e.code ) ) {
e.preventDefault();
this.click();
}
} );
}
/*
* If there are pending changes, show a confirm dialog before closing
*/
const addUnloadConfirm = function() {
mw.confirmCloseWindow( {
test: () => {
if ( running && checkActualChanges() ) {
return txt.pending;
} else if ( editCount !== 0 ) {
return txt.editInProgress;
}
}
} );
};
/*
* Mark the disambiguation options as such
*/
const markDisamOptions = function() {
var optionPageTitles = [];
var optionMarkers = [];
getDisamOptions().each( function() {
var link = $( this );
var title = extractPageName( link );
const optionMarker = document.createElement( 'a' );
optionMarker.classList.add( 'disamassist-optionmarker' );
optionMarker.href = link.attr( 'href' );
optionMarker.setAttribute( 'data-title', title );
optionMarker.textContent = txt.optionMarker;
optionMarker.addEventListener( 'click', ( e ) => {
e.preventDefault();
chooseReplacement( title );
} );
addButtonEvents( optionMarker );
link.after( optionMarker );
optionMarkers.push( optionMarker );
optionPageTitles.push( title );
} );
// Now check the disambiguation options and display a different message for those that are
// actually the same as the target page where the links go, as choosing those options doesn't really
// accomplish anything (except bypassing redirects, which might be useful in some cases)
var targetPage = getTargetPage();
fetchRedirects( optionPageTitles.concat( targetPage ) ).done( function( redirects ) {
var endTargetPage = resolveRedirect( targetPage, redirects );
for ( var ii = 0; ii < optionPageTitles.length; ii++ ) {
var endOptionTitle = resolveRedirect( optionPageTitles[ii], redirects );
if ( isSamePage( optionPageTitles[ii], targetPage ) ) {
$( optionMarkers[ii] ).text( txt.targetOptionMarker ).addClass( 'disamassist-curroptionmarker');
} else if ( isSamePage( endOptionTitle, endTargetPage ) ) {
$( optionMarkers[ii] ).text( txt.redirectOptionMarker ).addClass( 'disamassist-curroptionmarker');
}
}
} ).fail( error );
};
/*
* Check whether intentional links to disambiguation pages can be explicitly marked
* as such in this wiki. If so, ensure that a "Foo (disambiguation)" page exists.
* Returns a jQuery promise
*/
const ensureDABExists = function() {
var dfd = new $.Deferred();
var title = getTitle();
// That concept doesn't exist in this wiki.
if ( !cfg.intentionalLinkOption ) {
dfd.resolve( false );
// "Foo (disambiguation)" exists: it's the current page.
} else if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
dfd.resolve( true );
} else {
var disamTitle = cfg.disamFormat.replace( '$1', title );
loadPage( disamTitle ).done( function( page ) {
// "Foo (disambiguation)" doesn't exist.
if ( page.missing ) {
// We try to create it
page.content = cfg.redirectToDisam.replace( '$1', title );
var summary = txt.redirectSummary.replace( '$1', title );
savePage( disamTitle, page, summary, false, true ).done( function() {
dfd.resolve( true );
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
// It does exist
} else {
dfd.resolve( true );
}
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
}
return dfd.promise();
};
/*
* Check whether the edit cooldown applies and sets editLimit accordingly.
* Returns a jQuery promise
*/
const checkEditLimit = function() {
var dfd = new $.Deferred();
if ( cfg.editCooldown <= 0 ) {
editLimit = false;
dfd.resolve();
} else {
fetchRights().done( function( rights ) {
editLimit = !rights?.includes( 'bot' );
} ).fail( function( description ) {
error( description );
editLimit = true;
} ).always( function() {
dfd.resolve();
} );
}
return dfd.promise();
};
/*
* Find and ask the user to fix all the incoming links to the disambiguation ("target")
* page from a single "origin" page
*/
const doPage = function() {
if ( pageChanges.length > cfg.historySize ) {
applyChange( pageChanges.shift() );
}
if ( links.length === 0 ) {
var targetPage = getTargetPage();
getBacklinks( targetPage ).done( function( backlinks, pageTitles ) {
var pending = {};
$.each( pendingSaves, function() {
pending[this[0]] = true;
} );
possibleBacklinkDestinations = $.grep( pageTitles, function( t, ii) {
if ( t == targetPage ) {
return true;
}
return removeDisam(t) != targetPage;
} );
titleRegex = new RegExp('\\[\\[([ _]*?(?:' + possibleBacklinkDestinations.join('|').replaceAll('(', '\\(').replaceAll(')','\\)').replaceAll(' ', '[ _]') + ')[ _]*?)(#.*?)?(?:\\|(.*?))?\\]\\]', 'gi');
// Only incoming links from pages we haven't seen yet and we aren't currently
// saving (displayedPages is reset when the tool is closed and opened again,
// while the list of pending changes isn't; if the edit cooldown is disabled,
// it will be empty)
links = $.grep( backlinks, function( el, ii ) {
return !displayedPages[el] && !pending[el];
} );
if ( links.length === 0 ) {
updateContext();
} else {
doPage();
}
} ).fail( error );
} else {
currentPageTitle = links.shift();
displayedPages[currentPageTitle] = true;
toggleActionButtons( false );
loadPage( currentPageTitle ).done( function( data ) {
currentPageParameters = data;
currentLink = null;
doLink();
} ).fail( error );
}
};
/*
* Find and ask the user to fix a single incoming link to the disambiguation ("target")
* page
*/
const doLink = function() {
if (!currentPageParameters.redirect) {
currentLink = extractLinkToPage( currentPageParameters.content,
possibleBacklinkDestinations, currentLink ? currentLink.end : 0 );
if ( currentLink ) {
updateContext();
} else {
doPage();
}
} else {
doPage();
}
};
/*
* Replace the target of a link with a different one
* pageTitle: New link target
* extra: Additional text after the link (optional)
* summary: Change summary (optional)
*/
const chooseReplacement = function( pageTitle, extra, summary ) {
if ( choosing ) {
choosing = false;
if ( !summary ) {
if ( pageTitle ) {
summary = txt.summaryChanged.replace( '$1', pageTitle );
} else {
summary = txt.summaryOmitted;
}
}
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
if ( pageTitle && ( pageTitle !== getTargetPage() || extra ) ) {
if ( currentPageParameters.redirect ) {
currentLink.description = pageTitle;
}
currentPageParameters.content =
replaceLink( currentPageParameters.content, pageTitle, currentLink, extra || '' );
}
doLink();
}
};
/*
* Replace the link with an explicit link to the disambiguation page
*/
const chooseIntentionalLink = function() {
const targetPage = getTargetPage();
let title = cfg.disamFormat.replace( '$1', targetPage );
if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
title = targetPage;
}
chooseReplacement( title, '', txt.summaryIntentional );
};
/*
* Prompt for an alternative link target and use it as a replacement
*/
const chooseTitleFromPrompt = function() {
var title = prompt( txt.titleAsTextPrompt );
if ( title !== null ) {
chooseReplacement( title );
}
};
/*
* Remove the current link, leaving the text unchanged
*/
const chooseLinkRemoval = function() {
if ( choosing ) {
var summary = txt.summaryRemoved;
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
currentPageParameters.content = removeLink( currentPageParameters.content, currentLink );
doLink();
}
};
/*
* Add a "disambiguation needed" template after the link
*/
const chooseDisamNeeded = function() {
chooseReplacement( currentLink.title, cfg.disamNeededText, txt.summaryHelpNeeded );
};
/*
* Undo the last change
*/
const undo = function() {
if ( pageChanges.length !== 0 ) {
var lastPage = pageChanges[pageChanges.length - 1];
if ( currentPageTitle !== lastPage.title ) {
links.unshift( currentPageTitle );
currentPageTitle = lastPage.title;
}
currentPageParameters = lastPage.page;
currentPageParameters.content = lastPage.contentBefore.pop();
currentLink = lastPage.links.pop();
lastPage.summary.pop();
if ( lastPage.contentBefore.length === 0 ) {
pageChanges.pop();
}
updateContext();
}
};
/*
* Omit the current link without making a change
*/
const omit = function() {
chooseReplacement( null );
};
/*
* Save all the pending changes and restart the tool.
*/
const refresh = function() {
saveAndEnd();
start();
};
/*
* Enable or disable the buttons that can perform actions on a page or change the current link.
* enabled: Whether to enable or disable the buttons
*/
const toggleActionButtons = function( enabled ) {
var affectedButtons = [ui.omitButton, ui.titleAsTextButton, ui.removeLinkButton,
ui.intentionalLinkButton, ui.disamNeededButton, ui.undoButton];
$.each( affectedButtons, function( ii, button ) {
button?.prop( 'disabled', !enabled );
} );
};
/*
* Show or hide the 'no more links' message
* show: Whether to show or hide the message
*/
const toggleFinishedMessage = function( show ) {
toggleActionButtons( !show );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.finishedMessage.toggle( show );
ui.pageTitleLine.toggle( !show );
ui.context.toggle( !show );
if ( show ) console.log(Date.now())
};
const togglePendingEditBox = function( show ) {
if ( pendingEditBox === null ) {
pendingEditBox = $( '<div>' ).addClass( 'disamassist-box disamassist-pendingeditbox' );
pendingEditBoxText = $( '<div>' );
pendingEditBox.append( pendingEditBoxText ).hide();
if ( editLimit ) {
pendingEditBox.append( $( '<div>' ).text( txt.pendingEditBoxLimited )
.addClass( 'disamassist-subtitle' ) );
}
$( '#mw-content-text' ).before( pendingEditBox );
updateEditCounter();
}
if ( show ) {
pendingEditBox.fadeIn();
} else {
pendingEditBox.fadeOut();
}
};
const notifyCompletion = function() {
var oldTitle = document.title;
document.title = txt.notifyCharacter + document.title;
$( document.body ).one( 'mousemove', function() {
document.title = oldTitle;
} );
};
/*
* Update the displayed information to match the current link
* or lack thereof
*/
const updateContext = function() {
updateEditCounter();
if ( !currentLink ) {
toggleFinishedMessage( true );
} else {
ui.pageTitleLine.html( txt.pageTitleLine.replace( '$1',
mw.util.getUrl( currentPageTitle, {redirect: 'no'} ) ).replace( '$2', mw.html.escape( currentPageTitle ) ) );
var context = extractContext( currentPageParameters.content, currentLink );
ui.context.empty()
.append( $( '<span>' ).text( context[0] ) )
.append( $( '<span>' ).text( context[1] ).addClass( 'disamassist-inclink' ) )
.append( $( '<span>' ).text( context[2] ) );
toggleFinishedMessage( false );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.removeLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.intentionalLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.disamNeededButton?.prop( 'disabled', currentPageParameters.redirect || currentLink.hasDisamTemplate );
choosing = true;
}
};
/*
* Update the count of pending changes
*/
const updateEditCounter = function() {
if ( ui.pendingEditCounter ) {
ui.pendingEditCounter.text( txt.pendingEditCounter.replace( '$1', editCount )
.replace( '$2', countActuallyChangedFullyCheckedPages() ) );
}
if ( pendingEditBox ) {
if ( editCount === 0 && !running ) {
togglePendingEditBox( false );
notifyCompletion();
}
var textContent = editCount;
if ( editLimit ) {
textContent = txt.pendingEditBoxTimeEstimation.replace( '$1', editCount )
.replace( '$2', secondsToHHMMSS( cfg.editCooldown * editCount ) );
}
pendingEditBoxText.text( txt.pendingEditBox.replace( '$1', textContent ) );
}
};
/*
* Apply the changes made to an "origin" page
* pageChange: Change that will be saved
*/
const applyChange = function( pageChange ) {
if ( pageChange.page.content !== pageChange.contentBefore[0] ) {
editCount++;
// Filter duplicates and join
var changeSummaries = pageChange.summary
.filter( ( val, i, arr ) => val && arr.indexOf( val ) === i )
.join( txt.summarySeparator );
var summary = txt.summary.replace( '$1', getTargetPage() ).replace( '$2', changeSummaries );
var save = editLimit ? saveWithCooldown : savePage;
save( pageChange.title, pageChange.page, summary, true, true ).always( function() {
if ( editCount > 0 ) {
editCount--;
}
updateEditCounter();
} ).fail( error );
updateEditCounter();
}
};
/*
* Save all the pending changes
*/
const applyAllChanges = function() {
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
applyChange( pageChanges[ii] );
}
pageChanges = [];
};
/*
* Record a new pending change
* pageTitle: Title of the page
* page: Content of the page
* oldContent: Content of the page before the change
* link: Link that has been changed
* summary: Change summary
*/
const addChange = function( pageTitle, page, oldContent, link, summary ) {
if ( ( pageChanges.length === 0 ) || ( pageChanges[pageChanges.length - 1].title !== pageTitle ) ) {
pageChanges.push( {
title: pageTitle,
page: page,
contentBefore: [],
links: [],
summary: []
} );
}
var lastPageChange = pageChanges[pageChanges.length - 1];
lastPageChange.contentBefore.push( oldContent );
lastPageChange.links.push( link );
lastPageChange.summary.push( summary );
};
/*
* Check whether actual changes are stored in the history array
*/
const checkActualChanges = function() {
return countActualChanges() !== 0;
};
/*
* Return the number of entries in the history array that represent actual changes
*/
const countActualChanges = function() {
var changeCount = 0;
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
if ( pageChanges[ii].page.content !== pageChanges[ii].contentBefore[0] ) {
changeCount++;
}
}
return changeCount;
};
/*
* Return the number of changed pages in the history array, ignoring the last entry
* if we aren't done with that page yet
*/
const countActuallyChangedFullyCheckedPages = function() {
var changeCount = countActualChanges();
if ( pageChanges.length !== 0 ) {
var lastChange = pageChanges[pageChanges.length - 1];
if ( lastChange.title === currentPageTitle && currentLink !== null
&& lastChange.page.content !== lastChange.contentBefore[0] ) {
changeCount--;
}
}
return changeCount;
};
/*
* Find the links to disambiguation options in a disambiguation page
*/
const getDisamOptions = function() {
return $( '#mw-content-text a' ).filter( function() {
return !!extractPageName( $( this ) );
} );
};
/*
* Save all the pending changes and close the tool
*/
const saveAndEnd = function() {
applyAllChanges();
end();
};
/*
* Close the tool
*/
const end = function() {
var currentToolUI = ui.display;
choosing = false;
running = false;
$( '.disamassist-optionmarker' ).remove();
currentToolUI.fadeOut( { complete: function() {
currentToolUI.remove();
if ( editCount !== 0 ) {
togglePendingEditBox( true );
}
} } );
};
/*
* Display an error message
*/
const error = function( errorDescription ) {
var errorBox = $( '<div>' ).addClass( 'disamassist-box disamassist-errorbox' );
errorBox.text( txt.error.replace( '$1', errorDescription ) );
errorBox.append( createButton( txt.dismissError, function() {
errorBox.fadeOut();
} ).addClass( 'disamassist-errorbutton' ) );
var uiIsInPlace = ui && $.contains( document.documentElement, ui.display[0] );
var nextElement = uiIsInPlace ? ui.display : $( '#mw-content-text' );
nextElement.before( errorBox );
errorBox.hide().fadeIn();
}
/*
* Change a link so that it points to the title
* text: The wikitext of the whole page
* title: The new destination of the link
* link: The link that will be modified
* extra: Text that will be added after the link (optional)
*/
const replaceLink = function( text, title, link, extra ) {
var anchor = link.anchor || '';
var newContent;
if ( link.type === 'sortnaam' ) {
newContent = '{{sortnaam|' + link.first + '|' + link.last + '|' + title + anchor + '}}';
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + ( extra || '' ) + linkEnd;
}
if ( !link.hasExplicitDescription ) {
// Geen pipe aanwezig (bijv. [[appel#gezondheid]]), behoud de schone linkvorm met anker
newContent = title + anchor;
} else if ( isSamePage( title, link.description ) ) {
newContent = link.description + anchor;
} else {
newContent = title + anchor + '|' + link.description;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + '[[' + newContent + ']]' + link.afterDescription + ( extra || '' ) + linkEnd;
};
/*
* Remove a link from the text
* text: The wikitext of the whole page
* link: The link that will be removed
*/
const removeLink = function( text, link ) {
var anchor = link.anchor || '';
if ( link.type === 'sortnaam' ) {
var newContent = '{{sortnaam|' + link.first + '|' + link.last + '}}' + anchor;
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + linkEnd;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + link.description + anchor + link.afterDescription + linkEnd;
};
/*
* Extract a link from a string in wiki format,
* starting from a given index. Return a link if one can be found,
* otherwise return null. The "link" includes "disambiguation needed"
* templates inmediately following the link proper
* text: Text from which the link will be extracted
* lastIndex: Index from which the search will start
*/
const extractLink = function( text, lastIndex ) {
// 1. Zoek naar gewone wikitlinks (inclusief anker en pipe)
titleRegex.lastIndex = lastIndex;
var wikiMatch = titleRegex.exec( text );
// 2. Zoek naar sortnaam-templates
var sortnaamRegex = new RegExp('{{\\s*sortnaam\\s*\\|\\s*([^|]+?)\\s*\\|\\s*([^|]+?)\\s*(?:\\|\\s*([^}]*?)\\s*)?}}', 'gi');
sortnaamRegex.lastIndex = lastIndex;
var sortnaamMatch = sortnaamRegex.exec( text );
var matchType = null;
var matchIndex = Infinity;
if ( wikiMatch !== null && wikiMatch.index >= lastIndex ) {
matchIndex = wikiMatch.index;
matchType = 'wiki';
}
if ( sortnaamMatch !== null && sortnaamMatch.index >= lastIndex && sortnaamMatch.index < matchIndex ) {
matchIndex = sortnaamMatch.index;
matchType = 'sortnaam';
}
if ( matchType === 'wiki' ) {
var possiblyAmbiguous = true;
var hasDisamTemplate = false;
var title = wikiMatch[1].trim();
var anchor = wikiMatch[2] ? wikiMatch[2] : '';
var hasExplicitDescription = wikiMatch[3] !== undefined;
var description = hasExplicitDescription ? wikiMatch[3].trim() : title;
// Gebruik de volledige matchlengte zodat ankers en pipes altijd exact worden omsloten
var end = wikiMatch.index + wikiMatch[0].length;
var afterDescription = '';
var rest = text.substring( end );
var templateRegex = /^(\w*[.,:;?!)}\s]*){{\s*([^|{}]+?)\s*(?:\|[^{]*?)?}}/g;
var templateMatch = templateRegex.exec( rest );
if ( templateMatch !== null ) {
var templateTitle = getCanonicalTitle( templateMatch[2] );
if ( cfg.disamLinkTemplates?.includes( templateTitle ) ) {
end += templateMatch[0].length;
afterDescription = templateMatch[1].replace(/\s$/, '');
hasDisamTemplate = true;
} else if ( cfg.disamLinkIgnoreTemplates?.includes( templateTitle ) ) {
possiblyAmbiguous = false;
}
}
return {
type: 'wiki',
start: wikiMatch.index,
end: end,
possiblyAmbiguous: possiblyAmbiguous,
hasDisamTemplate: hasDisamTemplate,
title: title,
description: description,
hasExplicitDescription: hasExplicitDescription,
anchor: anchor,
afterDescription: afterDescription
};
} else if ( matchType === 'sortnaam' ) {
var first = sortnaamMatch[1].trim();
var last = sortnaamMatch[2].trim();
var rawParam3 = sortnaamMatch[3] ? sortnaamMatch[3].trim() : '';
var explicitTarget = rawParam3;
var anchor = '';
var hashIndex = rawParam3.indexOf( '#' );
if ( hashIndex !== -1 ) {
explicitTarget = rawParam3.substring( 0, hashIndex ).trim();
anchor = rawParam3.substring( hashIndex );
}
var target = explicitTarget !== '' ? explicitTarget : ( first + ' ' + last );
return {
type: 'sortnaam',
start: sortnaamMatch.index,
end: sortnaamMatch.index + sortnaamMatch[0].length,
possiblyAmbiguous: true,
hasDisamTemplate: false,
title: target,
description: sortnaamMatch[0],
first: first,
last: last,
explicitTarget: explicitTarget,
anchor: anchor,
afterDescription: ''
};
}
return null;
};
/*
* Extract a link to one of a number of destination pages from a string
* ("text") in wiki format, starting from a given index ("lastIndex").
* "Disambiguation needed" templates are included as part of the links.
* text: Page in wiki format
* destinations: Array of page titles to look for
* lastIndex: Index from which the search will start
*/
const extractLinkToPage = function( text, destinations, lastIndex ) {
var link, title;
do {
link = extractLink( text, lastIndex );
if ( link !== null ) {
lastIndex = link.end;
title = getCanonicalTitle( link.title );
}
} while (
link !== null
&& ( !link.possiblyAmbiguous || !destinations?.includes( title ) )
);
return link;
};
/*
* Find the "target" page: either the one we are in or the "main" one found by extracting
* the title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const getTargetPage = function() {
var title = getTitle();
return forceSamePage ? title : removeDisam( title );
};
/*
* Get the page title, with the namespace prefix if any.
*/
const getTitle = function() {
return mw.config.get( 'wgPageName' ).replace(/_/g, ' ')
}
/*
* Extract a "main" title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const removeDisam = function( title ) {
var match = new RegExp( cfg.disamRegExp ).exec( title );
return match ? match[1] : title;
};
/*
* Check whether two page titles are the same
*/
const isSamePage = function( title1, title2 ) {
return getCanonicalTitle( title1 ) === getCanonicalTitle( title2 );
};
/*
* Return the 'canonical title' of a page
*/
const getCanonicalTitle = function( title ) {
try {
title = new mw.Title( title );
// Check that page name matches the current namespace
if ( title.getNamespaceId() !== mw.config.get( 'wgNamespaceNumber' ) ) {
return;
}
const fragment = title.getFragment();
return title.getPrefixedText() + ( fragment ? `#${ fragment }` : '' );
} catch ( ex ) {
// mw.Title seems to be buggy, and some valid titles are rejected
// FIXME: This may cause false negatives
}
};
/*
* Extract the context around a given link in a text string
*/
const extractContext = function( text, link ) {
var contextStart = link.start - cfg.radius;
var contextEnd = link.end + cfg.radius;
var contextPrev = text.substring( contextStart, link.start );
const ellipsis = '…';
if ( contextStart > 0 ) {
contextPrev = ellipsis + contextPrev;
}
var contextNext = text.substring( link.end, contextEnd );
if ( contextEnd < text.length ) {
contextNext = contextNext + ellipsis;
}
return [contextPrev, text.substring( link.start, link.end ), contextNext];
};
/*
* Extract the prefixed page name from a link
*/
const extractPageName = function( link ) {
var pageName = extractPageNameRaw( link );
if ( pageName ) {
return getCanonicalTitle( pageName );
}
return null;
};
/*
* Extract the page name from a link, as is
*/
const extractPageNameRaw = function( link ) {
// Ignore images and interwiki links
if ( link.hasClass( 'image' ) || link.hasClass( 'extiw' ) ) {
return null;
}
var href = link.attr( 'href' );
// Red links in old parser format
if ( link.hasClass( 'new' ) ) {
const oldRedLink = mw.util.getParamValue( 'title', href );
if ( oldRedLink !== null ) {
return oldRedLink;
}
}
// Check that external links are coming from this wiki
if ( href.indexOf( '//' ) === 0 || href.indexOf( 'https://' ) === 0 ) {
try {
const fullUrl = href.indexOf( '//' ) === 0 ? location.protocol + href : href;
let urlData = new URL( fullUrl );
if ( urlData.hostname !== location.hostname ) {
return null;
}
} catch( e ) {
return null;
}
}
var articlePathRegexp = new RegExp(
mw.config.get( 'wgArticlePath' ).replace( '$1', '([^?]*)' )
);
var result = articlePathRegexp.exec( href );
if ( Array.isArray( result ) && result.length > 1 ) {
return decodeURIComponent( result[ 1 ] );
}
return null;
};
/*
* Check whether this is a disambiguation page
*/
const isDisam = function() {
const categories = mw.config.get( 'wgCategories', [] );
return cfg.disamCategories.some( c => categories.includes( c ) );
};
const secondsToHHMMSS = function( totalSeconds ) {
var hhmmss = '';
var hours = Math.floor( totalSeconds / 3600 );
var minutes = Math.floor( totalSeconds % 3600 / 60 );
var seconds = Math.floor( totalSeconds % 3600 % 60 );
if ( hours >= 1 ) {
hhmmss = pad( hours, '0', 2 ) + ':';
}
hhmmss += pad( minutes, '0', 2 ) + ':' + pad( seconds, '0', 2 );
return hhmmss;
};
const pad = function( str, z, width ) {
str = str.toString();
if ( str.length >= width ) {
return str;
} else {
return new Array( width - str.length + 1 ).join( z ) + str;
}
}
/*
* Create a new button
* text: Text that will be displayed on the button
* onClick: Function that will be called when the button is clicked
*/
const createButton = function( text, onClick ) {
var button = $( '<button>', { 'type': 'button' } );
button.text( text );
button.addClass( 'disamassist-button' ).click( onClick );
return button;
};
/*
* Given a page title and an array of possible redirects {from, to} ("canonical titles"), find the page
* at the end of the redirect chain, if there is one. Otherwise, return the page title that was passed
*/
const resolveRedirect = function( pageTitle, possibleRedirects ) {
var appliedRedirect = true;
var visitedPages = {};
var currentPage = getCanonicalTitle( pageTitle );
while ( appliedRedirect ) {
appliedRedirect = false;
for ( var ii = 0; ii < possibleRedirects.length; ii++ ) {
if ( possibleRedirects[ii].from === currentPage ) {
if ( visitedPages[possibleRedirects[ii].to] ) {
// Redirect chain detected
return pageTitle;
}
visitedPages[currentPage] = true;
appliedRedirect = true;
currentPage = possibleRedirects[ii].to;
}
}
}
// No redirect rules applied for an iteration of the outer loop:
// no more redirects. We are done
return currentPage;
};
/*
* Fetch the incoming links to a page. Returns a jQuery promise
* (success - array of titles of pages that contain links to the target page and
* array of "canonical titles" of possible destinations of the backlinks (either
* the target page or redirects to the target page), failure - error description)
* page: Target page
*/
const getBacklinks = function( page ) {
var dfd = new $.Deferred();
api.get( {
'action': 'query',
'list': 'backlinks',
'bltitle': page,
'blredirect': true,
'bllimit': cfg.backlinkLimit,
'blnamespace': cfg.targetNamespaces,
} ).done( function( data ) {
// There might be duplicate entries in some corner cases. We don't care,
// since we are going to check later, anyway
var backlinks = [];
var linkTitles = [getCanonicalTitle( page )];
$.each( data.query.backlinks, function() {
backlinks.push( this.title );
if ( this.redirlinks ) {
linkTitles.push( this.title );
$.each( this.redirlinks, function() {
backlinks.push( this.title );
} );
}
} );
dfd.resolve( backlinks, linkTitles );
} ).fail( function( code, data ) {
dfd.reject( txt.getBacklinksError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download a list of redirects for some pages. Returns a jQuery callback (success -
* array of redirects ({from, to}), failure - error description )
* pageTitles: Array of page titles
*/
const fetchRedirects = function( pageTitles ) {
var dfd = new $.Deferred();
var currentTitles = pageTitles.slice( 0, cfg.queryTitleLimit );
var restTitles = pageTitles.slice( cfg.queryTitleLimit );
api.get( {
action: 'query',
titles: currentTitles,
redirects: true,
} ).done( function( data ) {
var theseRedirects = data.query.redirects ? data.query.redirects : [];
if ( restTitles.length !== 0 ) {
fetchRedirects( restTitles ).done( function( redirects ) {
dfd.resolve( theseRedirects.concat( redirects ) );
} ).fail( function( description ) {
dfd.reject( description );
} );
} else {
dfd.resolve( theseRedirects );
}
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRedirectsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download the list of user rights for the current user. Returns a
* jQuery promise (success - array of right names, error - error description)
*/
const fetchRights = function() {
var dfd = $.Deferred();
api.get( {
action: 'query',
meta: 'userinfo',
uiprop: 'rights',
} ).done( function( data ) {
dfd.resolve( data.query.userinfo.rights );
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRightsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Load the raw page text for a given title. Returns a jQuery promise (success - page
* content, failure - error description)
* pageTitle: Title of the page
*/
const loadPage = function( pageTitle ) {
var dfd = new $.Deferred();
api.get( {
action: 'query',
titles: pageTitle,
prop: 'revisions',
rvprop: 'timestamp|content',
meta: 'tokens',
type: 'csrf',
} ).done( function( data ) {
var pages = data.query.pages;
for ( var key in pages ) {
if ( pages.hasOwnProperty( key ) ) {
break;
}
}
var rawPage = data.query.pages[key];
var page = {};
page.missing = rawPage.missing !== undefined;
if ( rawPage.revisions ) {
page.content = rawPage.revisions[0]['*'];
// Assume anything with # at the start is a redirect
if ( page.content.match( /^#[^\s]*?\s*\[\[/ ) ) {
page.redirect = true;
}
page.baseTimeStamp = rawPage.revisions[0].timestamp;
} else {
page.content = '';
page.baseTimeStamp = null;
}
page.startTimeStamp = rawPage.starttimestamp;
page.editToken = data.query.tokens.csrftoken;
dfd.resolve( page );
} ).fail( function( code, data ) {
dfd.reject( txt.loadPageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
/*
* Register changes to a page, to be saved later. Returns a jQuery promise
* (success - no params, failure - error description). Takes the same parameters
* as savePage
*/
const saveWithCooldown = function() {
var deferred = new $.Deferred();
pendingSaves.push( {args: arguments, dfd: deferred} );
if ( !runningSaves ) {
checkAndSave();
}
return deferred.promise();
};
/*
* Save the first set of changes in the list of pending changes, providing that
* enough time has passed since the last edit
*/
const checkAndSave = function() {
if ( pendingSaves.length === 0 ) {
runningSaves = false;
return;
}
runningSaves = true;
var millisSinceLast = new Date().getTime() - lastEditMillis;
if ( millisSinceLast < cfg.editCooldown * 1000 ) {
setTimeout( checkAndSave, cfg.editCooldown * 1000 - millisSinceLast );
} else {
// The last edit started at least cfg.editCooldown seconds ago
var save = pendingSaves.shift();
savePage.apply( this, save.args ).done( function() {
checkAndSave();
save.dfd.resolve();
} ).fail( function( description ) {
checkAndSave();
save.dfd.reject( description );
} );
// We'll use the time since the last edit started
lastEditMillis = new Date().getTime();
}
};
/*
* Save the changes made to a page. Returns a jQuery promise (success - no params,
* failure - error description)
* pageTitle: Title of the page
* page: Page data
* summary: Summary of the changes made to the page
* minorEdit: Whether to mark the edit as 'minor'
* botEdit: Whether to mark the edit as 'bot'
*/
const savePage = function( pageTitle, page, summary, minorEdit, botEdit ) {
var dfd = new $.Deferred();
api.post( {
action: 'edit',
title: pageTitle,
token: page.editToken,
text: page.content,
basetimestamp: page.baseTimeStamp,
starttimestamp: page.startTimeStamp,
summary: summary,
watchlist: cfg.watch ? cfg.watch : 'nochange',
assertuser: mw.config.get( 'wgUserName' ),
minor: minorEdit,
bot: botEdit,
} ).done( function() {
dfd.resolve();
} ).fail( function( code, data ) {
dfd.reject( txt.savePageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
install();
} )();
// </nowiki>
4c4ect8f7flheup7a450x2v5dzrb3vy
781194
781192
2026-10-27T16:06:26Z
Rots61
44293
781194
javascript
text/javascript
/*
* DisamAssist: a tool for repairing links from articles to disambiguation pages.
*/
// <nowiki>
// @ts-nocheck
( () => {
var cfg = {};
var txt = {};
var ui;
var links, pageChanges;
var currentPageTitle, currentPageParameters, currentLink;
var possibleBacklinkDestinations;
var titleRegex;
var forceSamePage = false;
var running = false;
var choosing = false;
var canMarkIntentionalLinks = false;
var displayedPages = {};
var editCount = 0;
var editLimit;
var pendingSaves = [];
var pendingEditBox = null;
var pendingEditBoxText;
var lastEditMillis = 0;
var runningSaves = false;
var api;
const dependencies = [
'mediawiki.api',
'mediawiki.confirmCloseWindow',
'mediawiki.Title',
'mediawiki.util',
];
/**
* Add an item in Tools menu
* @param {string} text
* @param {string} id
* @param {(e: MouseEvent) => void} callback
*/
const disamPortletLink = ( text, id, callback ) => {
const portlet = mw.util.addPortletLink(
'p-tb',
cfg.helpPage ? mw.util.getUrl( cfg.helpPage ) : '#',
text,
id
);
const link = portlet?.querySelector( 'a' );
link?.addEventListener( 'click', callback );
addButtonEvents( link );
}
/*
* Entry point. Check whether we are in a disambiguation page. If so, add a link to start the tool
*/
const install = function() {
cfg = window.DisamAssist.cfg;
txt = window.DisamAssist.txt;
if ( mw.config.get( 'wgAction' ) === 'view' && isDisam() ) {
mw.loader.using( dependencies, () => {
api = new mw.Api();
mw.loader.load( 'https://ru.wikipedia.org/w/index.php?title=Участник:Stjn/DisamAssist.css&action=raw&ctype=text/css', 'text/css' );
// This is a " (disambiguation)" page
if ( new RegExp( cfg.disamRegExp ).exec( getTitle() ) ) {
disamPortletLink( txt.startMain, 't-disamAssist-main', startMain );
disamPortletLink( txt.startSame, 't-disamAssist-same', startSame );
} else {
disamPortletLink( txt.start, 't-disamAssist-page', start );
}
} );
}
};
/*
* Start the tool. Display the UI and begin looking for links to fix
*/
const start = function( e ) {
e?.preventDefault();
if ( !running ) {
console.log(Date.now())
running = true;
links = [];
pageChanges = [];
displayedPages = {};
ensureDABExists().then( function( canMark ) {
canMarkIntentionalLinks = canMark;
createUI();
addUnloadConfirm();
markDisamOptions();
checkEditLimit().then( function() {
togglePendingEditBox( false );
doPage();
} );
} );
}
};
/*
* Start DisamAssist. Disambiguate incoming links to the current page, regardless
* of the title.
*/
const startSame = function( e ) {
e.preventDefault();
forceSamePage = true;
start( e );
};
/*
* Start DisamAssist. If the page title ends with " (disambiguation)", disambiguate
* links to the primary topic article. Otherwise, disambiguate links to the current
* page.
*/
const startMain = function( e ) {
e.preventDefault();
forceSamePage = false;
start( e );
};
/*
* Create and show the user interface.
*/
const createUI = function() {
ui = {
display: $( '<div>' ).addClass( 'disamassist-box disamassist-mainbox' ),
finishedMessage: $( '<div>' ).text( txt.noMoreLinks ).hide(),
pageTitleLine: $( '<span>' ).addClass( 'disamassist-pagetitleline' ),
pendingEditCounter: $( '<div>').addClass( 'disamassist-editcounter' ),
context: $( '<pre>' ).addClass( 'disamassist-context' ),
undoButton: createButton( txt.undo, undo ),
omitButton: createButton( txt.omit, omit ),
endButton: createButton( txt.close, saveAndEnd ),
refreshButton: createButton( txt.refresh, refresh ),
titleAsTextButton: createButton( txt.titleAsText, chooseTitleFromPrompt ),
intentionalLinkButton: canMarkIntentionalLinks ? createButton( txt.intentionalLink, chooseIntentionalLink ) : null,
disamNeededButton: cfg.disamNeededText ? createButton( txt.disamNeeded, chooseDisamNeeded ) : null,
removeLinkButton: createButton( txt.removeLink, chooseLinkRemoval )
};
var top = $( '<div>' ).addClass( 'disamassist-top' )
.append( [ui.pageTitleLine, ui.pendingEditCounter] );
var leftButtons = $( '<div>' ).addClass( 'disamassist-leftbuttons' )
.append( [ui.titleAsTextButton, ui.removeLinkButton, ui.intentionalLinkButton, ui.disamNeededButton, ui.omitButton] );
var rightButtons = $( '<div>' ).addClass( 'disamassist-rightbuttons' )
.append( [ui.undoButton, ui.refreshButton, ui.endButton] );
var allButtons = $( '<div>' ).addClass( 'disamassist-allbuttons' )
.append( [leftButtons, rightButtons] );
ui.display.append( [top, ui.finishedMessage, ui.context, allButtons] );
updateEditCounter();
toggleActionButtons( false );
// Insert the UI in the page
$( '#mw-content-text' ).before( ui.display );
ui.display.hide().fadeIn();
};
/**
* Add button role and related keyboard event
* @param {Element} el
*/
const addButtonEvents = ( el ) => {
el?.setAttribute( 'role', 'button' );
el?.addEventListener( 'keydown', function( e ) {
if ( [ 'Enter', 'Space' ].includes( e.code ) ) {
e.preventDefault();
this.click();
}
} );
}
/*
* If there are pending changes, show a confirm dialog before closing
*/
const addUnloadConfirm = function() {
mw.confirmCloseWindow( {
test: () => {
if ( running && checkActualChanges() ) {
return txt.pending;
} else if ( editCount !== 0 ) {
return txt.editInProgress;
}
}
} );
};
/*
* Mark the disambiguation options as such
*/
const markDisamOptions = function() {
var optionPageTitles = [];
var optionMarkers = [];
getDisamOptions().each( function() {
var link = $( this );
var title = extractPageName( link );
const optionMarker = document.createElement( 'a' );
optionMarker.classList.add( 'disamassist-optionmarker' );
optionMarker.href = link.attr( 'href' );
optionMarker.setAttribute( 'data-title', title );
optionMarker.textContent = txt.optionMarker;
optionMarker.addEventListener( 'click', ( e ) => {
e.preventDefault();
chooseReplacement( title );
} );
addButtonEvents( optionMarker );
link.after( optionMarker );
optionMarkers.push( optionMarker );
optionPageTitles.push( title );
} );
// Now check the disambiguation options and display a different message for those that are
// actually the same as the target page where the links go, as choosing those options doesn't really
// accomplish anything (except bypassing redirects, which might be useful in some cases)
var targetPage = getTargetPage();
fetchRedirects( optionPageTitles.concat( targetPage ) ).done( function( redirects ) {
var endTargetPage = resolveRedirect( targetPage, redirects );
for ( var ii = 0; ii < optionPageTitles.length; ii++ ) {
var endOptionTitle = resolveRedirect( optionPageTitles[ii], redirects );
if ( isSamePage( optionPageTitles[ii], targetPage ) ) {
$( optionMarkers[ii] ).text( txt.targetOptionMarker ).addClass( 'disamassist-curroptionmarker');
} else if ( isSamePage( endOptionTitle, endTargetPage ) ) {
$( optionMarkers[ii] ).text( txt.redirectOptionMarker ).addClass( 'disamassist-curroptionmarker');
}
}
} ).fail( error );
};
/*
* Check whether intentional links to disambiguation pages can be explicitly marked
* as such in this wiki. If so, ensure that a "Foo (disambiguation)" page exists.
* Returns a jQuery promise
*/
const ensureDABExists = function() {
var dfd = new $.Deferred();
var title = getTitle();
// That concept doesn't exist in this wiki.
if ( !cfg.intentionalLinkOption ) {
dfd.resolve( false );
// "Foo (disambiguation)" exists: it's the current page.
} else if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
dfd.resolve( true );
} else {
var disamTitle = cfg.disamFormat.replace( '$1', title );
loadPage( disamTitle ).done( function( page ) {
// "Foo (disambiguation)" doesn't exist.
if ( page.missing ) {
// We try to create it
page.content = cfg.redirectToDisam.replace( '$1', title );
var summary = txt.redirectSummary.replace( '$1', title );
savePage( disamTitle, page, summary, false, true ).done( function() {
dfd.resolve( true );
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
// It does exist
} else {
dfd.resolve( true );
}
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
}
return dfd.promise();
};
/*
* Check whether the edit cooldown applies and sets editLimit accordingly.
* Returns a jQuery promise
*/
const checkEditLimit = function() {
var dfd = new $.Deferred();
if ( cfg.editCooldown <= 0 ) {
editLimit = false;
dfd.resolve();
} else {
fetchRights().done( function( rights ) {
editLimit = !rights?.includes( 'bot' );
} ).fail( function( description ) {
error( description );
editLimit = true;
} ).always( function() {
dfd.resolve();
} );
}
return dfd.promise();
};
/*
* Find and ask the user to fix all the incoming links to the disambiguation ("target")
* page from a single "origin" page
*/
const doPage = function() {
if ( pageChanges.length > cfg.historySize ) {
applyChange( pageChanges.shift() );
}
if ( links.length === 0 ) {
var targetPage = getTargetPage();
getBacklinks( targetPage ).done( function( backlinks, pageTitles ) {
var pending = {};
$.each( pendingSaves, function() {
pending[this[0]] = true;
} );
possibleBacklinkDestinations = $.grep( pageTitles, function( t, ii) {
if ( t == targetPage ) {
return true;
}
return removeDisam(t) != targetPage;
} );
titleRegex = new RegExp('\\[\\[([ _]*?(?:' + possibleBacklinkDestinations.join('|').replaceAll('(', '\\(').replaceAll(')','\\)').replaceAll(' ', '[ _]') + ')[ _]*?)(#.*?)?(?:\\|(.*?))?\\]\\]', 'gi');
// Only incoming links from pages we haven't seen yet and we aren't currently
// saving (displayedPages is reset when the tool is closed and opened again,
// while the list of pending changes isn't; if the edit cooldown is disabled,
// it will be empty)
links = $.grep( backlinks, function( el, ii ) {
return !displayedPages[el] && !pending[el];
} );
if ( links.length === 0 ) {
updateContext();
} else {
doPage();
}
} ).fail( error );
} else {
currentPageTitle = links.shift();
displayedPages[currentPageTitle] = true;
toggleActionButtons( false );
loadPage( currentPageTitle ).done( function( data ) {
currentPageParameters = data;
currentLink = null;
doLink();
} ).fail( error );
}
};
/*
* Find and ask the user to fix a single incoming link to the disambiguation ("target")
* page
*/
const doLink = function() {
if (!currentPageParameters.redirect) {
currentLink = extractLinkToPage( currentPageParameters.content,
possibleBacklinkDestinations, currentLink ? currentLink.end : 0 );
if ( currentLink ) {
updateContext();
} else {
doPage();
}
} else {
doPage();
}
};
/*
* Replace the target of a link with a different one
* pageTitle: New link target
* extra: Additional text after the link (optional)
* summary: Change summary (optional)
*/
const chooseReplacement = function( pageTitle, extra, summary ) {
if ( choosing ) {
choosing = false;
if ( !summary ) {
if ( pageTitle ) {
summary = txt.summaryChanged.replace( '$1', pageTitle );
} else {
summary = txt.summaryOmitted;
}
}
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
if ( pageTitle && ( pageTitle !== getTargetPage() || extra ) ) {
if ( currentPageParameters.redirect ) {
currentLink.description = pageTitle;
}
currentPageParameters.content =
replaceLink( currentPageParameters.content, pageTitle, currentLink, extra || '' );
}
doLink();
}
};
/*
* Replace the link with an explicit link to the disambiguation page
*/
const chooseIntentionalLink = function() {
const targetPage = getTargetPage();
let title = cfg.disamFormat.replace( '$1', targetPage );
if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
title = targetPage;
}
chooseReplacement( title, '', txt.summaryIntentional );
};
/*
* Prompt for an alternative link target and use it as a replacement
*/
const chooseTitleFromPrompt = function() {
var title = prompt( txt.titleAsTextPrompt );
if ( title !== null ) {
chooseReplacement( title );
}
};
/*
* Remove the current link, leaving the text unchanged
*/
const chooseLinkRemoval = function() {
if ( choosing ) {
var summary = txt.summaryRemoved;
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
currentPageParameters.content = removeLink( currentPageParameters.content, currentLink );
doLink();
}
};
/*
* Add a "disambiguation needed" template after the link
*/
const chooseDisamNeeded = function() {
chooseReplacement( currentLink.title, cfg.disamNeededText, txt.summaryHelpNeeded );
};
/*
* Undo the last change
*/
const undo = function() {
if ( pageChanges.length !== 0 ) {
var lastPage = pageChanges[pageChanges.length - 1];
if ( currentPageTitle !== lastPage.title ) {
links.unshift( currentPageTitle );
currentPageTitle = lastPage.title;
}
currentPageParameters = lastPage.page;
currentPageParameters.content = lastPage.contentBefore.pop();
currentLink = lastPage.links.pop();
lastPage.summary.pop();
if ( lastPage.contentBefore.length === 0 ) {
pageChanges.pop();
}
updateContext();
}
};
/*
* Omit the current link without making a change
*/
const omit = function() {
chooseReplacement( null );
};
/*
* Save all the pending changes and restart the tool.
*/
const refresh = function() {
saveAndEnd();
start();
};
/*
* Enable or disable the buttons that can perform actions on a page or change the current link.
* enabled: Whether to enable or disable the buttons
*/
const toggleActionButtons = function( enabled ) {
var affectedButtons = [ui.omitButton, ui.titleAsTextButton, ui.removeLinkButton,
ui.intentionalLinkButton, ui.disamNeededButton, ui.undoButton];
$.each( affectedButtons, function( ii, button ) {
button?.prop( 'disabled', !enabled );
} );
};
/*
* Show or hide the 'no more links' message
* show: Whether to show or hide the message
*/
const toggleFinishedMessage = function( show ) {
toggleActionButtons( !show );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.finishedMessage.toggle( show );
ui.pageTitleLine.toggle( !show );
ui.context.toggle( !show );
if ( show ) console.log(Date.now())
};
const togglePendingEditBox = function( show ) {
if ( pendingEditBox === null ) {
pendingEditBox = $( '<div>' ).addClass( 'disamassist-box disamassist-pendingeditbox' );
pendingEditBoxText = $( '<div>' );
pendingEditBox.append( pendingEditBoxText ).hide();
if ( editLimit ) {
pendingEditBox.append( $( '<div>' ).text( txt.pendingEditBoxLimited )
.addClass( 'disamassist-subtitle' ) );
}
$( '#mw-content-text' ).before( pendingEditBox );
updateEditCounter();
}
if ( show ) {
pendingEditBox.fadeIn();
} else {
pendingEditBox.fadeOut();
}
};
const notifyCompletion = function() {
var oldTitle = document.title;
document.title = txt.notifyCharacter + document.title;
$( document.body ).one( 'mousemove', function() {
document.title = oldTitle;
} );
};
/*
* Update the displayed information to match the current link
* or lack thereof
*/
const updateContext = function() {
updateEditCounter();
if ( !currentLink ) {
toggleFinishedMessage( true );
} else {
ui.pageTitleLine.html( txt.pageTitleLine.replace( '$1',
mw.util.getUrl( currentPageTitle, {redirect: 'no'} ) ).replace( '$2', mw.html.escape( currentPageTitle ) ) );
var context = extractContext( currentPageParameters.content, currentLink );
ui.context.empty()
.append( $( '<span>' ).text( context[0] ) )
.append( $( '<span>' ).text( context[1] ).addClass( 'disamassist-inclink' ) )
.append( $( '<span>' ).text( context[2] ) );
toggleFinishedMessage( false );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.removeLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.intentionalLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.disamNeededButton?.prop( 'disabled', currentPageParameters.redirect || currentLink.hasDisamTemplate );
choosing = true;
}
};
/*
* Update the count of pending changes
*/
const updateEditCounter = function() {
if ( ui.pendingEditCounter ) {
ui.pendingEditCounter.text( txt.pendingEditCounter.replace( '$1', editCount )
.replace( '$2', countActuallyChangedFullyCheckedPages() ) );
}
if ( pendingEditBox ) {
if ( editCount === 0 && !running ) {
togglePendingEditBox( false );
notifyCompletion();
}
var textContent = editCount;
if ( editLimit ) {
textContent = txt.pendingEditBoxTimeEstimation.replace( '$1', editCount )
.replace( '$2', secondsToHHMMSS( cfg.editCooldown * editCount ) );
}
pendingEditBoxText.text( txt.pendingEditBox.replace( '$1', textContent ) );
}
};
/*
* Apply the changes made to an "origin" page
* pageChange: Change that will be saved
*/
const applyChange = function( pageChange ) {
if ( pageChange.page.content !== pageChange.contentBefore[0] ) {
editCount++;
// Filter duplicates and join
var changeSummaries = pageChange.summary
.filter( ( val, i, arr ) => val && arr.indexOf( val ) === i )
.join( txt.summarySeparator );
var summary = txt.summary.replace( '$1', getTargetPage() ).replace( '$2', changeSummaries );
var save = editLimit ? saveWithCooldown : savePage;
save( pageChange.title, pageChange.page, summary, true, true ).always( function() {
if ( editCount > 0 ) {
editCount--;
}
updateEditCounter();
} ).fail( error );
updateEditCounter();
}
};
/*
* Save all the pending changes
*/
const applyAllChanges = function() {
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
applyChange( pageChanges[ii] );
}
pageChanges = [];
};
/*
* Record a new pending change
* pageTitle: Title of the page
* page: Content of the page
* oldContent: Content of the page before the change
* link: Link that has been changed
* summary: Change summary
*/
const addChange = function( pageTitle, page, oldContent, link, summary ) {
if ( ( pageChanges.length === 0 ) || ( pageChanges[pageChanges.length - 1].title !== pageTitle ) ) {
pageChanges.push( {
title: pageTitle,
page: page,
contentBefore: [],
links: [],
summary: []
} );
}
var lastPageChange = pageChanges[pageChanges.length - 1];
lastPageChange.contentBefore.push( oldContent );
lastPageChange.links.push( link );
lastPageChange.summary.push( summary );
};
/*
* Check whether actual changes are stored in the history array
*/
const checkActualChanges = function() {
return countActualChanges() !== 0;
};
/*
* Return the number of entries in the history array that represent actual changes
*/
const countActualChanges = function() {
var changeCount = 0;
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
if ( pageChanges[ii].page.content !== pageChanges[ii].contentBefore[0] ) {
changeCount++;
}
}
return changeCount;
};
/*
* Return the number of changed pages in the history array, ignoring the last entry
* if we aren't done with that page yet
*/
const countActuallyChangedFullyCheckedPages = function() {
var changeCount = countActualChanges();
if ( pageChanges.length !== 0 ) {
var lastChange = pageChanges[pageChanges.length - 1];
if ( lastChange.title === currentPageTitle && currentLink !== null
&& lastChange.page.content !== lastChange.contentBefore[0] ) {
changeCount--;
}
}
return changeCount;
};
/*
* Find the links to disambiguation options in a disambiguation page
*/
const getDisamOptions = function() {
return $( '#mw-content-text a' ).filter( function() {
return !!extractPageName( $( this ) );
} );
};
/*
* Save all the pending changes and close the tool
*/
const saveAndEnd = function() {
applyAllChanges();
end();
};
/*
* Close the tool
*/
const end = function() {
var currentToolUI = ui.display;
choosing = false;
running = false;
$( '.disamassist-optionmarker' ).remove();
currentToolUI.fadeOut( { complete: function() {
currentToolUI.remove();
if ( editCount !== 0 ) {
togglePendingEditBox( true );
}
} } );
};
/*
* Display an error message
*/
const error = function( errorDescription ) {
var errorBox = $( '<div>' ).addClass( 'disamassist-box disamassist-errorbox' );
errorBox.text( txt.error.replace( '$1', errorDescription ) );
errorBox.append( createButton( txt.dismissError, function() {
errorBox.fadeOut();
} ).addClass( 'disamassist-errorbutton' ) );
var uiIsInPlace = ui && $.contains( document.documentElement, ui.display[0] );
var nextElement = uiIsInPlace ? ui.display : $( '#mw-content-text' );
nextElement.before( errorBox );
errorBox.hide().fadeIn();
}
/*
* Change a link so that it points to the title
* text: The wikitext of the whole page
* title: The new destination of the link
* link: The link that will be modified
* extra: Text that will be added after the link (optional)
*/
const replaceLink = function( text, title, link, extra ) {
var anchor = link.anchor || '';
var newContent;
if ( link.type === 'sortnaam' ) {
newContent = '{{sortnaam|' + link.first + '|' + link.last + '|' + title + anchor + '}}';
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + ( extra || '' ) + linkEnd;
}
if ( isSamePage( title, link.description ) ) {
newContent = link.description + anchor;
} else {
newContent = title + anchor + '|' + link.description;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + '[[' + newContent + ']]' + link.afterDescription + ( extra || '' ) + linkEnd;
};
/*
* Remove a link from the text
* text: The wikitext of the whole page
* link: The link that will be removed
*/
const removeLink = function( text, link ) {
var anchor = link.anchor || '';
if ( link.type === 'sortnaam' ) {
// Als er een derde parameter of anker was, laten we de derde parameter weg of behouden we alleen de basis
var newContent = '{{sortnaam|' + link.first + '|' + link.last + '}}' + anchor;
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + linkEnd;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + link.description + anchor + link.afterDescription + linkEnd;
};
/*
* Extract a link from a string in wiki format,
* starting from a given index. Return a link if one can be found,
* otherwise return null. The "link" includes "disambiguation needed"
* templates inmediately following the link proper
* text: Text from which the link will be extracted
* lastIndex: Index from which the search will start
*/
const extractLink = function( text, lastIndex ) {
// 1. Zoek naar gewone wikitlinks (inclusief optioneel anker #...)
titleRegex.lastIndex = lastIndex;
var wikiMatch = titleRegex.exec( text );
// 2. Zoek naar sortnaam-templates (inclusief optioneel anker na de derde parameter of achter de template)
var sortnaamRegex = new RegExp('{{\\s*sortnaam\\s*\\|\\s*([^|]+?)\\s*\\|\\s*([^|]+?)\\s*(?:\\|\\s*([^|#}]*?)\\s*)?(#[^|}]*)?}}', 'gi');
sortnaamRegex.lastIndex = lastIndex;
var sortnaamMatch = sortnaamRegex.exec( text );
var matchType = null;
var matchIndex = Infinity;
if ( wikiMatch !== null && wikiMatch.index >= lastIndex ) {
matchIndex = wikiMatch.index;
matchType = 'wiki';
}
if ( sortnaamMatch !== null && sortnaamMatch.index >= lastIndex && sortnaamMatch.index < matchIndex ) {
matchIndex = sortnaamMatch.index;
matchType = 'sortnaam';
}
if ( matchType === 'wiki' ) {
var possiblyAmbiguous = true;
var hasDisamTemplate = false;
// wikiMatch[2] bevat het anker als dat in de regex is opgenomen, of we vangen het op via de matchlengte
var fullMatchStr = wikiMatch[0];
var anchor = wikiMatch[2] ? wikiMatch[2] : '';
var end = wikiMatch.index + fullMatchStr.length;
var afterDescription = '';
var rest = text.substring( end );
var templateRegex = /^(\w*[.,:;?!)}\s]*){{\s*([^|{}]+?)\s*(?:\|[^{]*?)?}}/g;
var templateMatch = templateRegex.exec( rest );
if ( templateMatch !== null ) {
var templateTitle = getCanonicalTitle( templateMatch[2] );
if ( cfg.disamLinkTemplates?.includes( templateTitle ) ) {
end += templateMatch[0].length;
afterDescription = templateMatch[1].replace(/\s$/, '');
hasDisamTemplate = true;
} else if ( cfg.disamLinkIgnoreTemplates?.includes( templateTitle ) ) {
possiblyAmbiguous = false;
}
}
return {
type: 'wiki',
start: wikiMatch.index,
end: end,
possiblyAmbiguous: possiblyAmbiguous,
hasDisamTemplate: hasDisamTemplate,
title: wikiMatch[1],
description: wikiMatch[3] ? wikiMatch[3] : wikiMatch[1],
anchor: anchor,
afterDescription: afterDescription
};
} else if ( matchType === 'sortnaam' ) {
var first = sortnaamMatch[1].trim();
var last = sortnaamMatch[2].trim();
var explicitTarget = sortnaamMatch[3] ? sortnaamMatch[3].trim() : '';
var anchor = sortnaamMatch[4] ? sortnaamMatch[4].trim() : '';
var target = explicitTarget !== '' ? explicitTarget : ( first + ' ' + last );
return {
type: 'sortnaam',
start: sortnaamMatch.index,
end: sortnaamMatch.index + sortnaamMatch[0].length,
possiblyAmbiguous: true,
hasDisamTemplate: false,
title: target,
description: sortnaamMatch[0],
first: first,
last: last,
explicitTarget: explicitTarget,
anchor: anchor,
afterDescription: ''
};
}
return null;
};
/*
* Extract a link to one of a number of destination pages from a string
* ("text") in wiki format, starting from a given index ("lastIndex").
* "Disambiguation needed" templates are included as part of the links.
* text: Page in wiki format
* destinations: Array of page titles to look for
* lastIndex: Index from which the search will start
*/
const extractLinkToPage = function( text, destinations, lastIndex ) {
var link, title;
do {
link = extractLink( text, lastIndex );
if ( link !== null ) {
lastIndex = link.end;
title = getCanonicalTitle( link.title );
}
} while (
link !== null
&& ( !link.possiblyAmbiguous || !destinations?.includes( title ) )
);
return link;
};
/*
* Find the "target" page: either the one we are in or the "main" one found by extracting
* the title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const getTargetPage = function() {
var title = getTitle();
return forceSamePage ? title : removeDisam( title );
};
/*
* Get the page title, with the namespace prefix if any.
*/
const getTitle = function() {
return mw.config.get( 'wgPageName' ).replace(/_/g, ' ')
}
/*
* Extract a "main" title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const removeDisam = function( title ) {
var match = new RegExp( cfg.disamRegExp ).exec( title );
return match ? match[1] : title;
};
/*
* Check whether two page titles are the same
*/
const isSamePage = function( title1, title2 ) {
return getCanonicalTitle( title1 ) === getCanonicalTitle( title2 );
};
/*
* Return the 'canonical title' of a page
*/
const getCanonicalTitle = function( title ) {
try {
title = new mw.Title( title );
// Check that page name matches the current namespace
if ( title.getNamespaceId() !== mw.config.get( 'wgNamespaceNumber' ) ) {
return;
}
const fragment = title.getFragment();
return title.getPrefixedText() + ( fragment ? `#${ fragment }` : '' );
} catch ( ex ) {
// mw.Title seems to be buggy, and some valid titles are rejected
// FIXME: This may cause false negatives
}
};
/*
* Extract the context around a given link in a text string
*/
const extractContext = function( text, link ) {
var contextStart = link.start - cfg.radius;
var contextEnd = link.end + cfg.radius;
var contextPrev = text.substring( contextStart, link.start );
const ellipsis = '…';
if ( contextStart > 0 ) {
contextPrev = ellipsis + contextPrev;
}
var contextNext = text.substring( link.end, contextEnd );
if ( contextEnd < text.length ) {
contextNext = contextNext + ellipsis;
}
return [contextPrev, text.substring( link.start, link.end ), contextNext];
};
/*
* Extract the prefixed page name from a link
*/
const extractPageName = function( link ) {
var pageName = extractPageNameRaw( link );
if ( pageName ) {
return getCanonicalTitle( pageName );
}
return null;
};
/*
* Extract the page name from a link, as is
*/
const extractPageNameRaw = function( link ) {
// Ignore images and interwiki links
if ( link.hasClass( 'image' ) || link.hasClass( 'extiw' ) ) {
return null;
}
var href = link.attr( 'href' );
// Red links in old parser format
if ( link.hasClass( 'new' ) ) {
const oldRedLink = mw.util.getParamValue( 'title', href );
if ( oldRedLink !== null ) {
return oldRedLink;
}
}
// Check that external links are coming from this wiki
if ( href.indexOf( '//' ) === 0 || href.indexOf( 'https://' ) === 0 ) {
try {
const fullUrl = href.indexOf( '//' ) === 0 ? location.protocol + href : href;
let urlData = new URL( fullUrl );
if ( urlData.hostname !== location.hostname ) {
return null;
}
} catch( e ) {
return null;
}
}
var articlePathRegexp = new RegExp(
mw.config.get( 'wgArticlePath' ).replace( '$1', '([^?]*)' )
);
var result = articlePathRegexp.exec( href );
if ( Array.isArray( result ) && result.length > 1 ) {
return decodeURIComponent( result[ 1 ] );
}
return null;
};
/*
* Check whether this is a disambiguation page
*/
const isDisam = function() {
const categories = mw.config.get( 'wgCategories', [] );
return cfg.disamCategories.some( c => categories.includes( c ) );
};
const secondsToHHMMSS = function( totalSeconds ) {
var hhmmss = '';
var hours = Math.floor( totalSeconds / 3600 );
var minutes = Math.floor( totalSeconds % 3600 / 60 );
var seconds = Math.floor( totalSeconds % 3600 % 60 );
if ( hours >= 1 ) {
hhmmss = pad( hours, '0', 2 ) + ':';
}
hhmmss += pad( minutes, '0', 2 ) + ':' + pad( seconds, '0', 2 );
return hhmmss;
};
const pad = function( str, z, width ) {
str = str.toString();
if ( str.length >= width ) {
return str;
} else {
return new Array( width - str.length + 1 ).join( z ) + str;
}
}
/*
* Create a new button
* text: Text that will be displayed on the button
* onClick: Function that will be called when the button is clicked
*/
const createButton = function( text, onClick ) {
var button = $( '<button>', { 'type': 'button' } );
button.text( text );
button.addClass( 'disamassist-button' ).click( onClick );
return button;
};
/*
* Given a page title and an array of possible redirects {from, to} ("canonical titles"), find the page
* at the end of the redirect chain, if there is one. Otherwise, return the page title that was passed
*/
const resolveRedirect = function( pageTitle, possibleRedirects ) {
var appliedRedirect = true;
var visitedPages = {};
var currentPage = getCanonicalTitle( pageTitle );
while ( appliedRedirect ) {
appliedRedirect = false;
for ( var ii = 0; ii < possibleRedirects.length; ii++ ) {
if ( possibleRedirects[ii].from === currentPage ) {
if ( visitedPages[possibleRedirects[ii].to] ) {
// Redirect chain detected
return pageTitle;
}
visitedPages[currentPage] = true;
appliedRedirect = true;
currentPage = possibleRedirects[ii].to;
}
}
}
// No redirect rules applied for an iteration of the outer loop:
// no more redirects. We are done
return currentPage;
};
/*
* Fetch the incoming links to a page. Returns a jQuery promise
* (success - array of titles of pages that contain links to the target page and
* array of "canonical titles" of possible destinations of the backlinks (either
* the target page or redirects to the target page), failure - error description)
* page: Target page
*/
const getBacklinks = function( page ) {
var dfd = new $.Deferred();
api.get( {
'action': 'query',
'list': 'backlinks',
'bltitle': page,
'blredirect': true,
'bllimit': cfg.backlinkLimit,
'blnamespace': cfg.targetNamespaces,
} ).done( function( data ) {
// There might be duplicate entries in some corner cases. We don't care,
// since we are going to check later, anyway
var backlinks = [];
var linkTitles = [getCanonicalTitle( page )];
$.each( data.query.backlinks, function() {
backlinks.push( this.title );
if ( this.redirlinks ) {
linkTitles.push( this.title );
$.each( this.redirlinks, function() {
backlinks.push( this.title );
} );
}
} );
dfd.resolve( backlinks, linkTitles );
} ).fail( function( code, data ) {
dfd.reject( txt.getBacklinksError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download a list of redirects for some pages. Returns a jQuery callback (success -
* array of redirects ({from, to}), failure - error description )
* pageTitles: Array of page titles
*/
const fetchRedirects = function( pageTitles ) {
var dfd = new $.Deferred();
var currentTitles = pageTitles.slice( 0, cfg.queryTitleLimit );
var restTitles = pageTitles.slice( cfg.queryTitleLimit );
api.get( {
action: 'query',
titles: currentTitles,
redirects: true,
} ).done( function( data ) {
var theseRedirects = data.query.redirects ? data.query.redirects : [];
if ( restTitles.length !== 0 ) {
fetchRedirects( restTitles ).done( function( redirects ) {
dfd.resolve( theseRedirects.concat( redirects ) );
} ).fail( function( description ) {
dfd.reject( description );
} );
} else {
dfd.resolve( theseRedirects );
}
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRedirectsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download the list of user rights for the current user. Returns a
* jQuery promise (success - array of right names, error - error description)
*/
const fetchRights = function() {
var dfd = $.Deferred();
api.get( {
action: 'query',
meta: 'userinfo',
uiprop: 'rights',
} ).done( function( data ) {
dfd.resolve( data.query.userinfo.rights );
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRightsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Load the raw page text for a given title. Returns a jQuery promise (success - page
* content, failure - error description)
* pageTitle: Title of the page
*/
const loadPage = function( pageTitle ) {
var dfd = new $.Deferred();
api.get( {
action: 'query',
titles: pageTitle,
prop: 'revisions',
rvprop: 'timestamp|content',
meta: 'tokens',
type: 'csrf',
} ).done( function( data ) {
var pages = data.query.pages;
for ( var key in pages ) {
if ( pages.hasOwnProperty( key ) ) {
break;
}
}
var rawPage = data.query.pages[key];
var page = {};
page.missing = rawPage.missing !== undefined;
if ( rawPage.revisions ) {
page.content = rawPage.revisions[0]['*'];
// Assume anything with # at the start is a redirect
if ( page.content.match( /^#[^\s]*?\s*\[\[/ ) ) {
page.redirect = true;
}
page.baseTimeStamp = rawPage.revisions[0].timestamp;
} else {
page.content = '';
page.baseTimeStamp = null;
}
page.startTimeStamp = rawPage.starttimestamp;
page.editToken = data.query.tokens.csrftoken;
dfd.resolve( page );
} ).fail( function( code, data ) {
dfd.reject( txt.loadPageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
/*
* Register changes to a page, to be saved later. Returns a jQuery promise
* (success - no params, failure - error description). Takes the same parameters
* as savePage
*/
const saveWithCooldown = function() {
var deferred = new $.Deferred();
pendingSaves.push( {args: arguments, dfd: deferred} );
if ( !runningSaves ) {
checkAndSave();
}
return deferred.promise();
};
/*
* Save the first set of changes in the list of pending changes, providing that
* enough time has passed since the last edit
*/
const checkAndSave = function() {
if ( pendingSaves.length === 0 ) {
runningSaves = false;
return;
}
runningSaves = true;
var millisSinceLast = new Date().getTime() - lastEditMillis;
if ( millisSinceLast < cfg.editCooldown * 1000 ) {
setTimeout( checkAndSave, cfg.editCooldown * 1000 - millisSinceLast );
} else {
// The last edit started at least cfg.editCooldown seconds ago
var save = pendingSaves.shift();
savePage.apply( this, save.args ).done( function() {
checkAndSave();
save.dfd.resolve();
} ).fail( function( description ) {
checkAndSave();
save.dfd.reject( description );
} );
// We'll use the time since the last edit started
lastEditMillis = new Date().getTime();
}
};
/*
* Save the changes made to a page. Returns a jQuery promise (success - no params,
* failure - error description)
* pageTitle: Title of the page
* page: Page data
* summary: Summary of the changes made to the page
* minorEdit: Whether to mark the edit as 'minor'
* botEdit: Whether to mark the edit as 'bot'
*/
const savePage = function( pageTitle, page, summary, minorEdit, botEdit ) {
var dfd = new $.Deferred();
api.post( {
action: 'edit',
title: pageTitle,
token: page.editToken,
text: page.content,
basetimestamp: page.baseTimeStamp,
starttimestamp: page.startTimeStamp,
summary: summary,
watchlist: cfg.watch ? cfg.watch : 'nochange',
assertuser: mw.config.get( 'wgUserName' ),
minor: minorEdit,
bot: botEdit,
} ).done( function() {
dfd.resolve();
} ).fail( function( code, data ) {
dfd.reject( txt.savePageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
install();
} )();
// </nowiki>
hm77g6qklg9y3s1msf851l8ak2ujh4j
781195
781194
2026-10-27T16:16:11Z
Rots61
44293
781195
javascript
text/javascript
/*
* DisamAssist: a tool for repairing links from articles to disambiguation pages.
*/
// <nowiki>
// @ts-nocheck
( () => {
var cfg = {};
var txt = {};
var ui;
var links, pageChanges;
var currentPageTitle, currentPageParameters, currentLink;
var possibleBacklinkDestinations;
var titleRegex;
var forceSamePage = false;
var running = false;
var choosing = false;
var canMarkIntentionalLinks = false;
var displayedPages = {};
var editCount = 0;
var editLimit;
var pendingSaves = [];
var pendingEditBox = null;
var pendingEditBoxText;
var lastEditMillis = 0;
var runningSaves = false;
var api;
const dependencies = [
'mediawiki.api',
'mediawiki.confirmCloseWindow',
'mediawiki.Title',
'mediawiki.util',
];
/**
* Add an item in Tools menu
* @param {string} text
* @param {string} id
* @param {(e: MouseEvent) => void} callback
*/
const disamPortletLink = ( text, id, callback ) => {
const portlet = mw.util.addPortletLink(
'p-tb',
cfg.helpPage ? mw.util.getUrl( cfg.helpPage ) : '#',
text,
id
);
const link = portlet?.querySelector( 'a' );
link?.addEventListener( 'click', callback );
addButtonEvents( link );
}
/*
* Entry point. Check whether we are in a disambiguation page. If so, add a link to start the tool
*/
const install = function() {
cfg = window.DisamAssist.cfg;
txt = window.DisamAssist.txt;
if ( mw.config.get( 'wgAction' ) === 'view' && isDisam() ) {
mw.loader.using( dependencies, () => {
api = new mw.Api();
mw.loader.load( 'https://ru.wikipedia.org/w/index.php?title=Участник:Stjn/DisamAssist.css&action=raw&ctype=text/css', 'text/css' );
// This is a " (disambiguation)" page
if ( new RegExp( cfg.disamRegExp ).exec( getTitle() ) ) {
disamPortletLink( txt.startMain, 't-disamAssist-main', startMain );
disamPortletLink( txt.startSame, 't-disamAssist-same', startSame );
} else {
disamPortletLink( txt.start, 't-disamAssist-page', start );
}
} );
}
};
/*
* Start the tool. Display the UI and begin looking for links to fix
*/
const start = function( e ) {
e?.preventDefault();
if ( !running ) {
console.log(Date.now())
running = true;
links = [];
pageChanges = [];
displayedPages = {};
ensureDABExists().then( function( canMark ) {
canMarkIntentionalLinks = canMark;
createUI();
addUnloadConfirm();
markDisamOptions();
checkEditLimit().then( function() {
togglePendingEditBox( false );
doPage();
} );
} );
}
};
/*
* Start DisamAssist. Disambiguate incoming links to the current page, regardless
* of the title.
*/
const startSame = function( e ) {
e.preventDefault();
forceSamePage = true;
start( e );
};
/*
* Start DisamAssist. If the page title ends with " (disambiguation)", disambiguate
* links to the primary topic article. Otherwise, disambiguate links to the current
* page.
*/
const startMain = function( e ) {
e.preventDefault();
forceSamePage = false;
start( e );
};
/*
* Create and show the user interface.
*/
const createUI = function() {
ui = {
display: $( '<div>' ).addClass( 'disamassist-box disamassist-mainbox' ),
finishedMessage: $( '<div>' ).text( txt.noMoreLinks ).hide(),
pageTitleLine: $( '<span>' ).addClass( 'disamassist-pagetitleline' ),
pendingEditCounter: $( '<div>').addClass( 'disamassist-editcounter' ),
context: $( '<pre>' ).addClass( 'disamassist-context' ),
undoButton: createButton( txt.undo, undo ),
omitButton: createButton( txt.omit, omit ),
endButton: createButton( txt.close, saveAndEnd ),
refreshButton: createButton( txt.refresh, refresh ),
titleAsTextButton: createButton( txt.titleAsText, chooseTitleFromPrompt ),
intentionalLinkButton: canMarkIntentionalLinks ? createButton( txt.intentionalLink, chooseIntentionalLink ) : null,
disamNeededButton: cfg.disamNeededText ? createButton( txt.disamNeeded, chooseDisamNeeded ) : null,
removeLinkButton: createButton( txt.removeLink, chooseLinkRemoval )
};
var top = $( '<div>' ).addClass( 'disamassist-top' )
.append( [ui.pageTitleLine, ui.pendingEditCounter] );
var leftButtons = $( '<div>' ).addClass( 'disamassist-leftbuttons' )
.append( [ui.titleAsTextButton, ui.removeLinkButton, ui.intentionalLinkButton, ui.disamNeededButton, ui.omitButton] );
var rightButtons = $( '<div>' ).addClass( 'disamassist-rightbuttons' )
.append( [ui.undoButton, ui.refreshButton, ui.endButton] );
var allButtons = $( '<div>' ).addClass( 'disamassist-allbuttons' )
.append( [leftButtons, rightButtons] );
ui.display.append( [top, ui.finishedMessage, ui.context, allButtons] );
updateEditCounter();
toggleActionButtons( false );
// Insert the UI in the page
$( '#mw-content-text' ).before( ui.display );
ui.display.hide().fadeIn();
};
/**
* Add button role and related keyboard event
* @param {Element} el
*/
const addButtonEvents = ( el ) => {
el?.setAttribute( 'role', 'button' );
el?.addEventListener( 'keydown', function( e ) {
if ( [ 'Enter', 'Space' ].includes( e.code ) ) {
e.preventDefault();
this.click();
}
} );
}
/*
* If there are pending changes, show a confirm dialog before closing
*/
const addUnloadConfirm = function() {
mw.confirmCloseWindow( {
test: () => {
if ( running && checkActualChanges() ) {
return txt.pending;
} else if ( editCount !== 0 ) {
return txt.editInProgress;
}
}
} );
};
/*
* Mark the disambiguation options as such
*/
const markDisamOptions = function() {
var optionPageTitles = [];
var optionMarkers = [];
getDisamOptions().each( function() {
var link = $( this );
var title = extractPageName( link );
const optionMarker = document.createElement( 'a' );
optionMarker.classList.add( 'disamassist-optionmarker' );
optionMarker.href = link.attr( 'href' );
optionMarker.setAttribute( 'data-title', title );
optionMarker.textContent = txt.optionMarker;
optionMarker.addEventListener( 'click', ( e ) => {
e.preventDefault();
chooseReplacement( title );
} );
addButtonEvents( optionMarker );
link.after( optionMarker );
optionMarkers.push( optionMarker );
optionPageTitles.push( title );
} );
// Now check the disambiguation options and display a different message for those that are
// actually the same as the target page where the links go, as choosing those options doesn't really
// accomplish anything (except bypassing redirects, which might be useful in some cases)
var targetPage = getTargetPage();
fetchRedirects( optionPageTitles.concat( targetPage ) ).done( function( redirects ) {
var endTargetPage = resolveRedirect( targetPage, redirects );
for ( var ii = 0; ii < optionPageTitles.length; ii++ ) {
var endOptionTitle = resolveRedirect( optionPageTitles[ii], redirects );
if ( isSamePage( optionPageTitles[ii], targetPage ) ) {
$( optionMarkers[ii] ).text( txt.targetOptionMarker ).addClass( 'disamassist-curroptionmarker');
} else if ( isSamePage( endOptionTitle, endTargetPage ) ) {
$( optionMarkers[ii] ).text( txt.redirectOptionMarker ).addClass( 'disamassist-curroptionmarker');
}
}
} ).fail( error );
};
/*
* Check whether intentional links to disambiguation pages can be explicitly marked
* as such in this wiki. If so, ensure that a "Foo (disambiguation)" page exists.
* Returns a jQuery promise
*/
const ensureDABExists = function() {
var dfd = new $.Deferred();
var title = getTitle();
// That concept doesn't exist in this wiki.
if ( !cfg.intentionalLinkOption ) {
dfd.resolve( false );
// "Foo (disambiguation)" exists: it's the current page.
} else if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
dfd.resolve( true );
} else {
var disamTitle = cfg.disamFormat.replace( '$1', title );
loadPage( disamTitle ).done( function( page ) {
// "Foo (disambiguation)" doesn't exist.
if ( page.missing ) {
// We try to create it
page.content = cfg.redirectToDisam.replace( '$1', title );
var summary = txt.redirectSummary.replace( '$1', title );
savePage( disamTitle, page, summary, false, true ).done( function() {
dfd.resolve( true );
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
// It does exist
} else {
dfd.resolve( true );
}
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
}
return dfd.promise();
};
/*
* Check whether the edit cooldown applies and sets editLimit accordingly.
* Returns a jQuery promise
*/
const checkEditLimit = function() {
var dfd = new $.Deferred();
if ( cfg.editCooldown <= 0 ) {
editLimit = false;
dfd.resolve();
} else {
fetchRights().done( function( rights ) {
editLimit = !rights?.includes( 'bot' );
} ).fail( function( description ) {
error( description );
editLimit = true;
} ).always( function() {
dfd.resolve();
} );
}
return dfd.promise();
};
/*
* Find and ask the user to fix all the incoming links to the disambiguation ("target")
* page from a single "origin" page
*/
const doPage = function() {
if ( pageChanges.length > cfg.historySize ) {
applyChange( pageChanges.shift() );
}
if ( links.length === 0 ) {
var targetPage = getTargetPage();
getBacklinks( targetPage ).done( function( backlinks, pageTitles ) {
var pending = {};
$.each( pendingSaves, function() {
pending[this[0]] = true;
} );
possibleBacklinkDestinations = $.grep( pageTitles, function( t, ii) {
if ( t == targetPage ) {
return true;
}
return removeDisam(t) != targetPage;
} );
titleRegex = new RegExp('\\[\\[([ _]*?(?:' + possibleBacklinkDestinations.join('|').replaceAll('(', '\\(').replaceAll(')','\\)').replaceAll(' ', '[ _]') + ')[ _]*?)(#.*?)?(?:\\|(.*?))?\\]\\]', 'gi');
// Only incoming links from pages we haven't seen yet and we aren't currently
// saving (displayedPages is reset when the tool is closed and opened again,
// while the list of pending changes isn't; if the edit cooldown is disabled,
// it will be empty)
links = $.grep( backlinks, function( el, ii ) {
return !displayedPages[el] && !pending[el];
} );
if ( links.length === 0 ) {
updateContext();
} else {
doPage();
}
} ).fail( error );
} else {
currentPageTitle = links.shift();
displayedPages[currentPageTitle] = true;
toggleActionButtons( false );
loadPage( currentPageTitle ).done( function( data ) {
currentPageParameters = data;
currentLink = null;
doLink();
} ).fail( error );
}
};
/*
* Find and ask the user to fix a single incoming link to the disambiguation ("target")
* page
*/
const doLink = function() {
if (!currentPageParameters.redirect) {
currentLink = extractLinkToPage( currentPageParameters.content,
possibleBacklinkDestinations, currentLink ? currentLink.end : 0 );
if ( currentLink ) {
updateContext();
} else {
doPage();
}
} else {
doPage();
}
};
/*
* Replace the target of a link with a different one
* pageTitle: New link target
* extra: Additional text after the link (optional)
* summary: Change summary (optional)
*/
const chooseReplacement = function( pageTitle, extra, summary ) {
if ( choosing ) {
choosing = false;
if ( !summary ) {
if ( pageTitle ) {
summary = txt.summaryChanged.replace( '$1', pageTitle );
} else {
summary = txt.summaryOmitted;
}
}
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
if ( pageTitle && ( pageTitle !== getTargetPage() || extra ) ) {
if ( currentPageParameters.redirect ) {
currentLink.description = pageTitle;
}
currentPageParameters.content =
replaceLink( currentPageParameters.content, pageTitle, currentLink, extra || '' );
}
doLink();
}
};
/*
* Replace the link with an explicit link to the disambiguation page
*/
const chooseIntentionalLink = function() {
const targetPage = getTargetPage();
let title = cfg.disamFormat.replace( '$1', targetPage );
if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
title = targetPage;
}
chooseReplacement( title, '', txt.summaryIntentional );
};
/*
* Prompt for an alternative link target and use it as a replacement
*/
const chooseTitleFromPrompt = function() {
var title = prompt( txt.titleAsTextPrompt );
if ( title !== null ) {
chooseReplacement( title );
}
};
/*
* Remove the current link, leaving the text unchanged
*/
const chooseLinkRemoval = function() {
if ( choosing ) {
var summary = txt.summaryRemoved;
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
currentPageParameters.content = removeLink( currentPageParameters.content, currentLink );
doLink();
}
};
/*
* Add a "disambiguation needed" template after the link
*/
const chooseDisamNeeded = function() {
chooseReplacement( currentLink.title, cfg.disamNeededText, txt.summaryHelpNeeded );
};
/*
* Undo the last change
*/
const undo = function() {
if ( pageChanges.length !== 0 ) {
var lastPage = pageChanges[pageChanges.length - 1];
if ( currentPageTitle !== lastPage.title ) {
links.unshift( currentPageTitle );
currentPageTitle = lastPage.title;
}
currentPageParameters = lastPage.page;
currentPageParameters.content = lastPage.contentBefore.pop();
currentLink = lastPage.links.pop();
lastPage.summary.pop();
if ( lastPage.contentBefore.length === 0 ) {
pageChanges.pop();
}
updateContext();
}
};
/*
* Omit the current link without making a change
*/
const omit = function() {
chooseReplacement( null );
};
/*
* Save all the pending changes and restart the tool.
*/
const refresh = function() {
saveAndEnd();
start();
};
/*
* Enable or disable the buttons that can perform actions on a page or change the current link.
* enabled: Whether to enable or disable the buttons
*/
const toggleActionButtons = function( enabled ) {
var affectedButtons = [ui.omitButton, ui.titleAsTextButton, ui.removeLinkButton,
ui.intentionalLinkButton, ui.disamNeededButton, ui.undoButton];
$.each( affectedButtons, function( ii, button ) {
button?.prop( 'disabled', !enabled );
} );
};
/*
* Show or hide the 'no more links' message
* show: Whether to show or hide the message
*/
const toggleFinishedMessage = function( show ) {
toggleActionButtons( !show );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.finishedMessage.toggle( show );
ui.pageTitleLine.toggle( !show );
ui.context.toggle( !show );
if ( show ) console.log(Date.now())
};
const togglePendingEditBox = function( show ) {
if ( pendingEditBox === null ) {
pendingEditBox = $( '<div>' ).addClass( 'disamassist-box disamassist-pendingeditbox' );
pendingEditBoxText = $( '<div>' );
pendingEditBox.append( pendingEditBoxText ).hide();
if ( editLimit ) {
pendingEditBox.append( $( '<div>' ).text( txt.pendingEditBoxLimited )
.addClass( 'disamassist-subtitle' ) );
}
$( '#mw-content-text' ).before( pendingEditBox );
updateEditCounter();
}
if ( show ) {
pendingEditBox.fadeIn();
} else {
pendingEditBox.fadeOut();
}
};
const notifyCompletion = function() {
var oldTitle = document.title;
document.title = txt.notifyCharacter + document.title;
$( document.body ).one( 'mousemove', function() {
document.title = oldTitle;
} );
};
/*
* Update the displayed information to match the current link
* or lack thereof
*/
const updateContext = function() {
updateEditCounter();
if ( !currentLink ) {
toggleFinishedMessage( true );
} else {
ui.pageTitleLine.html( txt.pageTitleLine.replace( '$1',
mw.util.getUrl( currentPageTitle, {redirect: 'no'} ) ).replace( '$2', mw.html.escape( currentPageTitle ) ) );
var context = extractContext( currentPageParameters.content, currentLink );
ui.context.empty()
.append( $( '<span>' ).text( context[0] ) )
.append( $( '<span>' ).text( context[1] ).addClass( 'disamassist-inclink' ) )
.append( $( '<span>' ).text( context[2] ) );
toggleFinishedMessage( false );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.removeLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.intentionalLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.disamNeededButton?.prop( 'disabled', currentPageParameters.redirect || currentLink.hasDisamTemplate );
choosing = true;
}
};
/*
* Update the count of pending changes
*/
const updateEditCounter = function() {
if ( ui.pendingEditCounter ) {
ui.pendingEditCounter.text( txt.pendingEditCounter.replace( '$1', editCount )
.replace( '$2', countActuallyChangedFullyCheckedPages() ) );
}
if ( pendingEditBox ) {
if ( editCount === 0 && !running ) {
togglePendingEditBox( false );
notifyCompletion();
}
var textContent = editCount;
if ( editLimit ) {
textContent = txt.pendingEditBoxTimeEstimation.replace( '$1', editCount )
.replace( '$2', secondsToHHMMSS( cfg.editCooldown * editCount ) );
}
pendingEditBoxText.text( txt.pendingEditBox.replace( '$1', textContent ) );
}
};
/*
* Apply the changes made to an "origin" page
* pageChange: Change that will be saved
*/
const applyChange = function( pageChange ) {
if ( pageChange.page.content !== pageChange.contentBefore[0] ) {
editCount++;
// Filter duplicates and join
var changeSummaries = pageChange.summary
.filter( ( val, i, arr ) => val && arr.indexOf( val ) === i )
.join( txt.summarySeparator );
var summary = txt.summary.replace( '$1', getTargetPage() ).replace( '$2', changeSummaries );
var save = editLimit ? saveWithCooldown : savePage;
save( pageChange.title, pageChange.page, summary, true, true ).always( function() {
if ( editCount > 0 ) {
editCount--;
}
updateEditCounter();
} ).fail( error );
updateEditCounter();
}
};
/*
* Save all the pending changes
*/
const applyAllChanges = function() {
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
applyChange( pageChanges[ii] );
}
pageChanges = [];
};
/*
* Record a new pending change
* pageTitle: Title of the page
* page: Content of the page
* oldContent: Content of the page before the change
* link: Link that has been changed
* summary: Change summary
*/
const addChange = function( pageTitle, page, oldContent, link, summary ) {
if ( ( pageChanges.length === 0 ) || ( pageChanges[pageChanges.length - 1].title !== pageTitle ) ) {
pageChanges.push( {
title: pageTitle,
page: page,
contentBefore: [],
links: [],
summary: []
} );
}
var lastPageChange = pageChanges[pageChanges.length - 1];
lastPageChange.contentBefore.push( oldContent );
lastPageChange.links.push( link );
lastPageChange.summary.push( summary );
};
/*
* Check whether actual changes are stored in the history array
*/
const checkActualChanges = function() {
return countActualChanges() !== 0;
};
/*
* Return the number of entries in the history array that represent actual changes
*/
const countActualChanges = function() {
var changeCount = 0;
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
if ( pageChanges[ii].page.content !== pageChanges[ii].contentBefore[0] ) {
changeCount++;
}
}
return changeCount;
};
/*
* Return the number of changed pages in the history array, ignoring the last entry
* if we aren't done with that page yet
*/
const countActuallyChangedFullyCheckedPages = function() {
var changeCount = countActualChanges();
if ( pageChanges.length !== 0 ) {
var lastChange = pageChanges[pageChanges.length - 1];
if ( lastChange.title === currentPageTitle && currentLink !== null
&& lastChange.page.content !== lastChange.contentBefore[0] ) {
changeCount--;
}
}
return changeCount;
};
/*
* Find the links to disambiguation options in a disambiguation page
*/
const getDisamOptions = function() {
return $( '#mw-content-text a' ).filter( function() {
return !!extractPageName( $( this ) );
} );
};
/*
* Save all the pending changes and close the tool
*/
const saveAndEnd = function() {
applyAllChanges();
end();
};
/*
* Close the tool
*/
const end = function() {
var currentToolUI = ui.display;
choosing = false;
running = false;
$( '.disamassist-optionmarker' ).remove();
currentToolUI.fadeOut( { complete: function() {
currentToolUI.remove();
if ( editCount !== 0 ) {
togglePendingEditBox( true );
}
} } );
};
/*
* Display an error message
*/
const error = function( errorDescription ) {
var errorBox = $( '<div>' ).addClass( 'disamassist-box disamassist-errorbox' );
errorBox.text( txt.error.replace( '$1', errorDescription ) );
errorBox.append( createButton( txt.dismissError, function() {
errorBox.fadeOut();
} ).addClass( 'disamassist-errorbutton' ) );
var uiIsInPlace = ui && $.contains( document.documentElement, ui.display[0] );
var nextElement = uiIsInPlace ? ui.display : $( '#mw-content-text' );
nextElement.before( errorBox );
errorBox.hide().fadeIn();
}
/*
* Change a link so that it points to the title
* text: The wikitext of the whole page
* title: The new destination of the link
* link: The link that will be modified
* extra: Text that will be added after the link (optional)
*/
const replaceLink = function( text, title, link, extra ) {
var anchor = link.anchor || '';
var newContent;
if ( link.type === 'sortnaam' ) {
newContent = '{{sortnaam|' + link.first + '|' + link.last + '|' + title + anchor + '}}';
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + ( extra || '' ) + linkEnd;
}
if ( link.type === 'ill' ) {
newContent = '{{ill|' + title + anchor + '|' + link.lang + '|' + link.other + ( link.rest || '' ) + '}}';
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + ( extra || '' ) + linkEnd;
}
if ( isSamePage( title, link.description ) ) {
newContent = link.description + anchor;
} else {
newContent = title + anchor + '|' + link.description;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + '[[' + newContent + ']]' + link.afterDescription + ( extra || '' ) + linkEnd;
};
/*
* Remove a link from the text
* text: The wikitext of the whole page
* link: The link that will be removed
*/
const removeLink = function( text, link ) {
var anchor = link.anchor || '';
if ( link.type === 'sortnaam' ) {
var newContent = '{{sortnaam|' + link.first + '|' + link.last + '}}' + anchor;
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + linkEnd;
}
if ( link.type === 'ill' ) {
var newContent = link.title + anchor;
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + linkEnd;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + link.description + anchor + link.afterDescription + linkEnd;
};
/*
* Extract a link from a string in wiki format,
* starting from a given index. Return a link if one can be found,
* otherwise return null. The "link" includes "disambiguation needed"
* templates inmediately following the link proper
* text: Text from which the link will be extracted
* lastIndex: Index from which the search will start
*/
const extractLink = function( text, lastIndex ) {
// 1. Zoek naar gewone wikitlinks (inclusief optioneel anker #...)
titleRegex.lastIndex = lastIndex;
var wikiMatch = titleRegex.exec( text );
// 2. Zoek naar sortnaam-templates (inclusief optioneel anker na de derde parameter of achter de template)
var sortnaamRegex = new RegExp('{{\\s*sortnaam\\s*\\|\\s*([^|]+?)\\s*\\|\\s*([^|]+?)\\s*(?:\\|\\s*([^|#}]*?)\\s*)?(#[^|}]*)?}}', 'gi');
sortnaamRegex.lastIndex = lastIndex;
var sortnaamMatch = sortnaamRegex.exec( text );
// 3. Zoek naar ill-templates ({{ill|Paginalink|taal|anderetaallink}})
var illRegex = new RegExp('{{\\s*ill\\s*\\|\\s*([^|#}]+?)(#[^|#}]*)?\\s*\\|\\s*([^|#}]+?)\\s*\\|\\s*([^|#}]+?)([^}]*)?}}', 'gi');
illRegex.lastIndex = lastIndex;
var illMatch = illRegex.exec( text );
var matchType = null;
var matchIndex = Infinity;
if ( wikiMatch !== null && wikiMatch.index >= lastIndex ) {
matchIndex = wikiMatch.index;
matchType = 'wiki';
}
if ( sortnaamMatch !== null && sortnaamMatch.index >= lastIndex && sortnaamMatch.index < matchIndex ) {
matchIndex = sortnaamMatch.index;
matchType = 'sortnaam';
}
if ( illMatch !== null && illMatch.index >= lastIndex && illMatch.index < matchIndex ) {
matchIndex = illMatch.index;
matchType = 'ill';
}
if ( matchType === 'wiki' ) {
var possiblyAmbiguous = true;
var hasDisamTemplate = false;
var fullMatchStr = wikiMatch[0];
var anchor = wikiMatch[2] ? wikiMatch[2] : '';
var end = wikiMatch.index + fullMatchStr.length;
var afterDescription = '';
var rest = text.substring( end );
var templateRegex = /^(\w*[.,:;?!)}\s]*){{\s*([^|{}]+?)\s*(?:\|[^{]*?)?}}/g;
var templateMatch = templateRegex.exec( rest );
if ( templateMatch !== null ) {
var templateTitle = getCanonicalTitle( templateMatch[2] );
if ( cfg.disamLinkTemplates?.includes( templateTitle ) ) {
end += templateMatch[0].length;
afterDescription = templateMatch[1].replace(/\s$/, '');
hasDisamTemplate = true;
} else if ( cfg.disamLinkIgnoreTemplates?.includes( templateTitle ) ) {
possiblyAmbiguous = false;
}
}
return {
type: 'wiki',
start: wikiMatch.index,
end: end,
possiblyAmbiguous: possiblyAmbiguous,
hasDisamTemplate: hasDisamTemplate,
title: wikiMatch[1],
description: wikiMatch[3] ? wikiMatch[3] : wikiMatch[1],
anchor: anchor,
afterDescription: afterDescription
};
} else if ( matchType === 'sortnaam' ) {
var first = sortnaamMatch[1].trim();
var last = sortnaamMatch[2].trim();
var explicitTarget = sortnaamMatch[3] ? sortnaamMatch[3].trim() : '';
var anchor = sortnaamMatch[4] ? sortnaamMatch[4].trim() : '';
var target = explicitTarget !== '' ? explicitTarget : ( first + ' ' + last );
return {
type: 'sortnaam',
start: sortnaamMatch.index,
end: sortnaamMatch.index + sortnaamMatch[0].length,
possiblyAmbiguous: true,
hasDisamTemplate: false,
title: target,
description: sortnaamMatch[0],
first: first,
last: last,
explicitTarget: explicitTarget,
anchor: anchor,
afterDescription: ''
};
} else if ( matchType === 'ill' ) {
var title = illMatch[1].trim();
var anchor = illMatch[2] ? illMatch[2].trim() : '';
var lang = illMatch[3].trim();
var other = illMatch[4].trim();
var rest = illMatch[5] ? illMatch[5] : '';
return {
type: 'ill',
start: illMatch.index,
end: illMatch.index + illMatch[0].length,
possiblyAmbiguous: true,
hasDisamTemplate: false,
title: title,
description: title,
anchor: anchor,
lang: lang,
other: other,
rest: rest,
afterDescription: ''
};
}
return null;
};
/*
* Extract a link to one of a number of destination pages from a string
* ("text") in wiki format, starting from a given index ("lastIndex").
* "Disambiguation needed" templates are included as part of the links.
* text: Page in wiki format
* destinations: Array of page titles to look for
* lastIndex: Index from which the search will start
*/
const extractLinkToPage = function( text, destinations, lastIndex ) {
var link, title;
do {
link = extractLink( text, lastIndex );
if ( link !== null ) {
lastIndex = link.end;
title = getCanonicalTitle( link.title );
}
} while (
link !== null
&& ( !link.possiblyAmbiguous || !destinations?.includes( title ) )
);
return link;
};
/*
* Find the "target" page: either the one we are in or the "main" one found by extracting
* the title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const getTargetPage = function() {
var title = getTitle();
return forceSamePage ? title : removeDisam( title );
};
/*
* Get the page title, with the namespace prefix if any.
*/
const getTitle = function() {
return mw.config.get( 'wgPageName' ).replace(/_/g, ' ')
}
/*
* Extract a "main" title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const removeDisam = function( title ) {
var match = new RegExp( cfg.disamRegExp ).exec( title );
return match ? match[1] : title;
};
/*
* Check whether two page titles are the same
*/
const isSamePage = function( title1, title2 ) {
return getCanonicalTitle( title1 ) === getCanonicalTitle( title2 );
};
/*
* Return the 'canonical title' of a page
*/
const getCanonicalTitle = function( title ) {
try {
title = new mw.Title( title );
// Check that page name matches the current namespace
if ( title.getNamespaceId() !== mw.config.get( 'wgNamespaceNumber' ) ) {
return;
}
const fragment = title.getFragment();
return title.getPrefixedText() + ( fragment ? `#${ fragment }` : '' );
} catch ( ex ) {
// mw.Title seems to be buggy, and some valid titles are rejected
// FIXME: This may cause false negatives
}
};
/*
* Extract the context around a given link in a text string
*/
const extractContext = function( text, link ) {
var contextStart = link.start - cfg.radius;
var contextEnd = link.end + cfg.radius;
var contextPrev = text.substring( contextStart, link.start );
const ellipsis = '…';
if ( contextStart > 0 ) {
contextPrev = ellipsis + contextPrev;
}
var contextNext = text.substring( link.end, contextEnd );
if ( contextEnd < text.length ) {
contextNext = contextNext + ellipsis;
}
return [contextPrev, text.substring( link.start, link.end ), contextNext];
};
/*
* Extract the prefixed page name from a link
*/
const extractPageName = function( link ) {
var pageName = extractPageNameRaw( link );
if ( pageName ) {
return getCanonicalTitle( pageName );
}
return null;
};
/*
* Extract the page name from a link, as is
*/
const extractPageNameRaw = function( link ) {
// Ignore images and interwiki links
if ( link.hasClass( 'image' ) || link.hasClass( 'extiw' ) ) {
return null;
}
var href = link.attr( 'href' );
// Red links in old parser format
if ( link.hasClass( 'new' ) ) {
const oldRedLink = mw.util.getParamValue( 'title', href );
if ( oldRedLink !== null ) {
return oldRedLink;
}
}
// Check that external links are coming from this wiki
if ( href.indexOf( '//' ) === 0 || href.indexOf( 'https://' ) === 0 ) {
try {
const fullUrl = href.indexOf( '//' ) === 0 ? location.protocol + href : href;
let urlData = new URL( fullUrl );
if ( urlData.hostname !== location.hostname ) {
return null;
}
} catch( e ) {
return null;
}
}
var articlePathRegexp = new RegExp(
mw.config.get( 'wgArticlePath' ).replace( '$1', '([^?]*)' )
);
var result = articlePathRegexp.exec( href );
if ( Array.isArray( result ) && result.length > 1 ) {
return decodeURIComponent( result[ 1 ] );
}
return null;
};
/*
* Check whether this is a disambiguation page
*/
const isDisam = function() {
const categories = mw.config.get( 'wgCategories', [] );
return cfg.disamCategories.some( c => categories.includes( c ) );
};
const secondsToHHMMSS = function( totalSeconds ) {
var hhmmss = '';
var hours = Math.floor( totalSeconds / 3600 );
var minutes = Math.floor( totalSeconds % 3600 / 60 );
var seconds = Math.floor( totalSeconds % 3600 % 60 );
if ( hours >= 1 ) {
hhmmss = pad( hours, '0', 2 ) + ':';
}
hhmmss += pad( minutes, '0', 2 ) + ':' + pad( seconds, '0', 2 );
return hhmmss;
};
const pad = function( str, z, width ) {
str = str.toString();
if ( str.length >= width ) {
return str;
} else {
return new Array( width - str.length + 1 ).join( z ) + str;
}
}
/*
* Create a new button
* text: Text that will be displayed on the button
* onClick: Function that will be called when the button is clicked
*/
const createButton = function( text, onClick ) {
var button = $( '<button>', { 'type': 'button' } );
button.text( text );
button.addClass( 'disamassist-button' ).click( onClick );
return button;
};
/*
* Given a page title and an array of possible redirects {from, to} ("canonical titles"), find the page
* at the end of the redirect chain, if there is one. Otherwise, return the page title that was passed
*/
const resolveRedirect = function( pageTitle, possibleRedirects ) {
var appliedRedirect = true;
var visitedPages = {};
var currentPage = getCanonicalTitle( pageTitle );
while ( appliedRedirect ) {
appliedRedirect = false;
for ( var ii = 0; ii < possibleRedirects.length; ii++ ) {
if ( possibleRedirects[ii].from === currentPage ) {
if ( visitedPages[possibleRedirects[ii].to] ) {
// Redirect chain detected
return pageTitle;
}
visitedPages[currentPage] = true;
appliedRedirect = true;
currentPage = possibleRedirects[ii].to;
}
}
}
// No redirect rules applied for an iteration of the outer loop:
// no more redirects. We are done
return currentPage;
};
/*
* Fetch the incoming links to a page. Returns a jQuery promise
* (success - array of titles of pages that contain links to the target page and
* array of "canonical titles" of possible destinations of the backlinks (either
* the target page or redirects to the target page), failure - error description)
* page: Target page
*/
const getBacklinks = function( page ) {
var dfd = new $.Deferred();
api.get( {
'action': 'query',
'list': 'backlinks',
'bltitle': page,
'blredirect': true,
'bllimit': cfg.backlinkLimit,
'blnamespace': cfg.targetNamespaces,
} ).done( function( data ) {
// There might be duplicate entries in some corner cases. We don't care,
// since we are going to check later, anyway
var backlinks = [];
var linkTitles = [getCanonicalTitle( page )];
$.each( data.query.backlinks, function() {
backlinks.push( this.title );
if ( this.redirlinks ) {
linkTitles.push( this.title );
$.each( this.redirlinks, function() {
backlinks.push( this.title );
} );
}
} );
dfd.resolve( backlinks, linkTitles );
} ).fail( function( code, data ) {
dfd.reject( txt.getBacklinksError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download a list of redirects for some pages. Returns a jQuery callback (success -
* array of redirects ({from, to}), failure - error description )
* pageTitles: Array of page titles
*/
const fetchRedirects = function( pageTitles ) {
var dfd = new $.Deferred();
var currentTitles = pageTitles.slice( 0, cfg.queryTitleLimit );
var restTitles = pageTitles.slice( cfg.queryTitleLimit );
api.get( {
action: 'query',
titles: currentTitles,
redirects: true,
} ).done( function( data ) {
var theseRedirects = data.query.redirects ? data.query.redirects : [];
if ( restTitles.length !== 0 ) {
fetchRedirects( restTitles ).done( function( redirects ) {
dfd.resolve( theseRedirects.concat( redirects ) );
} ).fail( function( description ) {
dfd.reject( description );
} );
} else {
dfd.resolve( theseRedirects );
}
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRedirectsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download the list of user rights for the current user. Returns a
* jQuery promise (success - array of right names, error - error description)
*/
const fetchRights = function() {
var dfd = $.Deferred();
api.get( {
action: 'query',
meta: 'userinfo',
uiprop: 'rights',
} ).done( function( data ) {
dfd.resolve( data.query.userinfo.rights );
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRightsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Load the raw page text for a given title. Returns a jQuery promise (success - page
* content, failure - error description)
* pageTitle: Title of the page
*/
const loadPage = function( pageTitle ) {
var dfd = new $.Deferred();
api.get( {
action: 'query',
titles: pageTitle,
prop: 'revisions',
rvprop: 'timestamp|content',
meta: 'tokens',
type: 'csrf',
} ).done( function( data ) {
var pages = data.query.pages;
for ( var key in pages ) {
if ( pages.hasOwnProperty( key ) ) {
break;
}
}
var rawPage = data.query.pages[key];
var page = {};
page.missing = rawPage.missing !== undefined;
if ( rawPage.revisions ) {
page.content = rawPage.revisions[0]['*'];
// Assume anything with # at the start is a redirect
if ( page.content.match( /^#[^\s]*?\s*\[\[/ ) ) {
page.redirect = true;
}
page.baseTimeStamp = rawPage.revisions[0].timestamp;
} else {
page.content = '';
page.baseTimeStamp = null;
}
page.startTimeStamp = rawPage.starttimestamp;
page.editToken = data.query.tokens.csrftoken;
dfd.resolve( page );
} ).fail( function( code, data ) {
dfd.reject( txt.loadPageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
/*
* Register changes to a page, to be saved later. Returns a jQuery promise
* (success - no params, failure - error description). Takes the same parameters
* as savePage
*/
const saveWithCooldown = function() {
var deferred = new $.Deferred();
pendingSaves.push( {args: arguments, dfd: deferred} );
if ( !runningSaves ) {
checkAndSave();
}
return deferred.promise();
};
/*
* Save the first set of changes in the list of pending changes, providing that
* enough time has passed since the last edit
*/
const checkAndSave = function() {
if ( pendingSaves.length === 0 ) {
runningSaves = false;
return;
}
runningSaves = true;
var millisSinceLast = new Date().getTime() - lastEditMillis;
if ( millisSinceLast < cfg.editCooldown * 1000 ) {
setTimeout( checkAndSave, cfg.editCooldown * 1000 - millisSinceLast );
} else {
// The last edit started at least cfg.editCooldown seconds ago
var save = pendingSaves.shift();
savePage.apply( this, save.args ).done( function() {
checkAndSave();
save.dfd.resolve();
} ).fail( function( description ) {
checkAndSave();
save.dfd.reject( description );
} );
// We'll use the time since the last edit started
lastEditMillis = new Date().getTime();
}
};
/*
* Save the changes made to a page. Returns a jQuery promise (success - no params,
* failure - error description)
* pageTitle: Title of the page
* page: Page data
* summary: Summary of the changes made to the page
* minorEdit: Whether to mark the edit as 'minor'
* botEdit: Whether to mark the edit as 'bot'
*/
const savePage = function( pageTitle, page, summary, minorEdit, botEdit ) {
var dfd = new $.Deferred();
api.post( {
action: 'edit',
title: pageTitle,
token: page.editToken,
text: page.content,
basetimestamp: page.baseTimeStamp,
starttimestamp: page.startTimeStamp,
summary: summary,
watchlist: cfg.watch ? cfg.watch : 'nochange',
assertuser: mw.config.get( 'wgUserName' ),
minor: minorEdit,
bot: botEdit,
} ).done( function() {
dfd.resolve();
} ).fail( function( code, data ) {
dfd.reject( txt.savePageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
install();
} )();
// </nowiki>
c3bic6nz8d08fprgjitrzlv0znde6yj
781198
781195
2026-10-27T16:22:12Z
Rots61
44293
781198
javascript
text/javascript
/*
* DisamAssist: a tool for repairing links from articles to disambiguation pages.
*/
// <nowiki>
// @ts-nocheck
( () => {
var cfg = {};
var txt = {};
var ui;
var links, pageChanges;
var currentPageTitle, currentPageParameters, currentLink;
var possibleBacklinkDestinations;
var titleRegex;
var forceSamePage = false;
var running = false;
var choosing = false;
var canMarkIntentionalLinks = false;
var displayedPages = {};
var editCount = 0;
var editLimit;
var pendingSaves = [];
var pendingEditBox = null;
var pendingEditBoxText;
var lastEditMillis = 0;
var runningSaves = false;
var api;
const dependencies = [
'mediawiki.api',
'mediawiki.confirmCloseWindow',
'mediawiki.Title',
'mediawiki.util',
];
/**
* Add an item in Tools menu
* @param {string} text
* @param {string} id
* @param {(e: MouseEvent) => void} callback
*/
const disamPortletLink = ( text, id, callback ) => {
const portlet = mw.util.addPortletLink(
'p-tb',
cfg.helpPage ? mw.util.getUrl( cfg.helpPage ) : '#',
text,
id
);
const link = portlet?.querySelector( 'a' );
link?.addEventListener( 'click', callback );
addButtonEvents( link );
}
/*
* Entry point. Check whether we are in a disambiguation page. If so, add a link to start the tool
*/
const install = function() {
cfg = window.DisamAssist.cfg;
txt = window.DisamAssist.txt;
if ( mw.config.get( 'wgAction' ) === 'view' && isDisam() ) {
mw.loader.using( dependencies, () => {
api = new mw.Api();
mw.loader.load( 'https://ru.wikipedia.org/w/index.php?title=Участник:Stjn/DisamAssist.css&action=raw&ctype=text/css', 'text/css' );
// This is a " (disambiguation)" page
if ( new RegExp( cfg.disamRegExp ).exec( getTitle() ) ) {
disamPortletLink( txt.startMain, 't-disamAssist-main', startMain );
disamPortletLink( txt.startSame, 't-disamAssist-same', startSame );
} else {
disamPortletLink( txt.start, 't-disamAssist-page', start );
}
} );
}
};
/*
* Start the tool. Display the UI and begin looking for links to fix
*/
const start = function( e ) {
e?.preventDefault();
if ( !running ) {
console.log(Date.now())
running = true;
links = [];
pageChanges = [];
displayedPages = {};
ensureDABExists().then( function( canMark ) {
canMarkIntentionalLinks = canMark;
createUI();
addUnloadConfirm();
markDisamOptions();
checkEditLimit().then( function() {
togglePendingEditBox( false );
doPage();
} );
} );
}
};
/*
* Start DisamAssist. Disambiguate incoming links to the current page, regardless
* of the title.
*/
const startSame = function( e ) {
e.preventDefault();
forceSamePage = true;
start( e );
};
/*
* Start DisamAssist. If the page title ends with " (disambiguation)", disambiguate
* links to the primary topic article. Otherwise, disambiguate links to the current
* page.
*/
const startMain = function( e ) {
e.preventDefault();
forceSamePage = false;
start( e );
};
/*
* Create and show the user interface.
*/
const createUI = function() {
ui = {
display: $( '<div>' ).addClass( 'disamassist-box disamassist-mainbox' ),
finishedMessage: $( '<div>' ).text( txt.noMoreLinks ).hide(),
pageTitleLine: $( '<span>' ).addClass( 'disamassist-pagetitleline' ),
pendingEditCounter: $( '<div>').addClass( 'disamassist-editcounter' ),
context: $( '<pre>' ).addClass( 'disamassist-context' ),
undoButton: createButton( txt.undo, undo ),
omitButton: createButton( txt.omit, omit ),
endButton: createButton( txt.close, saveAndEnd ),
refreshButton: createButton( txt.refresh, refresh ),
titleAsTextButton: createButton( txt.titleAsText, chooseTitleFromPrompt ),
intentionalLinkButton: canMarkIntentionalLinks ? createButton( txt.intentionalLink, chooseIntentionalLink ) : null,
disamNeededButton: cfg.disamNeededText ? createButton( txt.disamNeeded, chooseDisamNeeded ) : null,
removeLinkButton: createButton( txt.removeLink, chooseLinkRemoval )
};
var top = $( '<div>' ).addClass( 'disamassist-top' )
.append( [ui.pageTitleLine, ui.pendingEditCounter] );
var leftButtons = $( '<div>' ).addClass( 'disamassist-leftbuttons' )
.append( [ui.titleAsTextButton, ui.removeLinkButton, ui.intentionalLinkButton, ui.disamNeededButton, ui.omitButton] );
var rightButtons = $( '<div>' ).addClass( 'disamassist-rightbuttons' )
.append( [ui.undoButton, ui.refreshButton, ui.endButton] );
var allButtons = $( '<div>' ).addClass( 'disamassist-allbuttons' )
.append( [leftButtons, rightButtons] );
ui.display.append( [top, ui.finishedMessage, ui.context, allButtons] );
updateEditCounter();
toggleActionButtons( false );
// Insert the UI in the page
$( '#mw-content-text' ).before( ui.display );
ui.display.hide().fadeIn();
};
/**
* Add button role and related keyboard event
* @param {Element} el
*/
const addButtonEvents = ( el ) => {
el?.setAttribute( 'role', 'button' );
el?.addEventListener( 'keydown', function( e ) {
if ( [ 'Enter', 'Space' ].includes( e.code ) ) {
e.preventDefault();
this.click();
}
} );
}
/*
* If there are pending changes, show a confirm dialog before closing
*/
const addUnloadConfirm = function() {
mw.confirmCloseWindow( {
test: () => {
if ( running && checkActualChanges() ) {
return txt.pending;
} else if ( editCount !== 0 ) {
return txt.editInProgress;
}
}
} );
};
/*
* Mark the disambiguation options as such
*/
const markDisamOptions = function() {
var optionPageTitles = [];
var optionMarkers = [];
getDisamOptions().each( function() {
var link = $( this );
var title = extractPageName( link );
const optionMarker = document.createElement( 'a' );
optionMarker.classList.add( 'disamassist-optionmarker' );
optionMarker.href = link.attr( 'href' );
optionMarker.setAttribute( 'data-title', title );
optionMarker.textContent = txt.optionMarker;
optionMarker.addEventListener( 'click', ( e ) => {
e.preventDefault();
chooseReplacement( title );
} );
addButtonEvents( optionMarker );
link.after( optionMarker );
optionMarkers.push( optionMarker );
optionPageTitles.push( title );
} );
// Now check the disambiguation options and display a different message for those that are
// actually the same as the target page where the links go, as choosing those options doesn't really
// accomplish anything (except bypassing redirects, which might be useful in some cases)
var targetPage = getTargetPage();
fetchRedirects( optionPageTitles.concat( targetPage ) ).done( function( redirects ) {
var endTargetPage = resolveRedirect( targetPage, redirects );
for ( var ii = 0; ii < optionPageTitles.length; ii++ ) {
var endOptionTitle = resolveRedirect( optionPageTitles[ii], redirects );
if ( isSamePage( optionPageTitles[ii], targetPage ) ) {
$( optionMarkers[ii] ).text( txt.targetOptionMarker ).addClass( 'disamassist-curroptionmarker');
} else if ( isSamePage( endOptionTitle, endTargetPage ) ) {
$( optionMarkers[ii] ).text( txt.redirectOptionMarker ).addClass( 'disamassist-curroptionmarker');
}
}
} ).fail( error );
};
/*
* Check whether intentional links to disambiguation pages can be explicitly marked
* as such in this wiki. If so, ensure that a "Foo (disambiguation)" page exists.
* Returns a jQuery promise
*/
const ensureDABExists = function() {
var dfd = new $.Deferred();
var title = getTitle();
// That concept doesn't exist in this wiki.
if ( !cfg.intentionalLinkOption ) {
dfd.resolve( false );
// "Foo (disambiguation)" exists: it's the current page.
} else if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
dfd.resolve( true );
} else {
var disamTitle = cfg.disamFormat.replace( '$1', title );
loadPage( disamTitle ).done( function( page ) {
// "Foo (disambiguation)" doesn't exist.
if ( page.missing ) {
// We try to create it
page.content = cfg.redirectToDisam.replace( '$1', title );
var summary = txt.redirectSummary.replace( '$1', title );
savePage( disamTitle, page, summary, false, true ).done( function() {
dfd.resolve( true );
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
// It does exist
} else {
dfd.resolve( true );
}
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
}
return dfd.promise();
};
/*
* Check whether the edit cooldown applies and sets editLimit accordingly.
* Returns a jQuery promise
*/
const checkEditLimit = function() {
var dfd = new $.Deferred();
if ( cfg.editCooldown <= 0 ) {
editLimit = false;
dfd.resolve();
} else {
fetchRights().done( function( rights ) {
editLimit = !rights?.includes( 'bot' );
} ).fail( function( description ) {
error( description );
editLimit = true;
} ).always( function() {
dfd.resolve();
} );
}
return dfd.promise();
};
/*
* Find and ask the user to fix all the incoming links to the disambiguation ("target")
* page from a single "origin" page
*/
const doPage = function() {
if ( pageChanges.length > cfg.historySize ) {
applyChange( pageChanges.shift() );
}
if ( links.length === 0 ) {
var targetPage = getTargetPage();
getBacklinks( targetPage ).done( function( backlinks, pageTitles ) {
var pending = {};
$.each( pendingSaves, function() {
pending[this[0]] = true;
} );
possibleBacklinkDestinations = $.grep( pageTitles, function( t, ii) {
if ( t == targetPage ) {
return true;
}
return removeDisam(t) != targetPage;
} );
titleRegex = new RegExp('\\[\\[([ _]*?(?:' + possibleBacklinkDestinations.join('|').replaceAll('(', '\\(').replaceAll(')','\\)').replaceAll(' ', '[ _]') + ')[ _]*?)(#.*?)?(?:\\|(.*?))?\\]\\]', 'gi');
// Only incoming links from pages we haven't seen yet and we aren't currently
// saving (displayedPages is reset when the tool is closed and opened again,
// while the list of pending changes isn't; if the edit cooldown is disabled,
// it will be empty)
links = $.grep( backlinks, function( el, ii ) {
return !displayedPages[el] && !pending[el];
} );
if ( links.length === 0 ) {
updateContext();
} else {
doPage();
}
} ).fail( error );
} else {
currentPageTitle = links.shift();
displayedPages[currentPageTitle] = true;
toggleActionButtons( false );
loadPage( currentPageTitle ).done( function( data ) {
currentPageParameters = data;
currentLink = null;
doLink();
} ).fail( error );
}
};
/*
* Find and ask the user to fix a single incoming link to the disambiguation ("target")
* page
*/
const doLink = function() {
if (!currentPageParameters.redirect) {
currentLink = extractLinkToPage( currentPageParameters.content,
possibleBacklinkDestinations, currentLink ? currentLink.end : 0 );
if ( currentLink ) {
updateContext();
} else {
doPage();
}
} else {
doPage();
}
};
/*
* Replace the target of a link with a different one
* pageTitle: New link target
* extra: Additional text after the link (optional)
* summary: Change summary (optional)
*/
const chooseReplacement = function( pageTitle, extra, summary ) {
if ( choosing ) {
choosing = false;
if ( !summary ) {
if ( pageTitle ) {
summary = txt.summaryChanged.replace( '$1', pageTitle );
} else {
summary = txt.summaryOmitted;
}
}
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
if ( pageTitle && ( pageTitle !== getTargetPage() || extra ) ) {
if ( currentPageParameters.redirect ) {
currentLink.description = pageTitle;
}
currentPageParameters.content =
replaceLink( currentPageParameters.content, pageTitle, currentLink, extra || '' );
}
doLink();
}
};
/*
* Replace the link with an explicit link to the disambiguation page
*/
const chooseIntentionalLink = function() {
const targetPage = getTargetPage();
let title = cfg.disamFormat.replace( '$1', targetPage );
if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
title = targetPage;
}
chooseReplacement( title, '', txt.summaryIntentional );
};
/*
* Prompt for an alternative link target and use it as a replacement
*/
const chooseTitleFromPrompt = function() {
var title = prompt( txt.titleAsTextPrompt );
if ( title !== null ) {
chooseReplacement( title );
}
};
/*
* Remove the current link, leaving the text unchanged
*/
const chooseLinkRemoval = function() {
if ( choosing ) {
var summary = txt.summaryRemoved;
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
currentPageParameters.content = removeLink( currentPageParameters.content, currentLink );
doLink();
}
};
/*
* Add a "disambiguation needed" template after the link
*/
const chooseDisamNeeded = function() {
chooseReplacement( currentLink.title, cfg.disamNeededText, txt.summaryHelpNeeded );
};
/*
* Undo the last change
*/
const undo = function() {
if ( pageChanges.length !== 0 ) {
var lastPage = pageChanges[pageChanges.length - 1];
if ( currentPageTitle !== lastPage.title ) {
links.unshift( currentPageTitle );
currentPageTitle = lastPage.title;
}
currentPageParameters = lastPage.page;
currentPageParameters.content = lastPage.contentBefore.pop();
currentLink = lastPage.links.pop();
lastPage.summary.pop();
if ( lastPage.contentBefore.length === 0 ) {
pageChanges.pop();
}
updateContext();
}
};
/*
* Omit the current link without making a change
*/
const omit = function() {
chooseReplacement( null );
};
/*
* Save all the pending changes and restart the tool.
*/
const refresh = function() {
saveAndEnd();
start();
};
/*
* Enable or disable the buttons that can perform actions on a page or change the current link.
* enabled: Whether to enable or disable the buttons
*/
const toggleActionButtons = function( enabled ) {
var affectedButtons = [ui.omitButton, ui.titleAsTextButton, ui.removeLinkButton,
ui.intentionalLinkButton, ui.disamNeededButton, ui.undoButton];
$.each( affectedButtons, function( ii, button ) {
button?.prop( 'disabled', !enabled );
} );
};
/*
* Show or hide the 'no more links' message
* show: Whether to show or hide the message
*/
const toggleFinishedMessage = function( show ) {
toggleActionButtons( !show );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.finishedMessage.toggle( show );
ui.pageTitleLine.toggle( !show );
ui.context.toggle( !show );
if ( show ) console.log(Date.now())
};
const togglePendingEditBox = function( show ) {
if ( pendingEditBox === null ) {
pendingEditBox = $( '<div>' ).addClass( 'disamassist-box disamassist-pendingeditbox' );
pendingEditBoxText = $( '<div>' );
pendingEditBox.append( pendingEditBoxText ).hide();
if ( editLimit ) {
pendingEditBox.append( $( '<div>' ).text( txt.pendingEditBoxLimited )
.addClass( 'disamassist-subtitle' ) );
}
$( '#mw-content-text' ).before( pendingEditBox );
updateEditCounter();
}
if ( show ) {
pendingEditBox.fadeIn();
} else {
pendingEditBox.fadeOut();
}
};
const notifyCompletion = function() {
var oldTitle = document.title;
document.title = txt.notifyCharacter + document.title;
$( document.body ).one( 'mousemove', function() {
document.title = oldTitle;
} );
};
/*
* Update the displayed information to match the current link
* or lack thereof
*/
const updateContext = function() {
updateEditCounter();
if ( !currentLink ) {
toggleFinishedMessage( true );
} else {
ui.pageTitleLine.html( txt.pageTitleLine.replace( '$1',
mw.util.getUrl( currentPageTitle, {redirect: 'no'} ) ).replace( '$2', mw.html.escape( currentPageTitle ) ) );
var context = extractContext( currentPageParameters.content, currentLink );
ui.context.empty()
.append( $( '<span>' ).text( context[0] ) )
.append( $( '<span>' ).text( context[1] ).addClass( 'disamassist-inclink' ) )
.append( $( '<span>' ).text( context[2] ) );
toggleFinishedMessage( false );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.removeLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.intentionalLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.disamNeededButton?.prop( 'disabled', currentPageParameters.redirect || currentLink.hasDisamTemplate );
choosing = true;
}
};
/*
* Update the count of pending changes
*/
const updateEditCounter = function() {
if ( ui.pendingEditCounter ) {
ui.pendingEditCounter.text( txt.pendingEditCounter.replace( '$1', editCount )
.replace( '$2', countActuallyChangedFullyCheckedPages() ) );
}
if ( pendingEditBox ) {
if ( editCount === 0 && !running ) {
togglePendingEditBox( false );
notifyCompletion();
}
var textContent = editCount;
if ( editLimit ) {
textContent = txt.pendingEditBoxTimeEstimation.replace( '$1', editCount )
.replace( '$2', secondsToHHMMSS( cfg.editCooldown * editCount ) );
}
pendingEditBoxText.text( txt.pendingEditBox.replace( '$1', textContent ) );
}
};
/*
* Apply the changes made to an "origin" page
* pageChange: Change that will be saved
*/
const applyChange = function( pageChange ) {
if ( pageChange.page.content !== pageChange.contentBefore[0] ) {
editCount++;
// Filter duplicates and join
var changeSummaries = pageChange.summary
.filter( ( val, i, arr ) => val && arr.indexOf( val ) === i )
.join( txt.summarySeparator );
var summary = txt.summary.replace( '$1', getTargetPage() ).replace( '$2', changeSummaries );
var save = editLimit ? saveWithCooldown : savePage;
save( pageChange.title, pageChange.page, summary, true, true ).always( function() {
if ( editCount > 0 ) {
editCount--;
}
updateEditCounter();
} ).fail( error );
updateEditCounter();
}
};
/*
* Save all the pending changes
*/
const applyAllChanges = function() {
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
applyChange( pageChanges[ii] );
}
pageChanges = [];
};
/*
* Record a new pending change
* pageTitle: Title of the page
* page: Content of the page
* oldContent: Content of the page before the change
* link: Link that has been changed
* summary: Change summary
*/
const addChange = function( pageTitle, page, oldContent, link, summary ) {
if ( ( pageChanges.length === 0 ) || ( pageChanges[pageChanges.length - 1].title !== pageTitle ) ) {
pageChanges.push( {
title: pageTitle,
page: page,
contentBefore: [],
links: [],
summary: []
} );
}
var lastPageChange = pageChanges[pageChanges.length - 1];
lastPageChange.contentBefore.push( oldContent );
lastPageChange.links.push( link );
lastPageChange.summary.push( summary );
};
/*
* Check whether actual changes are stored in the history array
*/
const checkActualChanges = function() {
return countActualChanges() !== 0;
};
/*
* Return the number of entries in the history array that represent actual changes
*/
const countActualChanges = function() {
var changeCount = 0;
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
if ( pageChanges[ii].page.content !== pageChanges[ii].contentBefore[0] ) {
changeCount++;
}
}
return changeCount;
};
/*
* Return the number of changed pages in the history array, ignoring the last entry
* if we aren't done with that page yet
*/
const countActuallyChangedFullyCheckedPages = function() {
var changeCount = countActualChanges();
if ( pageChanges.length !== 0 ) {
var lastChange = pageChanges[pageChanges.length - 1];
if ( lastChange.title === currentPageTitle && currentLink !== null
&& lastChange.page.content !== lastChange.contentBefore[0] ) {
changeCount--;
}
}
return changeCount;
};
/*
* Find the links to disambiguation options in a disambiguation page
*/
const getDisamOptions = function() {
return $( '#mw-content-text a' ).filter( function() {
return !!extractPageName( $( this ) );
} );
};
/*
* Save all the pending changes and close the tool
*/
const saveAndEnd = function() {
applyAllChanges();
end();
};
/*
* Close the tool
*/
const end = function() {
var currentToolUI = ui.display;
choosing = false;
running = false;
$( '.disamassist-optionmarker' ).remove();
currentToolUI.fadeOut( { complete: function() {
currentToolUI.remove();
if ( editCount !== 0 ) {
togglePendingEditBox( true );
}
} } );
};
/*
* Display an error message
*/
const error = function( errorDescription ) {
var errorBox = $( '<div>' ).addClass( 'disamassist-box disamassist-errorbox' );
errorBox.text( txt.error.replace( '$1', errorDescription ) );
errorBox.append( createButton( txt.dismissError, function() {
errorBox.fadeOut();
} ).addClass( 'disamassist-errorbutton' ) );
var uiIsInPlace = ui && $.contains( document.documentElement, ui.display[0] );
var nextElement = uiIsInPlace ? ui.display : $( '#mw-content-text' );
nextElement.before( errorBox );
errorBox.hide().fadeIn();
}
/*
* Change a link so that it points to the title
* text: The wikitext of the whole page
* title: The new destination of the link
* link: The link that will be modified
* extra: Text that will be added after the link (optional)
*/
const replaceLink = function( text, title, link, extra ) {
var anchor = link.anchor || '';
var newContent;
if ( link.type === 'sortnaam' ) {
newContent = '{{sortnaam|' + link.first + '|' + link.last + '|' + title + anchor + '}}';
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + ( extra || '' ) + linkEnd;
}
if ( link.type === 'ill' ) {
newContent = '{{ill|' + title + anchor + '|' + link.lang + '|' + link.other + ( link.rest || '' ) + '}}';
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + ( extra || '' ) + linkEnd;
}
if ( isSamePage( title, link.description ) ) {
newContent = link.description + anchor;
} else {
newContent = title + anchor + '|' + link.description;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + '[[' + newContent + ']]' + link.afterDescription + ( extra || '' ) + linkEnd;
};
/*
* Remove a link from the text
* text: The wikitext of the whole page
* link: The link that will be removed
*/
const removeLink = function( text, link ) {
var anchor = link.anchor || '';
if ( link.type === 'sortnaam' ) {
var newContent = '{{sortnaam|' + link.first + '|' + link.last + '}}' + anchor;
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + linkEnd;
}
if ( link.type === 'ill' ) {
var newContent = link.title + anchor;
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + linkEnd;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + link.description + anchor + link.afterDescription + linkEnd;
};
/*
* Extract a link from a string in wiki format,
* starting from a given index. Return a link if one can be found,
* otherwise return null.
*/
const extractLink = function( text, lastIndex ) {
// 1. Zoek naar gewone wikitlinks (inclusief optioneel anker #...)
titleRegex.lastIndex = lastIndex;
var wikiMatch = titleRegex.exec( text );
// 2. Zoek naar sortnaam-templates
var sortnaamRegex = new RegExp('{{\\s*sortnaam\\s*\\|\\s*([^|]+?)\\s*\\|\\s*([^|]+?)\\s*(?:\\|\\s*([^|#}]*?)\\s*)?(#[^|}]*)?}}', 'gi');
sortnaamRegex.lastIndex = lastIndex;
var sortnaamMatch = sortnaamRegex.exec( text );
// 3. Zoek naar ill-templates (ondersteunt ook nieuwe regels en extra parameters)
var illRegex = new RegExp('{{\\s*ill\\s*\\|([\\s\\S]*?)}}', 'gi');
illRegex.lastIndex = lastIndex;
var illMatch = illRegex.exec( text );
var matchType = null;
var matchIndex = Infinity;
if ( wikiMatch !== null && wikiMatch.index >= lastIndex ) {
matchIndex = wikiMatch.index;
matchType = 'wiki';
}
if ( sortnaamMatch !== null && sortnaamMatch.index >= lastIndex && sortnaamMatch.index < matchIndex ) {
matchIndex = sortnaamMatch.index;
matchType = 'sortnaam';
}
if ( illMatch !== null && illMatch.index >= lastIndex && illMatch.index < matchIndex ) {
matchIndex = illMatch.index;
matchType = 'ill';
}
if ( matchType === 'wiki' ) {
var possiblyAmbiguous = true;
var hasDisamTemplate = false;
var fullMatchStr = wikiMatch[0];
var anchor = wikiMatch[2] ? wikiMatch[2] : '';
var end = wikiMatch.index + fullMatchStr.length;
var afterDescription = '';
var rest = text.substring( end );
var templateRegex = /^(\w*[.,:;?!)}\s]*){{\s*([^|{}]+?)\s*(?:\|[^{]*?)?}}/g;
var templateMatch = templateRegex.exec( rest );
if ( templateMatch !== null ) {
var templateTitle = getCanonicalTitle( templateMatch[2] );
if ( cfg.disamLinkTemplates?.includes( templateTitle ) ) {
end += templateMatch[0].length;
afterDescription = templateMatch[1].replace(/\s$/, '');
hasDisamTemplate = true;
} else if ( cfg.disamLinkIgnoreTemplates?.includes( templateTitle ) ) {
possiblyAmbiguous = false;
}
}
return {
type: 'wiki',
start: wikiMatch.index,
end: end,
possiblyAmbiguous: possiblyAmbiguous,
hasDisamTemplate: hasDisamTemplate,
title: wikiMatch[1],
description: wikiMatch[3] ? wikiMatch[3] : wikiMatch[1],
anchor: anchor,
afterDescription: afterDescription
};
} else if ( matchType === 'sortnaam' ) {
var first = sortnaamMatch[1].trim();
var last = sortnaamMatch[2].trim();
var explicitTarget = sortnaamMatch[3] ? sortnaamMatch[3].trim() : '';
var anchor = sortnaamMatch[4] ? sortnaamMatch[4].trim() : '';
var target = explicitTarget !== '' ? explicitTarget : ( first + ' ' + last );
return {
type: 'sortnaam',
start: sortnaamMatch.index,
end: sortnaamMatch.index + sortnaamMatch[0].length,
possiblyAmbiguous: true,
hasDisamTemplate: false,
title: target,
description: sortnaamMatch[0],
first: first,
last: last,
explicitTarget: explicitTarget,
anchor: anchor,
afterDescription: ''
};
} else if ( matchType === 'ill' ) {
var inner = illMatch[1];
var parts = inner.split('|');
var firstParam = parts[0].trim();
var anchorMatch = firstParam.match(/^(.*?)(#.*)$/);
var title = anchorMatch ? anchorMatch[1].trim() : firstParam;
var anchor = anchorMatch ? anchorMatch[2] : '';
var lang = parts[1] ? parts[1].trim() : '';
var other = parts[2] ? parts[2].trim() : '';
var restParts = parts.slice(3);
var rest = restParts.length > 0 ? '|' + restParts.join('|') : '';
return {
type: 'ill',
start: illMatch.index,
end: illMatch.index + illMatch[0].length,
possiblyAmbiguous: true,
hasDisamTemplate: false,
title: title,
description: title,
anchor: anchor,
lang: lang,
other: other,
rest: rest,
afterDescription: ''
};
}
return null;
};
/*
* Extract a link to one of a number of destination pages from a string
* ("text") in wiki format, starting from a given index ("lastIndex").
* "Disambiguation needed" templates are included as part of the links.
* text: Page in wiki format
* destinations: Array of page titles to look for
* lastIndex: Index from which the search will start
*/
const extractLinkToPage = function( text, destinations, lastIndex ) {
var link, title;
do {
link = extractLink( text, lastIndex );
if ( link !== null ) {
lastIndex = link.end;
title = getCanonicalTitle( link.title );
}
} while (
link !== null
&& ( !link.possiblyAmbiguous || !destinations?.includes( title ) )
);
return link;
};
/*
* Find the "target" page: either the one we are in or the "main" one found by extracting
* the title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const getTargetPage = function() {
var title = getTitle();
return forceSamePage ? title : removeDisam( title );
};
/*
* Get the page title, with the namespace prefix if any.
*/
const getTitle = function() {
return mw.config.get( 'wgPageName' ).replace(/_/g, ' ')
}
/*
* Extract a "main" title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const removeDisam = function( title ) {
var match = new RegExp( cfg.disamRegExp ).exec( title );
return match ? match[1] : title;
};
/*
* Check whether two page titles are the same
*/
const isSamePage = function( title1, title2 ) {
return getCanonicalTitle( title1 ) === getCanonicalTitle( title2 );
};
/*
* Return the 'canonical title' of a page
*/
const getCanonicalTitle = function( title ) {
try {
title = new mw.Title( title );
// Check that page name matches the current namespace
if ( title.getNamespaceId() !== mw.config.get( 'wgNamespaceNumber' ) ) {
return;
}
const fragment = title.getFragment();
return title.getPrefixedText() + ( fragment ? `#${ fragment }` : '' );
} catch ( ex ) {
// mw.Title seems to be buggy, and some valid titles are rejected
// FIXME: This may cause false negatives
}
};
/*
* Extract the context around a given link in a text string
*/
const extractContext = function( text, link ) {
var contextStart = link.start - cfg.radius;
var contextEnd = link.end + cfg.radius;
var contextPrev = text.substring( contextStart, link.start );
const ellipsis = '…';
if ( contextStart > 0 ) {
contextPrev = ellipsis + contextPrev;
}
var contextNext = text.substring( link.end, contextEnd );
if ( contextEnd < text.length ) {
contextNext = contextNext + ellipsis;
}
return [contextPrev, text.substring( link.start, link.end ), contextNext];
};
/*
* Extract the prefixed page name from a link
*/
const extractPageName = function( link ) {
var pageName = extractPageNameRaw( link );
if ( pageName ) {
return getCanonicalTitle( pageName );
}
return null;
};
/*
* Extract the page name from a link, as is
*/
const extractPageNameRaw = function( link ) {
// Ignore images and interwiki links
if ( link.hasClass( 'image' ) || link.hasClass( 'extiw' ) ) {
return null;
}
var href = link.attr( 'href' );
// Red links in old parser format
if ( link.hasClass( 'new' ) ) {
const oldRedLink = mw.util.getParamValue( 'title', href );
if ( oldRedLink !== null ) {
return oldRedLink;
}
}
// Check that external links are coming from this wiki
if ( href.indexOf( '//' ) === 0 || href.indexOf( 'https://' ) === 0 ) {
try {
const fullUrl = href.indexOf( '//' ) === 0 ? location.protocol + href : href;
let urlData = new URL( fullUrl );
if ( urlData.hostname !== location.hostname ) {
return null;
}
} catch( e ) {
return null;
}
}
var articlePathRegexp = new RegExp(
mw.config.get( 'wgArticlePath' ).replace( '$1', '([^?]*)' )
);
var result = articlePathRegexp.exec( href );
if ( Array.isArray( result ) && result.length > 1 ) {
return decodeURIComponent( result[ 1 ] );
}
return null;
};
/*
* Check whether this is a disambiguation page
*/
const isDisam = function() {
const categories = mw.config.get( 'wgCategories', [] );
return cfg.disamCategories.some( c => categories.includes( c ) );
};
const secondsToHHMMSS = function( totalSeconds ) {
var hhmmss = '';
var hours = Math.floor( totalSeconds / 3600 );
var minutes = Math.floor( totalSeconds % 3600 / 60 );
var seconds = Math.floor( totalSeconds % 3600 % 60 );
if ( hours >= 1 ) {
hhmmss = pad( hours, '0', 2 ) + ':';
}
hhmmss += pad( minutes, '0', 2 ) + ':' + pad( seconds, '0', 2 );
return hhmmss;
};
const pad = function( str, z, width ) {
str = str.toString();
if ( str.length >= width ) {
return str;
} else {
return new Array( width - str.length + 1 ).join( z ) + str;
}
}
/*
* Create a new button
* text: Text that will be displayed on the button
* onClick: Function that will be called when the button is clicked
*/
const createButton = function( text, onClick ) {
var button = $( '<button>', { 'type': 'button' } );
button.text( text );
button.addClass( 'disamassist-button' ).click( onClick );
return button;
};
/*
* Given a page title and an array of possible redirects {from, to} ("canonical titles"), find the page
* at the end of the redirect chain, if there is one. Otherwise, return the page title that was passed
*/
const resolveRedirect = function( pageTitle, possibleRedirects ) {
var appliedRedirect = true;
var visitedPages = {};
var currentPage = getCanonicalTitle( pageTitle );
while ( appliedRedirect ) {
appliedRedirect = false;
for ( var ii = 0; ii < possibleRedirects.length; ii++ ) {
if ( possibleRedirects[ii].from === currentPage ) {
if ( visitedPages[possibleRedirects[ii].to] ) {
// Redirect chain detected
return pageTitle;
}
visitedPages[currentPage] = true;
appliedRedirect = true;
currentPage = possibleRedirects[ii].to;
}
}
}
// No redirect rules applied for an iteration of the outer loop:
// no more redirects. We are done
return currentPage;
};
/*
* Fetch the incoming links to a page. Returns a jQuery promise
* (success - array of titles of pages that contain links to the target page and
* array of "canonical titles" of possible destinations of the backlinks (either
* the target page or redirects to the target page), failure - error description)
* page: Target page
*/
const getBacklinks = function( page ) {
var dfd = new $.Deferred();
api.get( {
'action': 'query',
'list': 'backlinks',
'bltitle': page,
'blredirect': true,
'bllimit': cfg.backlinkLimit,
'blnamespace': cfg.targetNamespaces,
} ).done( function( data ) {
// There might be duplicate entries in some corner cases. We don't care,
// since we are going to check later, anyway
var backlinks = [];
var linkTitles = [getCanonicalTitle( page )];
$.each( data.query.backlinks, function() {
backlinks.push( this.title );
if ( this.redirlinks ) {
linkTitles.push( this.title );
$.each( this.redirlinks, function() {
backlinks.push( this.title );
} );
}
} );
dfd.resolve( backlinks, linkTitles );
} ).fail( function( code, data ) {
dfd.reject( txt.getBacklinksError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download a list of redirects for some pages. Returns a jQuery callback (success -
* array of redirects ({from, to}), failure - error description )
* pageTitles: Array of page titles
*/
const fetchRedirects = function( pageTitles ) {
var dfd = new $.Deferred();
var currentTitles = pageTitles.slice( 0, cfg.queryTitleLimit );
var restTitles = pageTitles.slice( cfg.queryTitleLimit );
api.get( {
action: 'query',
titles: currentTitles,
redirects: true,
} ).done( function( data ) {
var theseRedirects = data.query.redirects ? data.query.redirects : [];
if ( restTitles.length !== 0 ) {
fetchRedirects( restTitles ).done( function( redirects ) {
dfd.resolve( theseRedirects.concat( redirects ) );
} ).fail( function( description ) {
dfd.reject( description );
} );
} else {
dfd.resolve( theseRedirects );
}
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRedirectsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download the list of user rights for the current user. Returns a
* jQuery promise (success - array of right names, error - error description)
*/
const fetchRights = function() {
var dfd = $.Deferred();
api.get( {
action: 'query',
meta: 'userinfo',
uiprop: 'rights',
} ).done( function( data ) {
dfd.resolve( data.query.userinfo.rights );
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRightsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Load the raw page text for a given title. Returns a jQuery promise (success - page
* content, failure - error description)
* pageTitle: Title of the page
*/
const loadPage = function( pageTitle ) {
var dfd = new $.Deferred();
api.get( {
action: 'query',
titles: pageTitle,
prop: 'revisions',
rvprop: 'timestamp|content',
meta: 'tokens',
type: 'csrf',
} ).done( function( data ) {
var pages = data.query.pages;
for ( var key in pages ) {
if ( pages.hasOwnProperty( key ) ) {
break;
}
}
var rawPage = data.query.pages[key];
var page = {};
page.missing = rawPage.missing !== undefined;
if ( rawPage.revisions ) {
page.content = rawPage.revisions[0]['*'];
// Assume anything with # at the start is a redirect
if ( page.content.match( /^#[^\s]*?\s*\[\[/ ) ) {
page.redirect = true;
}
page.baseTimeStamp = rawPage.revisions[0].timestamp;
} else {
page.content = '';
page.baseTimeStamp = null;
}
page.startTimeStamp = rawPage.starttimestamp;
page.editToken = data.query.tokens.csrftoken;
dfd.resolve( page );
} ).fail( function( code, data ) {
dfd.reject( txt.loadPageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
/*
* Register changes to a page, to be saved later. Returns a jQuery promise
* (success - no params, failure - error description). Takes the same parameters
* as savePage
*/
const saveWithCooldown = function() {
var deferred = new $.Deferred();
pendingSaves.push( {args: arguments, dfd: deferred} );
if ( !runningSaves ) {
checkAndSave();
}
return deferred.promise();
};
/*
* Save the first set of changes in the list of pending changes, providing that
* enough time has passed since the last edit
*/
const checkAndSave = function() {
if ( pendingSaves.length === 0 ) {
runningSaves = false;
return;
}
runningSaves = true;
var millisSinceLast = new Date().getTime() - lastEditMillis;
if ( millisSinceLast < cfg.editCooldown * 1000 ) {
setTimeout( checkAndSave, cfg.editCooldown * 1000 - millisSinceLast );
} else {
// The last edit started at least cfg.editCooldown seconds ago
var save = pendingSaves.shift();
savePage.apply( this, save.args ).done( function() {
checkAndSave();
save.dfd.resolve();
} ).fail( function( description ) {
checkAndSave();
save.dfd.reject( description );
} );
// We'll use the time since the last edit started
lastEditMillis = new Date().getTime();
}
};
/*
* Save the changes made to a page. Returns a jQuery promise (success - no params,
* failure - error description)
* pageTitle: Title of the page
* page: Page data
* summary: Summary of the changes made to the page
* minorEdit: Whether to mark the edit as 'minor'
* botEdit: Whether to mark the edit as 'bot'
*/
const savePage = function( pageTitle, page, summary, minorEdit, botEdit ) {
var dfd = new $.Deferred();
api.post( {
action: 'edit',
title: pageTitle,
token: page.editToken,
text: page.content,
basetimestamp: page.baseTimeStamp,
starttimestamp: page.startTimeStamp,
summary: summary,
watchlist: cfg.watch ? cfg.watch : 'nochange',
assertuser: mw.config.get( 'wgUserName' ),
minor: minorEdit,
bot: botEdit,
} ).done( function() {
dfd.resolve();
} ).fail( function( code, data ) {
dfd.reject( txt.savePageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
install();
} )();
// </nowiki>
rinfy42nb1vgcitq4ksgf8er6krq7aa
781202
781198
2026-10-27T16:27:58Z
Rots61
44293
781202
javascript
text/javascript
/*
* DisamAssist: a tool for repairing links from articles to disambiguation pages.
*/
// <nowiki>
// @ts-nocheck
( () => {
var cfg = {};
var txt = {};
var ui;
var links, pageChanges;
var currentPageTitle, currentPageParameters, currentLink;
var possibleBacklinkDestinations;
var titleRegex;
var forceSamePage = false;
var running = false;
var choosing = false;
var canMarkIntentionalLinks = false;
var displayedPages = {};
var editCount = 0;
var editLimit;
var pendingSaves = [];
var pendingEditBox = null;
var pendingEditBoxText;
var lastEditMillis = 0;
var runningSaves = false;
var api;
const dependencies = [
'mediawiki.api',
'mediawiki.confirmCloseWindow',
'mediawiki.Title',
'mediawiki.util',
];
/**
* Add an item in Tools menu
* @param {string} text
* @param {string} id
* @param {(e: MouseEvent) => void} callback
*/
const disamPortletLink = ( text, id, callback ) => {
const portlet = mw.util.addPortletLink(
'p-tb',
cfg.helpPage ? mw.util.getUrl( cfg.helpPage ) : '#',
text,
id
);
const link = portlet?.querySelector( 'a' );
link?.addEventListener( 'click', callback );
addButtonEvents( link );
}
/*
* Entry point. Check whether we are in a disambiguation page. If so, add a link to start the tool
*/
const install = function() {
cfg = window.DisamAssist.cfg;
txt = window.DisamAssist.txt;
if ( mw.config.get( 'wgAction' ) === 'view' && isDisam() ) {
mw.loader.using( dependencies, () => {
api = new mw.Api();
mw.loader.load( 'https://ru.wikipedia.org/w/index.php?title=Участник:Stjn/DisamAssist.css&action=raw&ctype=text/css', 'text/css' );
// This is a " (disambiguation)" page
if ( new RegExp( cfg.disamRegExp ).exec( getTitle() ) ) {
disamPortletLink( txt.startMain, 't-disamAssist-main', startMain );
disamPortletLink( txt.startSame, 't-disamAssist-same', startSame );
} else {
disamPortletLink( txt.start, 't-disamAssist-page', start );
}
} );
}
};
/*
* Start the tool. Display the UI and begin looking for links to fix
*/
const start = function( e ) {
e?.preventDefault();
if ( !running ) {
console.log(Date.now())
running = true;
links = [];
pageChanges = [];
displayedPages = {};
ensureDABExists().then( function( canMark ) {
canMarkIntentionalLinks = canMark;
createUI();
addUnloadConfirm();
markDisamOptions();
checkEditLimit().then( function() {
togglePendingEditBox( false );
doPage();
} );
} );
}
};
/*
* Start DisamAssist. Disambiguate incoming links to the current page, regardless
* of the title.
*/
const startSame = function( e ) {
e.preventDefault();
forceSamePage = true;
start( e );
};
/*
* Start DisamAssist. If the page title ends with " (disambiguation)", disambiguate
* links to the primary topic article. Otherwise, disambiguate links to the current
* page.
*/
const startMain = function( e ) {
e.preventDefault();
forceSamePage = false;
start( e );
};
/*
* Create and show the user interface.
*/
const createUI = function() {
ui = {
display: $( '<div>' ).addClass( 'disamassist-box disamassist-mainbox' ),
finishedMessage: $( '<div>' ).text( txt.noMoreLinks ).hide(),
pageTitleLine: $( '<span>' ).addClass( 'disamassist-pagetitleline' ),
pendingEditCounter: $( '<div>').addClass( 'disamassist-editcounter' ),
context: $( '<pre>' ).addClass( 'disamassist-context' ),
undoButton: createButton( txt.undo, undo ),
omitButton: createButton( txt.omit, omit ),
endButton: createButton( txt.close, saveAndEnd ),
refreshButton: createButton( txt.refresh, refresh ),
titleAsTextButton: createButton( txt.titleAsText, chooseTitleFromPrompt ),
intentionalLinkButton: canMarkIntentionalLinks ? createButton( txt.intentionalLink, chooseIntentionalLink ) : null,
disamNeededButton: cfg.disamNeededText ? createButton( txt.disamNeeded, chooseDisamNeeded ) : null,
removeLinkButton: createButton( txt.removeLink, chooseLinkRemoval )
};
var top = $( '<div>' ).addClass( 'disamassist-top' )
.append( [ui.pageTitleLine, ui.pendingEditCounter] );
var leftButtons = $( '<div>' ).addClass( 'disamassist-leftbuttons' )
.append( [ui.titleAsTextButton, ui.removeLinkButton, ui.intentionalLinkButton, ui.disamNeededButton, ui.omitButton] );
var rightButtons = $( '<div>' ).addClass( 'disamassist-rightbuttons' )
.append( [ui.undoButton, ui.refreshButton, ui.endButton] );
var allButtons = $( '<div>' ).addClass( 'disamassist-allbuttons' )
.append( [leftButtons, rightButtons] );
ui.display.append( [top, ui.finishedMessage, ui.context, allButtons] );
updateEditCounter();
toggleActionButtons( false );
// Insert the UI in the page
$( '#mw-content-text' ).before( ui.display );
ui.display.hide().fadeIn();
};
/**
* Add button role and related keyboard event
* @param {Element} el
*/
const addButtonEvents = ( el ) => {
el?.setAttribute( 'role', 'button' );
el?.addEventListener( 'keydown', function( e ) {
if ( [ 'Enter', 'Space' ].includes( e.code ) ) {
e.preventDefault();
this.click();
}
} );
}
/*
* If there are pending changes, show a confirm dialog before closing
*/
const addUnloadConfirm = function() {
mw.confirmCloseWindow( {
test: () => {
if ( running && checkActualChanges() ) {
return txt.pending;
} else if ( editCount !== 0 ) {
return txt.editInProgress;
}
}
} );
};
/*
* Mark the disambiguation options as such
*/
const markDisamOptions = function() {
var optionPageTitles = [];
var optionMarkers = [];
getDisamOptions().each( function() {
var link = $( this );
var title = extractPageName( link );
const optionMarker = document.createElement( 'a' );
optionMarker.classList.add( 'disamassist-optionmarker' );
optionMarker.href = link.attr( 'href' );
optionMarker.setAttribute( 'data-title', title );
optionMarker.textContent = txt.optionMarker;
optionMarker.addEventListener( 'click', ( e ) => {
e.preventDefault();
chooseReplacement( title );
} );
addButtonEvents( optionMarker );
link.after( optionMarker );
optionMarkers.push( optionMarker );
optionPageTitles.push( title );
} );
// Now check the disambiguation options and display a different message for those that are
// actually the same as the target page where the links go, as choosing those options doesn't really
// accomplish anything (except bypassing redirects, which might be useful in some cases)
var targetPage = getTargetPage();
fetchRedirects( optionPageTitles.concat( targetPage ) ).done( function( redirects ) {
var endTargetPage = resolveRedirect( targetPage, redirects );
for ( var ii = 0; ii < optionPageTitles.length; ii++ ) {
var endOptionTitle = resolveRedirect( optionPageTitles[ii], redirects );
if ( isSamePage( optionPageTitles[ii], targetPage ) ) {
$( optionMarkers[ii] ).text( txt.targetOptionMarker ).addClass( 'disamassist-curroptionmarker');
} else if ( isSamePage( endOptionTitle, endTargetPage ) ) {
$( optionMarkers[ii] ).text( txt.redirectOptionMarker ).addClass( 'disamassist-curroptionmarker');
}
}
} ).fail( error );
};
/*
* Check whether intentional links to disambiguation pages can be explicitly marked
* as such in this wiki. If so, ensure that a "Foo (disambiguation)" page exists.
* Returns a jQuery promise
*/
const ensureDABExists = function() {
var dfd = new $.Deferred();
var title = getTitle();
// That concept doesn't exist in this wiki.
if ( !cfg.intentionalLinkOption ) {
dfd.resolve( false );
// "Foo (disambiguation)" exists: it's the current page.
} else if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
dfd.resolve( true );
} else {
var disamTitle = cfg.disamFormat.replace( '$1', title );
loadPage( disamTitle ).done( function( page ) {
// "Foo (disambiguation)" doesn't exist.
if ( page.missing ) {
// We try to create it
page.content = cfg.redirectToDisam.replace( '$1', title );
var summary = txt.redirectSummary.replace( '$1', title );
savePage( disamTitle, page, summary, false, true ).done( function() {
dfd.resolve( true );
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
// It does exist
} else {
dfd.resolve( true );
}
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
}
return dfd.promise();
};
/*
* Check whether the edit cooldown applies and sets editLimit accordingly.
* Returns a jQuery promise
*/
const checkEditLimit = function() {
var dfd = new $.Deferred();
if ( cfg.editCooldown <= 0 ) {
editLimit = false;
dfd.resolve();
} else {
fetchRights().done( function( rights ) {
editLimit = !rights?.includes( 'bot' );
} ).fail( function( description ) {
error( description );
editLimit = true;
} ).always( function() {
dfd.resolve();
} );
}
return dfd.promise();
};
/*
* Find and ask the user to fix all the incoming links to the disambiguation ("target")
* page from a single "origin" page
*/
const doPage = function() {
if ( !running ) return; // Voorkom dat er actie wordt ondernomen als het programma gestopt is
if ( pageChanges.length > cfg.historySize ) {
applyChange( pageChanges.shift() );
}
if ( links.length === 0 ) {
var targetPage = getTargetPage();
getBacklinks( targetPage ).done( function( backlinks, pageTitles ) {
if ( !running ) return; // Extra check of de tool nog actief is
var pending = {};
$.each( pendingSaves, function() {
pending[this[0]] = true;
} );
possibleBacklinkDestinations = $.grep( pageTitles, function( t, ii) {
if ( t == targetPage ) {
return true;
}
return removeDisam(t) != targetPage;
} );
titleRegex = new RegExp('\\[\\[([ _]*?(?:' + possibleBacklinkDestinations.join('|').replaceAll('(', '\\(').replaceAll(')','\\)').replaceAll(' ', '[ _]') + ')[ _]*?)(#.*?)?(?:\\|(.*?))?\\]\\]', 'gi');
links = $.grep( backlinks, function( el, ii ) {
return !displayedPages[el] && !pending[el];
} );
if ( links.length === 0 ) {
updateContext();
} else {
doPage();
}
} ).fail( error );
} else {
currentPageTitle = links.shift();
displayedPages[currentPageTitle] = true;
toggleActionButtons( false );
loadPage( currentPageTitle ).done( function( data ) {
if ( !running ) return;
currentPageParameters = data;
currentLink = null;
doLink();
} ).fail( error );
}
};
/*
* Find and ask the user to fix a single incoming link to the disambiguation ("target")
* page
*/
const doLink = function() {
if (!currentPageParameters.redirect) {
currentLink = extractLinkToPage( currentPageParameters.content,
possibleBacklinkDestinations, currentLink ? currentLink.end : 0 );
if ( currentLink ) {
updateContext();
} else {
doPage();
}
} else {
doPage();
}
};
/*
* Replace the target of a link with a different one
* pageTitle: New link target
* extra: Additional text after the link (optional)
* summary: Change summary (optional)
*/
const chooseReplacement = function( pageTitle, extra, summary ) {
if ( choosing ) {
choosing = false;
if ( !summary ) {
if ( pageTitle ) {
summary = txt.summaryChanged.replace( '$1', pageTitle );
} else {
summary = txt.summaryOmitted;
}
}
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
if ( pageTitle && ( pageTitle !== getTargetPage() || extra ) ) {
if ( currentPageParameters.redirect ) {
currentLink.description = pageTitle;
}
currentPageParameters.content =
replaceLink( currentPageParameters.content, pageTitle, currentLink, extra || '' );
}
doLink();
}
};
/*
* Replace the link with an explicit link to the disambiguation page
*/
const chooseIntentionalLink = function() {
const targetPage = getTargetPage();
let title = cfg.disamFormat.replace( '$1', targetPage );
if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
title = targetPage;
}
chooseReplacement( title, '', txt.summaryIntentional );
};
/*
* Prompt for an alternative link target and use it as a replacement
*/
const chooseTitleFromPrompt = function() {
var title = prompt( txt.titleAsTextPrompt );
if ( title !== null ) {
chooseReplacement( title );
}
};
/*
* Remove the current link, leaving the text unchanged
*/
const chooseLinkRemoval = function() {
if ( choosing ) {
var summary = txt.summaryRemoved;
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
currentPageParameters.content = removeLink( currentPageParameters.content, currentLink );
doLink();
}
};
/*
* Add a "disambiguation needed" template after the link
*/
const chooseDisamNeeded = function() {
chooseReplacement( currentLink.title, cfg.disamNeededText, txt.summaryHelpNeeded );
};
/*
* Undo the last change
*/
const undo = function() {
if ( pageChanges.length !== 0 ) {
var lastPage = pageChanges[pageChanges.length - 1];
if ( currentPageTitle !== lastPage.title ) {
links.unshift( currentPageTitle );
currentPageTitle = lastPage.title;
}
currentPageParameters = lastPage.page;
currentPageParameters.content = lastPage.contentBefore.pop();
currentLink = lastPage.links.pop();
lastPage.summary.pop();
if ( lastPage.contentBefore.length === 0 ) {
pageChanges.pop();
}
updateContext();
}
};
/*
* Omit the current link without making a change
*/
const omit = function() {
chooseReplacement( null );
};
/*
* Save all the pending changes and restart the tool.
*/
const refresh = function() {
saveAndEnd();
start();
};
/*
* Enable or disable the buttons that can perform actions on a page or change the current link.
* enabled: Whether to enable or disable the buttons
*/
const toggleActionButtons = function( enabled ) {
var affectedButtons = [ui.omitButton, ui.titleAsTextButton, ui.removeLinkButton,
ui.intentionalLinkButton, ui.disamNeededButton, ui.undoButton];
$.each( affectedButtons, function( ii, button ) {
button?.prop( 'disabled', !enabled );
} );
};
/*
* Show or hide the 'no more links' message
* show: Whether to show or hide the message
*/
const toggleFinishedMessage = function( show ) {
toggleActionButtons( !show );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.finishedMessage.toggle( show );
ui.pageTitleLine.toggle( !show );
ui.context.toggle( !show );
if ( show ) console.log(Date.now())
};
const togglePendingEditBox = function( show ) {
if ( pendingEditBox === null ) {
pendingEditBox = $( '<div>' ).addClass( 'disamassist-box disamassist-pendingeditbox' );
pendingEditBoxText = $( '<div>' );
pendingEditBox.append( pendingEditBoxText ).hide();
if ( editLimit ) {
pendingEditBox.append( $( '<div>' ).text( txt.pendingEditBoxLimited )
.addClass( 'disamassist-subtitle' ) );
}
$( '#mw-content-text' ).before( pendingEditBox );
updateEditCounter();
}
if ( show ) {
pendingEditBox.fadeIn();
} else {
pendingEditBox.fadeOut();
}
};
const notifyCompletion = function() {
var oldTitle = document.title;
document.title = txt.notifyCharacter + document.title;
$( document.body ).one( 'mousemove', function() {
document.title = oldTitle;
} );
};
/*
* Update the displayed information to match the current link
* or lack thereof
*/
const updateContext = function() {
updateEditCounter();
if ( !currentLink ) {
running = false; // Zorg dat de loop definitief stopt
toggleFinishedMessage( true );
} else {
ui.pageTitleLine.html( txt.pageTitleLine.replace( '$1',
mw.util.getUrl( currentPageTitle, {redirect: 'no'} ) ).replace( '$2', mw.html.escape( currentPageTitle ) ) );
var context = extractContext( currentPageParameters.content, currentLink );
ui.context.empty()
.append( $( '<span>' ).text( context[0] ) )
.append( $( '<span>' ).text( context[1] ).addClass( 'disamassist-inclink' ) )
.append( $( '<span>' ).text( context[2] ) );
toggleFinishedMessage( false );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.removeLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.intentionalLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.disamNeededButton?.prop( 'disabled', currentPageParameters.redirect || currentLink.hasDisamTemplate );
choosing = true;
}
};
/*
* Update the count of pending changes
*/
const updateEditCounter = function() {
if ( ui.pendingEditCounter ) {
ui.pendingEditCounter.text( txt.pendingEditCounter.replace( '$1', editCount )
.replace( '$2', countActuallyChangedFullyCheckedPages() ) );
}
if ( pendingEditBox ) {
if ( editCount === 0 && !running ) {
togglePendingEditBox( false );
notifyCompletion();
}
var textContent = editCount;
if ( editLimit ) {
textContent = txt.pendingEditBoxTimeEstimation.replace( '$1', editCount )
.replace( '$2', secondsToHHMMSS( cfg.editCooldown * editCount ) );
}
pendingEditBoxText.text( txt.pendingEditBox.replace( '$1', textContent ) );
}
};
/*
* Apply the changes made to an "origin" page
* pageChange: Change that will be saved
*/
const applyChange = function( pageChange ) {
if ( pageChange.page.content !== pageChange.contentBefore[0] ) {
editCount++;
// Filter duplicates and join
var changeSummaries = pageChange.summary
.filter( ( val, i, arr ) => val && arr.indexOf( val ) === i )
.join( txt.summarySeparator );
var summary = txt.summary.replace( '$1', getTargetPage() ).replace( '$2', changeSummaries );
var save = editLimit ? saveWithCooldown : savePage;
save( pageChange.title, pageChange.page, summary, true, true ).always( function() {
if ( editCount > 0 ) {
editCount--;
}
updateEditCounter();
} ).fail( error );
updateEditCounter();
}
};
/*
* Save all the pending changes
*/
const applyAllChanges = function() {
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
applyChange( pageChanges[ii] );
}
pageChanges = [];
};
/*
* Record a new pending change
* pageTitle: Title of the page
* page: Content of the page
* oldContent: Content of the page before the change
* link: Link that has been changed
* summary: Change summary
*/
const addChange = function( pageTitle, page, oldContent, link, summary ) {
if ( ( pageChanges.length === 0 ) || ( pageChanges[pageChanges.length - 1].title !== pageTitle ) ) {
pageChanges.push( {
title: pageTitle,
page: page,
contentBefore: [],
links: [],
summary: []
} );
}
var lastPageChange = pageChanges[pageChanges.length - 1];
lastPageChange.contentBefore.push( oldContent );
lastPageChange.links.push( link );
lastPageChange.summary.push( summary );
};
/*
* Check whether actual changes are stored in the history array
*/
const checkActualChanges = function() {
return countActualChanges() !== 0;
};
/*
* Return the number of entries in the history array that represent actual changes
*/
const countActualChanges = function() {
var changeCount = 0;
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
if ( pageChanges[ii].page.content !== pageChanges[ii].contentBefore[0] ) {
changeCount++;
}
}
return changeCount;
};
/*
* Return the number of changed pages in the history array, ignoring the last entry
* if we aren't done with that page yet
*/
const countActuallyChangedFullyCheckedPages = function() {
var changeCount = countActualChanges();
if ( pageChanges.length !== 0 ) {
var lastChange = pageChanges[pageChanges.length - 1];
if ( lastChange.title === currentPageTitle && currentLink !== null
&& lastChange.page.content !== lastChange.contentBefore[0] ) {
changeCount--;
}
}
return changeCount;
};
/*
* Find the links to disambiguation options in a disambiguation page
*/
const getDisamOptions = function() {
return $( '#mw-content-text a' ).filter( function() {
return !!extractPageName( $( this ) );
} );
};
/*
* Save all the pending changes and close the tool
*/
const saveAndEnd = function() {
applyAllChanges();
end();
};
/*
* Close the tool
*/
const end = function() {
var currentToolUI = ui.display;
choosing = false;
running = false;
$( '.disamassist-optionmarker' ).remove();
currentToolUI.fadeOut( { complete: function() {
currentToolUI.remove();
if ( editCount !== 0 ) {
togglePendingEditBox( true );
}
} } );
};
/*
* Display an error message
*/
const error = function( errorDescription ) {
var errorBox = $( '<div>' ).addClass( 'disamassist-box disamassist-errorbox' );
errorBox.text( txt.error.replace( '$1', errorDescription ) );
errorBox.append( createButton( txt.dismissError, function() {
errorBox.fadeOut();
} ).addClass( 'disamassist-errorbutton' ) );
var uiIsInPlace = ui && $.contains( document.documentElement, ui.display[0] );
var nextElement = uiIsInPlace ? ui.display : $( '#mw-content-text' );
nextElement.before( errorBox );
errorBox.hide().fadeIn();
}
/*
* Change a link so that it points to the title
* text: The wikitext of the whole page
* title: The new destination of the link
* link: The link that will be modified
* extra: Text that will be added after the link (optional)
*/
const replaceLink = function( text, title, link, extra ) {
var anchor = link.anchor || '';
var newContent;
if ( link.type === 'sortnaam' ) {
newContent = '{{sortnaam|' + link.first + '|' + link.last + '|' + title + anchor + '}}';
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + ( extra || '' ) + linkEnd;
}
if ( link.type === 'ill' ) {
newContent = '{{ill|' + title + anchor + '|' + link.lang + '|' + link.other + ( link.rest || '' ) + '}}';
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + ( extra || '' ) + linkEnd;
}
if ( isSamePage( title, link.description ) ) {
newContent = link.description + anchor;
} else {
newContent = title + anchor + '|' + link.description;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + '[[' + newContent + ']]' + link.afterDescription + ( extra || '' ) + linkEnd;
};
/*
* Remove a link from the text
* text: The wikitext of the whole page
* link: The link that will be removed
*/
const removeLink = function( text, link ) {
var anchor = link.anchor || '';
if ( link.type === 'sortnaam' ) {
var newContent = '{{sortnaam|' + link.first + '|' + link.last + '}}' + anchor;
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + linkEnd;
}
if ( link.type === 'ill' ) {
var newContent = link.title + anchor;
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + linkEnd;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + link.description + anchor + link.afterDescription + linkEnd;
};
/*
* Extract a link from a string in wiki format,
* starting from a given index. Return a link if one can be found,
* otherwise return null.
*/
const extractLink = function( text, lastIndex ) {
// 1. Zoek naar gewone wikitlinks (inclusief optioneel anker #...)
titleRegex.lastIndex = lastIndex;
var wikiMatch = titleRegex.exec( text );
// 2. Zoek naar sortnaam-templates
var sortnaamRegex = new RegExp('{{\\s*sortnaam\\s*\\|\\s*([^|]+?)\\s*\\|\\s*([^|]+?)\\s*(?:\\|\\s*([^|#}]*?)\\s*)?(#[^|}]*)?}}', 'gi');
sortnaamRegex.lastIndex = lastIndex;
var sortnaamMatch = sortnaamRegex.exec( text );
// 3. Zoek naar ill-templates (ondersteunt ook nieuwe regels en extra parameters)
var illRegex = new RegExp('{{\\s*ill\\s*\\|([\\s\\S]*?)}}', 'gi');
illRegex.lastIndex = lastIndex;
var illMatch = illRegex.exec( text );
var matchType = null;
var matchIndex = Infinity;
if ( wikiMatch !== null && wikiMatch.index >= lastIndex ) {
matchIndex = wikiMatch.index;
matchType = 'wiki';
}
if ( sortnaamMatch !== null && sortnaamMatch.index >= lastIndex && sortnaamMatch.index < matchIndex ) {
matchIndex = sortnaamMatch.index;
matchType = 'sortnaam';
}
if ( illMatch !== null && illMatch.index >= lastIndex && illMatch.index < matchIndex ) {
matchIndex = illMatch.index;
matchType = 'ill';
}
if ( matchType === 'wiki' ) {
var possiblyAmbiguous = true;
var hasDisamTemplate = false;
var fullMatchStr = wikiMatch[0];
var anchor = wikiMatch[2] ? wikiMatch[2] : '';
var end = wikiMatch.index + fullMatchStr.length;
var afterDescription = '';
var rest = text.substring( end );
var templateRegex = /^(\w*[.,:;?!)}\s]*){{\s*([^|{}]+?)\s*(?:\|[^{]*?)?}}/g;
var templateMatch = templateRegex.exec( rest );
if ( templateMatch !== null ) {
var templateTitle = getCanonicalTitle( templateMatch[2] );
if ( cfg.disamLinkTemplates?.includes( templateTitle ) ) {
end += templateMatch[0].length;
afterDescription = templateMatch[1].replace(/\s$/, '');
hasDisamTemplate = true;
} else if ( cfg.disamLinkIgnoreTemplates?.includes( templateTitle ) ) {
possiblyAmbiguous = false;
}
}
return {
type: 'wiki',
start: wikiMatch.index,
end: end,
possiblyAmbiguous: possiblyAmbiguous,
hasDisamTemplate: hasDisamTemplate,
title: wikiMatch[1],
description: wikiMatch[3] ? wikiMatch[3] : wikiMatch[1],
anchor: anchor,
afterDescription: afterDescription
};
} else if ( matchType === 'sortnaam' ) {
var first = sortnaamMatch[1].trim();
var last = sortnaamMatch[2].trim();
var explicitTarget = sortnaamMatch[3] ? sortnaamMatch[3].trim() : '';
var anchor = sortnaamMatch[4] ? sortnaamMatch[4].trim() : '';
var target = explicitTarget !== '' ? explicitTarget : ( first + ' ' + last );
return {
type: 'sortnaam',
start: sortnaamMatch.index,
end: sortnaamMatch.index + sortnaamMatch[0].length,
possiblyAmbiguous: true,
hasDisamTemplate: false,
title: target,
description: sortnaamMatch[0],
first: first,
last: last,
explicitTarget: explicitTarget,
anchor: anchor,
afterDescription: ''
};
} else if ( matchType === 'ill' ) {
var inner = illMatch[1];
var parts = inner.split('|');
var firstParam = parts[0].trim();
var anchorMatch = firstParam.match(/^(.*?)(#.*)$/);
var title = anchorMatch ? anchorMatch[1].trim() : firstParam;
var anchor = anchorMatch ? anchorMatch[2] : '';
var lang = parts[1] ? parts[1].trim() : '';
var other = parts[2] ? parts[2].trim() : '';
var restParts = parts.slice(3);
var rest = restParts.length > 0 ? '|' + restParts.join('|') : '';
return {
type: 'ill',
start: illMatch.index,
end: illMatch.index + illMatch[0].length,
possiblyAmbiguous: true,
hasDisamTemplate: false,
title: title,
description: title,
anchor: anchor,
lang: lang,
other: other,
rest: rest,
afterDescription: ''
};
}
return null;
};
/*
* Extract a link to one of a number of destination pages from a string
* ("text") in wiki format, starting from a given index ("lastIndex").
* "Disambiguation needed" templates are included as part of the links.
* text: Page in wiki format
* destinations: Array of page titles to look for
* lastIndex: Index from which the search will start
*/
const extractLinkToPage = function( text, destinations, lastIndex ) {
var link, title;
do {
link = extractLink( text, lastIndex );
if ( link !== null ) {
lastIndex = link.end;
title = getCanonicalTitle( link.title );
}
} while (
link !== null
&& ( !link.possiblyAmbiguous || !destinations?.includes( title ) )
);
return link;
};
/*
* Find the "target" page: either the one we are in or the "main" one found by extracting
* the title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const getTargetPage = function() {
var title = getTitle();
return forceSamePage ? title : removeDisam( title );
};
/*
* Get the page title, with the namespace prefix if any.
*/
const getTitle = function() {
return mw.config.get( 'wgPageName' ).replace(/_/g, ' ')
}
/*
* Extract a "main" title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const removeDisam = function( title ) {
var match = new RegExp( cfg.disamRegExp ).exec( title );
return match ? match[1] : title;
};
/*
* Check whether two page titles are the same
*/
const isSamePage = function( title1, title2 ) {
return getCanonicalTitle( title1 ) === getCanonicalTitle( title2 );
};
/*
* Return the 'canonical title' of a page
*/
const getCanonicalTitle = function( title ) {
try {
title = new mw.Title( title );
// Check that page name matches the current namespace
if ( title.getNamespaceId() !== mw.config.get( 'wgNamespaceNumber' ) ) {
return;
}
const fragment = title.getFragment();
return title.getPrefixedText() + ( fragment ? `#${ fragment }` : '' );
} catch ( ex ) {
// mw.Title seems to be buggy, and some valid titles are rejected
// FIXME: This may cause false negatives
}
};
/*
* Extract the context around a given link in a text string
*/
const extractContext = function( text, link ) {
var contextStart = link.start - cfg.radius;
var contextEnd = link.end + cfg.radius;
var contextPrev = text.substring( contextStart, link.start );
const ellipsis = '…';
if ( contextStart > 0 ) {
contextPrev = ellipsis + contextPrev;
}
var contextNext = text.substring( link.end, contextEnd );
if ( contextEnd < text.length ) {
contextNext = contextNext + ellipsis;
}
return [contextPrev, text.substring( link.start, link.end ), contextNext];
};
/*
* Extract the prefixed page name from a link
*/
const extractPageName = function( link ) {
var pageName = extractPageNameRaw( link );
if ( pageName ) {
return getCanonicalTitle( pageName );
}
return null;
};
/*
* Extract the page name from a link, as is
*/
const extractPageNameRaw = function( link ) {
// Ignore images and interwiki links
if ( link.hasClass( 'image' ) || link.hasClass( 'extiw' ) ) {
return null;
}
var href = link.attr( 'href' );
// Red links in old parser format
if ( link.hasClass( 'new' ) ) {
const oldRedLink = mw.util.getParamValue( 'title', href );
if ( oldRedLink !== null ) {
return oldRedLink;
}
}
// Check that external links are coming from this wiki
if ( href.indexOf( '//' ) === 0 || href.indexOf( 'https://' ) === 0 ) {
try {
const fullUrl = href.indexOf( '//' ) === 0 ? location.protocol + href : href;
let urlData = new URL( fullUrl );
if ( urlData.hostname !== location.hostname ) {
return null;
}
} catch( e ) {
return null;
}
}
var articlePathRegexp = new RegExp(
mw.config.get( 'wgArticlePath' ).replace( '$1', '([^?]*)' )
);
var result = articlePathRegexp.exec( href );
if ( Array.isArray( result ) && result.length > 1 ) {
return decodeURIComponent( result[ 1 ] );
}
return null;
};
/*
* Check whether this is a disambiguation page
*/
const isDisam = function() {
const categories = mw.config.get( 'wgCategories', [] );
return cfg.disamCategories.some( c => categories.includes( c ) );
};
const secondsToHHMMSS = function( totalSeconds ) {
var hhmmss = '';
var hours = Math.floor( totalSeconds / 3600 );
var minutes = Math.floor( totalSeconds % 3600 / 60 );
var seconds = Math.floor( totalSeconds % 3600 % 60 );
if ( hours >= 1 ) {
hhmmss = pad( hours, '0', 2 ) + ':';
}
hhmmss += pad( minutes, '0', 2 ) + ':' + pad( seconds, '0', 2 );
return hhmmss;
};
const pad = function( str, z, width ) {
str = str.toString();
if ( str.length >= width ) {
return str;
} else {
return new Array( width - str.length + 1 ).join( z ) + str;
}
}
/*
* Create a new button
* text: Text that will be displayed on the button
* onClick: Function that will be called when the button is clicked
*/
const createButton = function( text, onClick ) {
var button = $( '<button>', { 'type': 'button' } );
button.text( text );
button.addClass( 'disamassist-button' ).click( onClick );
return button;
};
/*
* Given a page title and an array of possible redirects {from, to} ("canonical titles"), find the page
* at the end of the redirect chain, if there is one. Otherwise, return the page title that was passed
*/
const resolveRedirect = function( pageTitle, possibleRedirects ) {
var appliedRedirect = true;
var visitedPages = {};
var currentPage = getCanonicalTitle( pageTitle );
while ( appliedRedirect ) {
appliedRedirect = false;
for ( var ii = 0; ii < possibleRedirects.length; ii++ ) {
if ( possibleRedirects[ii].from === currentPage ) {
if ( visitedPages[possibleRedirects[ii].to] ) {
// Redirect chain detected
return pageTitle;
}
visitedPages[currentPage] = true;
appliedRedirect = true;
currentPage = possibleRedirects[ii].to;
}
}
}
// No redirect rules applied for an iteration of the outer loop:
// no more redirects. We are done
return currentPage;
};
/*
* Fetch the incoming links to a page. Returns a jQuery promise
* (success - array of titles of pages that contain links to the target page and
* array of "canonical titles" of possible destinations of the backlinks (either
* the target page or redirects to the target page), failure - error description)
* page: Target page
*/
const getBacklinks = function( page ) {
var dfd = new $.Deferred();
api.get( {
'action': 'query',
'list': 'backlinks',
'bltitle': page,
'blredirect': true,
'bllimit': cfg.backlinkLimit,
'blnamespace': cfg.targetNamespaces,
} ).done( function( data ) {
// There might be duplicate entries in some corner cases. We don't care,
// since we are going to check later, anyway
var backlinks = [];
var linkTitles = [getCanonicalTitle( page )];
$.each( data.query.backlinks, function() {
backlinks.push( this.title );
if ( this.redirlinks ) {
linkTitles.push( this.title );
$.each( this.redirlinks, function() {
backlinks.push( this.title );
} );
}
} );
dfd.resolve( backlinks, linkTitles );
} ).fail( function( code, data ) {
dfd.reject( txt.getBacklinksError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download a list of redirects for some pages. Returns a jQuery callback (success -
* array of redirects ({from, to}), failure - error description )
* pageTitles: Array of page titles
*/
const fetchRedirects = function( pageTitles ) {
var dfd = new $.Deferred();
var currentTitles = pageTitles.slice( 0, cfg.queryTitleLimit );
var restTitles = pageTitles.slice( cfg.queryTitleLimit );
api.get( {
action: 'query',
titles: currentTitles,
redirects: true,
} ).done( function( data ) {
var theseRedirects = data.query.redirects ? data.query.redirects : [];
if ( restTitles.length !== 0 ) {
fetchRedirects( restTitles ).done( function( redirects ) {
dfd.resolve( theseRedirects.concat( redirects ) );
} ).fail( function( description ) {
dfd.reject( description );
} );
} else {
dfd.resolve( theseRedirects );
}
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRedirectsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download the list of user rights for the current user. Returns a
* jQuery promise (success - array of right names, error - error description)
*/
const fetchRights = function() {
var dfd = $.Deferred();
api.get( {
action: 'query',
meta: 'userinfo',
uiprop: 'rights',
} ).done( function( data ) {
dfd.resolve( data.query.userinfo.rights );
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRightsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Load the raw page text for a given title. Returns a jQuery promise (success - page
* content, failure - error description)
* pageTitle: Title of the page
*/
const loadPage = function( pageTitle ) {
var dfd = new $.Deferred();
api.get( {
action: 'query',
titles: pageTitle,
prop: 'revisions',
rvprop: 'timestamp|content',
meta: 'tokens',
type: 'csrf',
} ).done( function( data ) {
var pages = data.query.pages;
for ( var key in pages ) {
if ( pages.hasOwnProperty( key ) ) {
break;
}
}
var rawPage = data.query.pages[key];
var page = {};
page.missing = rawPage.missing !== undefined;
if ( rawPage.revisions ) {
page.content = rawPage.revisions[0]['*'];
// Assume anything with # at the start is a redirect
if ( page.content.match( /^#[^\s]*?\s*\[\[/ ) ) {
page.redirect = true;
}
page.baseTimeStamp = rawPage.revisions[0].timestamp;
} else {
page.content = '';
page.baseTimeStamp = null;
}
page.startTimeStamp = rawPage.starttimestamp;
page.editToken = data.query.tokens.csrftoken;
dfd.resolve( page );
} ).fail( function( code, data ) {
dfd.reject( txt.loadPageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
/*
* Register changes to a page, to be saved later. Returns a jQuery promise
* (success - no params, failure - error description). Takes the same parameters
* as savePage
*/
const saveWithCooldown = function() {
var deferred = new $.Deferred();
pendingSaves.push( {args: arguments, dfd: deferred} );
if ( !runningSaves ) {
checkAndSave();
}
return deferred.promise();
};
/*
* Save the first set of changes in the list of pending changes, providing that
* enough time has passed since the last edit
*/
const checkAndSave = function() {
if ( pendingSaves.length === 0 ) {
runningSaves = false;
return;
}
runningSaves = true;
var millisSinceLast = new Date().getTime() - lastEditMillis;
if ( millisSinceLast < cfg.editCooldown * 1000 ) {
setTimeout( checkAndSave, cfg.editCooldown * 1000 - millisSinceLast );
} else {
// The last edit started at least cfg.editCooldown seconds ago
var save = pendingSaves.shift();
savePage.apply( this, save.args ).done( function() {
checkAndSave();
save.dfd.resolve();
} ).fail( function( description ) {
checkAndSave();
save.dfd.reject( description );
} );
// We'll use the time since the last edit started
lastEditMillis = new Date().getTime();
}
};
/*
* Save the changes made to a page. Returns a jQuery promise (success - no params,
* failure - error description)
* pageTitle: Title of the page
* page: Page data
* summary: Summary of the changes made to the page
* minorEdit: Whether to mark the edit as 'minor'
* botEdit: Whether to mark the edit as 'bot'
*/
const savePage = function( pageTitle, page, summary, minorEdit, botEdit ) {
var dfd = new $.Deferred();
api.post( {
action: 'edit',
title: pageTitle,
token: page.editToken,
text: page.content,
basetimestamp: page.baseTimeStamp,
starttimestamp: page.startTimeStamp,
summary: summary,
watchlist: cfg.watch ? cfg.watch : 'nochange',
assertuser: mw.config.get( 'wgUserName' ),
minor: minorEdit,
bot: botEdit,
} ).done( function() {
dfd.resolve();
} ).fail( function( code, data ) {
dfd.reject( txt.savePageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
install();
} )();
// </nowiki>
he7jsbxj0f7vrd2qd3ldfkfg3867gtx
781204
781202
2026-10-27T16:29:27Z
Rots61
44293
781204
javascript
text/javascript
/*
* DisamAssist: a tool for repairing links from articles to disambiguation pages.
*/
// <nowiki>
// @ts-nocheck
( () => {
var cfg = {};
var txt = {};
var ui;
var links, pageChanges;
var currentPageTitle, currentPageParameters, currentLink;
var possibleBacklinkDestinations;
var titleRegex;
var forceSamePage = false;
var running = false;
var choosing = false;
var canMarkIntentionalLinks = false;
var displayedPages = {};
var editCount = 0;
var editLimit;
var pendingSaves = [];
var pendingEditBox = null;
var pendingEditBoxText;
var lastEditMillis = 0;
var runningSaves = false;
var api;
const dependencies = [
'mediawiki.api',
'mediawiki.confirmCloseWindow',
'mediawiki.Title',
'mediawiki.util',
];
/**
* Add an item in Tools menu
* @param {string} text
* @param {string} id
* @param {(e: MouseEvent) => void} callback
*/
const disamPortletLink = ( text, id, callback ) => {
const portlet = mw.util.addPortletLink(
'p-tb',
cfg.helpPage ? mw.util.getUrl( cfg.helpPage ) : '#',
text,
id
);
const link = portlet?.querySelector( 'a' );
link?.addEventListener( 'click', callback );
addButtonEvents( link );
}
/*
* Entry point. Check whether we are in a disambiguation page. If so, add a link to start the tool
*/
const install = function() {
cfg = window.DisamAssist.cfg;
txt = window.DisamAssist.txt;
if ( mw.config.get( 'wgAction' ) === 'view' && isDisam() ) {
mw.loader.using( dependencies, () => {
api = new mw.Api();
mw.loader.load( 'https://ru.wikipedia.org/w/index.php?title=Участник:Stjn/DisamAssist.css&action=raw&ctype=text/css', 'text/css' );
// This is a " (disambiguation)" page
if ( new RegExp( cfg.disamRegExp ).exec( getTitle() ) ) {
disamPortletLink( txt.startMain, 't-disamAssist-main', startMain );
disamPortletLink( txt.startSame, 't-disamAssist-same', startSame );
} else {
disamPortletLink( txt.start, 't-disamAssist-page', start );
}
} );
}
};
/*
* Start the tool. Display the UI and begin looking for links to fix
*/
const start = function( e ) {
e?.preventDefault();
if ( !running ) {
console.log(Date.now())
running = true;
links = [];
pageChanges = [];
displayedPages = {};
ensureDABExists().then( function( canMark ) {
canMarkIntentionalLinks = canMark;
createUI();
addUnloadConfirm();
markDisamOptions();
checkEditLimit().then( function() {
togglePendingEditBox( false );
doPage();
} );
} );
}
};
/*
* Start DisamAssist. Disambiguate incoming links to the current page, regardless
* of the title.
*/
const startSame = function( e ) {
e.preventDefault();
forceSamePage = true;
start( e );
};
/*
* Start DisamAssist. If the page title ends with " (disambiguation)", disambiguate
* links to the primary topic article. Otherwise, disambiguate links to the current
* page.
*/
const startMain = function( e ) {
e.preventDefault();
forceSamePage = false;
start( e );
};
/*
* Create and show the user interface.
*/
const createUI = function() {
ui = {
display: $( '<div>' ).addClass( 'disamassist-box disamassist-mainbox' ),
finishedMessage: $( '<div>' ).text( txt.noMoreLinks ).hide(),
pageTitleLine: $( '<span>' ).addClass( 'disamassist-pagetitleline' ),
pendingEditCounter: $( '<div>').addClass( 'disamassist-editcounter' ),
context: $( '<pre>' ).addClass( 'disamassist-context' ),
undoButton: createButton( txt.undo, undo ),
omitButton: createButton( txt.omit, omit ),
endButton: createButton( txt.close, saveAndEnd ),
refreshButton: createButton( txt.refresh, refresh ),
titleAsTextButton: createButton( txt.titleAsText, chooseTitleFromPrompt ),
intentionalLinkButton: canMarkIntentionalLinks ? createButton( txt.intentionalLink, chooseIntentionalLink ) : null,
disamNeededButton: cfg.disamNeededText ? createButton( txt.disamNeeded, chooseDisamNeeded ) : null,
removeLinkButton: createButton( txt.removeLink, chooseLinkRemoval )
};
var top = $( '<div>' ).addClass( 'disamassist-top' )
.append( [ui.pageTitleLine, ui.pendingEditCounter] );
var leftButtons = $( '<div>' ).addClass( 'disamassist-leftbuttons' )
.append( [ui.titleAsTextButton, ui.removeLinkButton, ui.intentionalLinkButton, ui.disamNeededButton, ui.omitButton] );
var rightButtons = $( '<div>' ).addClass( 'disamassist-rightbuttons' )
.append( [ui.undoButton, ui.refreshButton, ui.endButton] );
var allButtons = $( '<div>' ).addClass( 'disamassist-allbuttons' )
.append( [leftButtons, rightButtons] );
ui.display.append( [top, ui.finishedMessage, ui.context, allButtons] );
updateEditCounter();
toggleActionButtons( false );
// Insert the UI in the page
$( '#mw-content-text' ).before( ui.display );
ui.display.hide().fadeIn();
};
/**
* Add button role and related keyboard event
* @param {Element} el
*/
const addButtonEvents = ( el ) => {
el?.setAttribute( 'role', 'button' );
el?.addEventListener( 'keydown', function( e ) {
if ( [ 'Enter', 'Space' ].includes( e.code ) ) {
e.preventDefault();
this.click();
}
} );
}
/*
* If there are pending changes, show a confirm dialog before closing
*/
const addUnloadConfirm = function() {
mw.confirmCloseWindow( {
test: () => {
if ( running && checkActualChanges() ) {
return txt.pending;
} else if ( editCount !== 0 ) {
return txt.editInProgress;
}
}
} );
};
/*
* Mark the disambiguation options as such
*/
const markDisamOptions = function() {
var optionPageTitles = [];
var optionMarkers = [];
getDisamOptions().each( function() {
var link = $( this );
var title = extractPageName( link );
const optionMarker = document.createElement( 'a' );
optionMarker.classList.add( 'disamassist-optionmarker' );
optionMarker.href = link.attr( 'href' );
optionMarker.setAttribute( 'data-title', title );
optionMarker.textContent = txt.optionMarker;
optionMarker.addEventListener( 'click', ( e ) => {
e.preventDefault();
chooseReplacement( title );
} );
addButtonEvents( optionMarker );
link.after( optionMarker );
optionMarkers.push( optionMarker );
optionPageTitles.push( title );
} );
// Now check the disambiguation options and display a different message for those that are
// actually the same as the target page where the links go, as choosing those options doesn't really
// accomplish anything (except bypassing redirects, which might be useful in some cases)
var targetPage = getTargetPage();
fetchRedirects( optionPageTitles.concat( targetPage ) ).done( function( redirects ) {
var endTargetPage = resolveRedirect( targetPage, redirects );
for ( var ii = 0; ii < optionPageTitles.length; ii++ ) {
var endOptionTitle = resolveRedirect( optionPageTitles[ii], redirects );
if ( isSamePage( optionPageTitles[ii], targetPage ) ) {
$( optionMarkers[ii] ).text( txt.targetOptionMarker ).addClass( 'disamassist-curroptionmarker');
} else if ( isSamePage( endOptionTitle, endTargetPage ) ) {
$( optionMarkers[ii] ).text( txt.redirectOptionMarker ).addClass( 'disamassist-curroptionmarker');
}
}
} ).fail( error );
};
/*
* Check whether intentional links to disambiguation pages can be explicitly marked
* as such in this wiki. If so, ensure that a "Foo (disambiguation)" page exists.
* Returns a jQuery promise
*/
const ensureDABExists = function() {
var dfd = new $.Deferred();
var title = getTitle();
// That concept doesn't exist in this wiki.
if ( !cfg.intentionalLinkOption ) {
dfd.resolve( false );
// "Foo (disambiguation)" exists: it's the current page.
} else if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
dfd.resolve( true );
} else {
var disamTitle = cfg.disamFormat.replace( '$1', title );
loadPage( disamTitle ).done( function( page ) {
// "Foo (disambiguation)" doesn't exist.
if ( page.missing ) {
// We try to create it
page.content = cfg.redirectToDisam.replace( '$1', title );
var summary = txt.redirectSummary.replace( '$1', title );
savePage( disamTitle, page, summary, false, true ).done( function() {
dfd.resolve( true );
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
// It does exist
} else {
dfd.resolve( true );
}
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
}
return dfd.promise();
};
/*
* Check whether the edit cooldown applies and sets editLimit accordingly.
* Returns a jQuery promise
*/
const checkEditLimit = function() {
var dfd = new $.Deferred();
if ( cfg.editCooldown <= 0 ) {
editLimit = false;
dfd.resolve();
} else {
fetchRights().done( function( rights ) {
editLimit = !rights?.includes( 'bot' );
} ).fail( function( description ) {
error( description );
editLimit = true;
} ).always( function() {
dfd.resolve();
} );
}
return dfd.promise();
};
/*
* Find and ask the user to fix all the incoming links to the disambiguation ("target")
* page from a single "origin" page
*/
const doPage = function() {
if ( pageChanges.length > cfg.historySize ) {
applyChange( pageChanges.shift() );
}
if ( links.length === 0 ) {
var targetPage = getTargetPage();
getBacklinks( targetPage ).done( function( backlinks, pageTitles ) {
var pending = {};
$.each( pendingSaves, function() {
pending[this[0]] = true;
} );
possibleBacklinkDestinations = $.grep( pageTitles, function( t, ii) {
if ( t == targetPage ) {
return true;
}
return removeDisam(t) != targetPage;
} );
titleRegex = new RegExp('\\[\\[([ _]*?(?:' + possibleBacklinkDestinations.join('|').replaceAll('(', '\\(').replaceAll(')','\\)').replaceAll(' ', '[ _]') + ')[ _]*?)(#.*?)?(?:\\|(.*?))?\\]\\]', 'gi');
// Only incoming links from pages we haven't seen yet and we aren't currently
// saving (displayedPages is reset when the tool is closed and opened again,
// while the list of pending changes isn't; if the edit cooldown is disabled,
// it will be empty)
links = $.grep( backlinks, function( el, ii ) {
return !displayedPages[el] && !pending[el];
} );
if ( links.length === 0 ) {
updateContext();
} else {
doPage();
}
} ).fail( error );
} else {
currentPageTitle = links.shift();
displayedPages[currentPageTitle] = true;
toggleActionButtons( false );
loadPage( currentPageTitle ).done( function( data ) {
currentPageParameters = data;
currentLink = null;
doLink();
} ).fail( error );
}
};
/*
* Find and ask the user to fix a single incoming link to the disambiguation ("target")
* page
*/
const doLink = function() {
if (!currentPageParameters.redirect) {
currentLink = extractLinkToPage( currentPageParameters.content,
possibleBacklinkDestinations, currentLink ? currentLink.end : 0 );
if ( currentLink ) {
updateContext();
} else {
doPage();
}
} else {
doPage();
}
};
/*
* Replace the target of a link with a different one
* pageTitle: New link target
* extra: Additional text after the link (optional)
* summary: Change summary (optional)
*/
const chooseReplacement = function( pageTitle, extra, summary ) {
if ( choosing ) {
choosing = false;
if ( !summary ) {
if ( pageTitle ) {
summary = txt.summaryChanged.replace( '$1', pageTitle );
} else {
summary = txt.summaryOmitted;
}
}
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
if ( pageTitle && ( pageTitle !== getTargetPage() || extra ) ) {
if ( currentPageParameters.redirect ) {
currentLink.description = pageTitle;
}
currentPageParameters.content =
replaceLink( currentPageParameters.content, pageTitle, currentLink, extra || '' );
}
doLink();
}
};
/*
* Replace the link with an explicit link to the disambiguation page
*/
const chooseIntentionalLink = function() {
const targetPage = getTargetPage();
let title = cfg.disamFormat.replace( '$1', targetPage );
if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
title = targetPage;
}
chooseReplacement( title, '', txt.summaryIntentional );
};
/*
* Prompt for an alternative link target and use it as a replacement
*/
const chooseTitleFromPrompt = function() {
var title = prompt( txt.titleAsTextPrompt );
if ( title !== null ) {
chooseReplacement( title );
}
};
/*
* Remove the current link, leaving the text unchanged
*/
const chooseLinkRemoval = function() {
if ( choosing ) {
var summary = txt.summaryRemoved;
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
currentPageParameters.content = removeLink( currentPageParameters.content, currentLink );
doLink();
}
};
/*
* Add a "disambiguation needed" template after the link
*/
const chooseDisamNeeded = function() {
chooseReplacement( currentLink.title, cfg.disamNeededText, txt.summaryHelpNeeded );
};
/*
* Undo the last change
*/
const undo = function() {
if ( pageChanges.length !== 0 ) {
var lastPage = pageChanges[pageChanges.length - 1];
if ( currentPageTitle !== lastPage.title ) {
links.unshift( currentPageTitle );
currentPageTitle = lastPage.title;
}
currentPageParameters = lastPage.page;
currentPageParameters.content = lastPage.contentBefore.pop();
currentLink = lastPage.links.pop();
lastPage.summary.pop();
if ( lastPage.contentBefore.length === 0 ) {
pageChanges.pop();
}
updateContext();
}
};
/*
* Omit the current link without making a change
*/
const omit = function() {
chooseReplacement( null );
};
/*
* Save all the pending changes and restart the tool.
*/
const refresh = function() {
saveAndEnd();
start();
};
/*
* Enable or disable the buttons that can perform actions on a page or change the current link.
* enabled: Whether to enable or disable the buttons
*/
const toggleActionButtons = function( enabled ) {
var affectedButtons = [ui.omitButton, ui.titleAsTextButton, ui.removeLinkButton,
ui.intentionalLinkButton, ui.disamNeededButton, ui.undoButton];
$.each( affectedButtons, function( ii, button ) {
button?.prop( 'disabled', !enabled );
} );
};
/*
* Show or hide the 'no more links' message
* show: Whether to show or hide the message
*/
const toggleFinishedMessage = function( show ) {
toggleActionButtons( !show );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.finishedMessage.toggle( show );
ui.pageTitleLine.toggle( !show );
ui.context.toggle( !show );
if ( show ) console.log(Date.now())
};
const togglePendingEditBox = function( show ) {
if ( pendingEditBox === null ) {
pendingEditBox = $( '<div>' ).addClass( 'disamassist-box disamassist-pendingeditbox' );
pendingEditBoxText = $( '<div>' );
pendingEditBox.append( pendingEditBoxText ).hide();
if ( editLimit ) {
pendingEditBox.append( $( '<div>' ).text( txt.pendingEditBoxLimited )
.addClass( 'disamassist-subtitle' ) );
}
$( '#mw-content-text' ).before( pendingEditBox );
updateEditCounter();
}
if ( show ) {
pendingEditBox.fadeIn();
} else {
pendingEditBox.fadeOut();
}
};
const notifyCompletion = function() {
var oldTitle = document.title;
document.title = txt.notifyCharacter + document.title;
$( document.body ).one( 'mousemove', function() {
document.title = oldTitle;
} );
};
/*
* Update the displayed information to match the current link
* or lack thereof
*/
const updateContext = function() {
updateEditCounter();
if ( !currentLink ) {
toggleFinishedMessage( true );
} else {
ui.pageTitleLine.html( txt.pageTitleLine.replace( '$1',
mw.util.getUrl( currentPageTitle, {redirect: 'no'} ) ).replace( '$2', mw.html.escape( currentPageTitle ) ) );
var context = extractContext( currentPageParameters.content, currentLink );
ui.context.empty()
.append( $( '<span>' ).text( context[0] ) )
.append( $( '<span>' ).text( context[1] ).addClass( 'disamassist-inclink' ) )
.append( $( '<span>' ).text( context[2] ) );
toggleFinishedMessage( false );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.removeLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.intentionalLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.disamNeededButton?.prop( 'disabled', currentPageParameters.redirect || currentLink.hasDisamTemplate );
choosing = true;
}
};
/*
* Update the count of pending changes
*/
const updateEditCounter = function() {
if ( ui.pendingEditCounter ) {
ui.pendingEditCounter.text( txt.pendingEditCounter.replace( '$1', editCount )
.replace( '$2', countActuallyChangedFullyCheckedPages() ) );
}
if ( pendingEditBox ) {
if ( editCount === 0 && !running ) {
togglePendingEditBox( false );
notifyCompletion();
}
var textContent = editCount;
if ( editLimit ) {
textContent = txt.pendingEditBoxTimeEstimation.replace( '$1', editCount )
.replace( '$2', secondsToHHMMSS( cfg.editCooldown * editCount ) );
}
pendingEditBoxText.text( txt.pendingEditBox.replace( '$1', textContent ) );
}
};
/*
* Apply the changes made to an "origin" page
* pageChange: Change that will be saved
*/
const applyChange = function( pageChange ) {
if ( pageChange.page.content !== pageChange.contentBefore[0] ) {
editCount++;
// Filter duplicates and join
var changeSummaries = pageChange.summary
.filter( ( val, i, arr ) => val && arr.indexOf( val ) === i )
.join( txt.summarySeparator );
var summary = txt.summary.replace( '$1', getTargetPage() ).replace( '$2', changeSummaries );
var save = editLimit ? saveWithCooldown : savePage;
save( pageChange.title, pageChange.page, summary, true, true ).always( function() {
if ( editCount > 0 ) {
editCount--;
}
updateEditCounter();
} ).fail( error );
updateEditCounter();
}
};
/*
* Save all the pending changes
*/
const applyAllChanges = function() {
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
applyChange( pageChanges[ii] );
}
pageChanges = [];
};
/*
* Record a new pending change
* pageTitle: Title of the page
* page: Content of the page
* oldContent: Content of the page before the change
* link: Link that has been changed
* summary: Change summary
*/
const addChange = function( pageTitle, page, oldContent, link, summary ) {
if ( ( pageChanges.length === 0 ) || ( pageChanges[pageChanges.length - 1].title !== pageTitle ) ) {
pageChanges.push( {
title: pageTitle,
page: page,
contentBefore: [],
links: [],
summary: []
} );
}
var lastPageChange = pageChanges[pageChanges.length - 1];
lastPageChange.contentBefore.push( oldContent );
lastPageChange.links.push( link );
lastPageChange.summary.push( summary );
};
/*
* Check whether actual changes are stored in the history array
*/
const checkActualChanges = function() {
return countActualChanges() !== 0;
};
/*
* Return the number of entries in the history array that represent actual changes
*/
const countActualChanges = function() {
var changeCount = 0;
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
if ( pageChanges[ii].page.content !== pageChanges[ii].contentBefore[0] ) {
changeCount++;
}
}
return changeCount;
};
/*
* Return the number of changed pages in the history array, ignoring the last entry
* if we aren't done with that page yet
*/
const countActuallyChangedFullyCheckedPages = function() {
var changeCount = countActualChanges();
if ( pageChanges.length !== 0 ) {
var lastChange = pageChanges[pageChanges.length - 1];
if ( lastChange.title === currentPageTitle && currentLink !== null
&& lastChange.page.content !== lastChange.contentBefore[0] ) {
changeCount--;
}
}
return changeCount;
};
/*
* Find the links to disambiguation options in a disambiguation page
*/
const getDisamOptions = function() {
return $( '#mw-content-text a' ).filter( function() {
return !!extractPageName( $( this ) );
} );
};
/*
* Save all the pending changes and close the tool
*/
const saveAndEnd = function() {
applyAllChanges();
end();
};
/*
* Close the tool
*/
const end = function() {
var currentToolUI = ui.display;
choosing = false;
running = false;
$( '.disamassist-optionmarker' ).remove();
currentToolUI.fadeOut( { complete: function() {
currentToolUI.remove();
if ( editCount !== 0 ) {
togglePendingEditBox( true );
}
} } );
};
/*
* Display an error message
*/
const error = function( errorDescription ) {
var errorBox = $( '<div>' ).addClass( 'disamassist-box disamassist-errorbox' );
errorBox.text( txt.error.replace( '$1', errorDescription ) );
errorBox.append( createButton( txt.dismissError, function() {
errorBox.fadeOut();
} ).addClass( 'disamassist-errorbutton' ) );
var uiIsInPlace = ui && $.contains( document.documentElement, ui.display[0] );
var nextElement = uiIsInPlace ? ui.display : $( '#mw-content-text' );
nextElement.before( errorBox );
errorBox.hide().fadeIn();
}
/*
* Change a link so that it points to the title
* text: The wikitext of the whole page
* title: The new destination of the link
* link: The link that will be modified
* extra: Text that will be added after the link (optional)
*/
const replaceLink = function( text, title, link, extra ) {
var anchor = link.anchor || '';
var newContent;
if ( link.type === 'sortnaam' ) {
newContent = '{{sortnaam|' + link.first + '|' + link.last + '|' + title + anchor + '}}';
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + ( extra || '' ) + linkEnd;
}
if ( isSamePage( title, link.description ) ) {
newContent = link.description + anchor;
} else {
newContent = title + anchor + '|' + link.description;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + '[[' + newContent + ']]' + link.afterDescription + ( extra || '' ) + linkEnd;
};
/*
* Remove a link from the text
* text: The wikitext of the whole page
* link: The link that will be removed
*/
const removeLink = function( text, link ) {
var anchor = link.anchor || '';
if ( link.type === 'sortnaam' ) {
// Als er een derde parameter of anker was, laten we de derde parameter weg of behouden we alleen de basis
var newContent = '{{sortnaam|' + link.first + '|' + link.last + '}}' + anchor;
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + linkEnd;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + link.description + anchor + link.afterDescription + linkEnd;
};
/*
* Extract a link from a string in wiki format,
* starting from a given index. Return a link if one can be found,
* otherwise return null. The "link" includes "disambiguation needed"
* templates inmediately following the link proper
* text: Text from which the link will be extracted
* lastIndex: Index from which the search will start
*/
const extractLink = function( text, lastIndex ) {
// 1. Zoek naar gewone wikitlinks (inclusief optioneel anker #...)
titleRegex.lastIndex = lastIndex;
var wikiMatch = titleRegex.exec( text );
// 2. Zoek naar sortnaam-templates (inclusief optioneel anker na de derde parameter of achter de template)
var sortnaamRegex = new RegExp('{{\\s*sortnaam\\s*\\|\\s*([^|]+?)\\s*\\|\\s*([^|]+?)\\s*(?:\\|\\s*([^|#}]*?)\\s*)?(#[^|}]*)?}}', 'gi');
sortnaamRegex.lastIndex = lastIndex;
var sortnaamMatch = sortnaamRegex.exec( text );
var matchType = null;
var matchIndex = Infinity;
if ( wikiMatch !== null && wikiMatch.index >= lastIndex ) {
matchIndex = wikiMatch.index;
matchType = 'wiki';
}
if ( sortnaamMatch !== null && sortnaamMatch.index >= lastIndex && sortnaamMatch.index < matchIndex ) {
matchIndex = sortnaamMatch.index;
matchType = 'sortnaam';
}
if ( matchType === 'wiki' ) {
var possiblyAmbiguous = true;
var hasDisamTemplate = false;
// wikiMatch[2] bevat het anker als dat in de regex is opgenomen, of we vangen het op via de matchlengte
var fullMatchStr = wikiMatch[0];
var anchor = wikiMatch[2] ? wikiMatch[2] : '';
var end = wikiMatch.index + fullMatchStr.length;
var afterDescription = '';
var rest = text.substring( end );
var templateRegex = /^(\w*[.,:;?!)}\s]*){{\s*([^|{}]+?)\s*(?:\|[^{]*?)?}}/g;
var templateMatch = templateRegex.exec( rest );
if ( templateMatch !== null ) {
var templateTitle = getCanonicalTitle( templateMatch[2] );
if ( cfg.disamLinkTemplates?.includes( templateTitle ) ) {
end += templateMatch[0].length;
afterDescription = templateMatch[1].replace(/\s$/, '');
hasDisamTemplate = true;
} else if ( cfg.disamLinkIgnoreTemplates?.includes( templateTitle ) ) {
possiblyAmbiguous = false;
}
}
return {
type: 'wiki',
start: wikiMatch.index,
end: end,
possiblyAmbiguous: possiblyAmbiguous,
hasDisamTemplate: hasDisamTemplate,
title: wikiMatch[1],
description: wikiMatch[3] ? wikiMatch[3] : wikiMatch[1],
anchor: anchor,
afterDescription: afterDescription
};
} else if ( matchType === 'sortnaam' ) {
var first = sortnaamMatch[1].trim();
var last = sortnaamMatch[2].trim();
var explicitTarget = sortnaamMatch[3] ? sortnaamMatch[3].trim() : '';
var anchor = sortnaamMatch[4] ? sortnaamMatch[4].trim() : '';
var target = explicitTarget !== '' ? explicitTarget : ( first + ' ' + last );
return {
type: 'sortnaam',
start: sortnaamMatch.index,
end: sortnaamMatch.index + sortnaamMatch[0].length,
possiblyAmbiguous: true,
hasDisamTemplate: false,
title: target,
description: sortnaamMatch[0],
first: first,
last: last,
explicitTarget: explicitTarget,
anchor: anchor,
afterDescription: ''
};
}
return null;
};
/*
* Extract a link to one of a number of destination pages from a string
* ("text") in wiki format, starting from a given index ("lastIndex").
* "Disambiguation needed" templates are included as part of the links.
* text: Page in wiki format
* destinations: Array of page titles to look for
* lastIndex: Index from which the search will start
*/
const extractLinkToPage = function( text, destinations, lastIndex ) {
var link, title;
do {
link = extractLink( text, lastIndex );
if ( link !== null ) {
lastIndex = link.end;
title = getCanonicalTitle( link.title );
}
} while (
link !== null
&& ( !link.possiblyAmbiguous || !destinations?.includes( title ) )
);
return link;
};
/*
* Find the "target" page: either the one we are in or the "main" one found by extracting
* the title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const getTargetPage = function() {
var title = getTitle();
return forceSamePage ? title : removeDisam( title );
};
/*
* Get the page title, with the namespace prefix if any.
*/
const getTitle = function() {
return mw.config.get( 'wgPageName' ).replace(/_/g, ' ')
}
/*
* Extract a "main" title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const removeDisam = function( title ) {
var match = new RegExp( cfg.disamRegExp ).exec( title );
return match ? match[1] : title;
};
/*
* Check whether two page titles are the same
*/
const isSamePage = function( title1, title2 ) {
return getCanonicalTitle( title1 ) === getCanonicalTitle( title2 );
};
/*
* Return the 'canonical title' of a page
*/
const getCanonicalTitle = function( title ) {
try {
title = new mw.Title( title );
// Check that page name matches the current namespace
if ( title.getNamespaceId() !== mw.config.get( 'wgNamespaceNumber' ) ) {
return;
}
const fragment = title.getFragment();
return title.getPrefixedText() + ( fragment ? `#${ fragment }` : '' );
} catch ( ex ) {
// mw.Title seems to be buggy, and some valid titles are rejected
// FIXME: This may cause false negatives
}
};
/*
* Extract the context around a given link in a text string
*/
const extractContext = function( text, link ) {
var contextStart = link.start - cfg.radius;
var contextEnd = link.end + cfg.radius;
var contextPrev = text.substring( contextStart, link.start );
const ellipsis = '…';
if ( contextStart > 0 ) {
contextPrev = ellipsis + contextPrev;
}
var contextNext = text.substring( link.end, contextEnd );
if ( contextEnd < text.length ) {
contextNext = contextNext + ellipsis;
}
return [contextPrev, text.substring( link.start, link.end ), contextNext];
};
/*
* Extract the prefixed page name from a link
*/
const extractPageName = function( link ) {
var pageName = extractPageNameRaw( link );
if ( pageName ) {
return getCanonicalTitle( pageName );
}
return null;
};
/*
* Extract the page name from a link, as is
*/
const extractPageNameRaw = function( link ) {
// Ignore images and interwiki links
if ( link.hasClass( 'image' ) || link.hasClass( 'extiw' ) ) {
return null;
}
var href = link.attr( 'href' );
// Red links in old parser format
if ( link.hasClass( 'new' ) ) {
const oldRedLink = mw.util.getParamValue( 'title', href );
if ( oldRedLink !== null ) {
return oldRedLink;
}
}
// Check that external links are coming from this wiki
if ( href.indexOf( '//' ) === 0 || href.indexOf( 'https://' ) === 0 ) {
try {
const fullUrl = href.indexOf( '//' ) === 0 ? location.protocol + href : href;
let urlData = new URL( fullUrl );
if ( urlData.hostname !== location.hostname ) {
return null;
}
} catch( e ) {
return null;
}
}
var articlePathRegexp = new RegExp(
mw.config.get( 'wgArticlePath' ).replace( '$1', '([^?]*)' )
);
var result = articlePathRegexp.exec( href );
if ( Array.isArray( result ) && result.length > 1 ) {
return decodeURIComponent( result[ 1 ] );
}
return null;
};
/*
* Check whether this is a disambiguation page
*/
const isDisam = function() {
const categories = mw.config.get( 'wgCategories', [] );
return cfg.disamCategories.some( c => categories.includes( c ) );
};
const secondsToHHMMSS = function( totalSeconds ) {
var hhmmss = '';
var hours = Math.floor( totalSeconds / 3600 );
var minutes = Math.floor( totalSeconds % 3600 / 60 );
var seconds = Math.floor( totalSeconds % 3600 % 60 );
if ( hours >= 1 ) {
hhmmss = pad( hours, '0', 2 ) + ':';
}
hhmmss += pad( minutes, '0', 2 ) + ':' + pad( seconds, '0', 2 );
return hhmmss;
};
const pad = function( str, z, width ) {
str = str.toString();
if ( str.length >= width ) {
return str;
} else {
return new Array( width - str.length + 1 ).join( z ) + str;
}
}
/*
* Create a new button
* text: Text that will be displayed on the button
* onClick: Function that will be called when the button is clicked
*/
const createButton = function( text, onClick ) {
var button = $( '<button>', { 'type': 'button' } );
button.text( text );
button.addClass( 'disamassist-button' ).click( onClick );
return button;
};
/*
* Given a page title and an array of possible redirects {from, to} ("canonical titles"), find the page
* at the end of the redirect chain, if there is one. Otherwise, return the page title that was passed
*/
const resolveRedirect = function( pageTitle, possibleRedirects ) {
var appliedRedirect = true;
var visitedPages = {};
var currentPage = getCanonicalTitle( pageTitle );
while ( appliedRedirect ) {
appliedRedirect = false;
for ( var ii = 0; ii < possibleRedirects.length; ii++ ) {
if ( possibleRedirects[ii].from === currentPage ) {
if ( visitedPages[possibleRedirects[ii].to] ) {
// Redirect chain detected
return pageTitle;
}
visitedPages[currentPage] = true;
appliedRedirect = true;
currentPage = possibleRedirects[ii].to;
}
}
}
// No redirect rules applied for an iteration of the outer loop:
// no more redirects. We are done
return currentPage;
};
/*
* Fetch the incoming links to a page. Returns a jQuery promise
* (success - array of titles of pages that contain links to the target page and
* array of "canonical titles" of possible destinations of the backlinks (either
* the target page or redirects to the target page), failure - error description)
* page: Target page
*/
const getBacklinks = function( page ) {
var dfd = new $.Deferred();
api.get( {
'action': 'query',
'list': 'backlinks',
'bltitle': page,
'blredirect': true,
'bllimit': cfg.backlinkLimit,
'blnamespace': cfg.targetNamespaces,
} ).done( function( data ) {
// There might be duplicate entries in some corner cases. We don't care,
// since we are going to check later, anyway
var backlinks = [];
var linkTitles = [getCanonicalTitle( page )];
$.each( data.query.backlinks, function() {
backlinks.push( this.title );
if ( this.redirlinks ) {
linkTitles.push( this.title );
$.each( this.redirlinks, function() {
backlinks.push( this.title );
} );
}
} );
dfd.resolve( backlinks, linkTitles );
} ).fail( function( code, data ) {
dfd.reject( txt.getBacklinksError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download a list of redirects for some pages. Returns a jQuery callback (success -
* array of redirects ({from, to}), failure - error description )
* pageTitles: Array of page titles
*/
const fetchRedirects = function( pageTitles ) {
var dfd = new $.Deferred();
var currentTitles = pageTitles.slice( 0, cfg.queryTitleLimit );
var restTitles = pageTitles.slice( cfg.queryTitleLimit );
api.get( {
action: 'query',
titles: currentTitles,
redirects: true,
} ).done( function( data ) {
var theseRedirects = data.query.redirects ? data.query.redirects : [];
if ( restTitles.length !== 0 ) {
fetchRedirects( restTitles ).done( function( redirects ) {
dfd.resolve( theseRedirects.concat( redirects ) );
} ).fail( function( description ) {
dfd.reject( description );
} );
} else {
dfd.resolve( theseRedirects );
}
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRedirectsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download the list of user rights for the current user. Returns a
* jQuery promise (success - array of right names, error - error description)
*/
const fetchRights = function() {
var dfd = $.Deferred();
api.get( {
action: 'query',
meta: 'userinfo',
uiprop: 'rights',
} ).done( function( data ) {
dfd.resolve( data.query.userinfo.rights );
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRightsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Load the raw page text for a given title. Returns a jQuery promise (success - page
* content, failure - error description)
* pageTitle: Title of the page
*/
const loadPage = function( pageTitle ) {
var dfd = new $.Deferred();
api.get( {
action: 'query',
titles: pageTitle,
prop: 'revisions',
rvprop: 'timestamp|content',
meta: 'tokens',
type: 'csrf',
} ).done( function( data ) {
var pages = data.query.pages;
for ( var key in pages ) {
if ( pages.hasOwnProperty( key ) ) {
break;
}
}
var rawPage = data.query.pages[key];
var page = {};
page.missing = rawPage.missing !== undefined;
if ( rawPage.revisions ) {
page.content = rawPage.revisions[0]['*'];
// Assume anything with # at the start is a redirect
if ( page.content.match( /^#[^\s]*?\s*\[\[/ ) ) {
page.redirect = true;
}
page.baseTimeStamp = rawPage.revisions[0].timestamp;
} else {
page.content = '';
page.baseTimeStamp = null;
}
page.startTimeStamp = rawPage.starttimestamp;
page.editToken = data.query.tokens.csrftoken;
dfd.resolve( page );
} ).fail( function( code, data ) {
dfd.reject( txt.loadPageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
/*
* Register changes to a page, to be saved later. Returns a jQuery promise
* (success - no params, failure - error description). Takes the same parameters
* as savePage
*/
const saveWithCooldown = function() {
var deferred = new $.Deferred();
pendingSaves.push( {args: arguments, dfd: deferred} );
if ( !runningSaves ) {
checkAndSave();
}
return deferred.promise();
};
/*
* Save the first set of changes in the list of pending changes, providing that
* enough time has passed since the last edit
*/
const checkAndSave = function() {
if ( pendingSaves.length === 0 ) {
runningSaves = false;
return;
}
runningSaves = true;
var millisSinceLast = new Date().getTime() - lastEditMillis;
if ( millisSinceLast < cfg.editCooldown * 1000 ) {
setTimeout( checkAndSave, cfg.editCooldown * 1000 - millisSinceLast );
} else {
// The last edit started at least cfg.editCooldown seconds ago
var save = pendingSaves.shift();
savePage.apply( this, save.args ).done( function() {
checkAndSave();
save.dfd.resolve();
} ).fail( function( description ) {
checkAndSave();
save.dfd.reject( description );
} );
// We'll use the time since the last edit started
lastEditMillis = new Date().getTime();
}
};
/*
* Save the changes made to a page. Returns a jQuery promise (success - no params,
* failure - error description)
* pageTitle: Title of the page
* page: Page data
* summary: Summary of the changes made to the page
* minorEdit: Whether to mark the edit as 'minor'
* botEdit: Whether to mark the edit as 'bot'
*/
const savePage = function( pageTitle, page, summary, minorEdit, botEdit ) {
var dfd = new $.Deferred();
api.post( {
action: 'edit',
title: pageTitle,
token: page.editToken,
text: page.content,
basetimestamp: page.baseTimeStamp,
starttimestamp: page.startTimeStamp,
summary: summary,
watchlist: cfg.watch ? cfg.watch : 'nochange',
assertuser: mw.config.get( 'wgUserName' ),
minor: minorEdit,
bot: botEdit,
} ).done( function() {
dfd.resolve();
} ).fail( function( code, data ) {
dfd.reject( txt.savePageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
install();
} )();
// </nowiki>
hm77g6qklg9y3s1msf851l8ak2ujh4j
781206
781204
2026-10-27T16:30:18Z
Rots61
44293
781206
javascript
text/javascript
/*
* DisamAssist: a tool for repairing links from articles to disambiguation pages.
*/
// <nowiki>
// @ts-nocheck
( () => {
var cfg = {};
var txt = {};
var ui;
var links, pageChanges;
var currentPageTitle, currentPageParameters, currentLink;
var possibleBacklinkDestinations;
var titleRegex;
var forceSamePage = false;
var running = false;
var choosing = false;
var canMarkIntentionalLinks = false;
var displayedPages = {};
var editCount = 0;
var editLimit;
var pendingSaves = [];
var pendingEditBox = null;
var pendingEditBoxText;
var lastEditMillis = 0;
var runningSaves = false;
var api;
const dependencies = [
'mediawiki.api',
'mediawiki.confirmCloseWindow',
'mediawiki.Title',
'mediawiki.util',
];
/**
* Add an item in Tools menu
* @param {string} text
* @param {string} id
* @param {(e: MouseEvent) => void} callback
*/
const disamPortletLink = ( text, id, callback ) => {
const portlet = mw.util.addPortletLink(
'p-tb',
cfg.helpPage ? mw.util.getUrl( cfg.helpPage ) : '#',
text,
id
);
const link = portlet?.querySelector( 'a' );
link?.addEventListener( 'click', callback );
addButtonEvents( link );
}
/*
* Entry point. Check whether we are in a disambiguation page. If so, add a link to start the tool
*/
const install = function() {
cfg = window.DisamAssist.cfg;
txt = window.DisamAssist.txt;
if ( mw.config.get( 'wgAction' ) === 'view' && isDisam() ) {
mw.loader.using( dependencies, () => {
api = new mw.Api();
mw.loader.load( 'https://ru.wikipedia.org/w/index.php?title=Участник:Stjn/DisamAssist.css&action=raw&ctype=text/css', 'text/css' );
// This is a " (disambiguation)" page
if ( new RegExp( cfg.disamRegExp ).exec( getTitle() ) ) {
disamPortletLink( txt.startMain, 't-disamAssist-main', startMain );
disamPortletLink( txt.startSame, 't-disamAssist-same', startSame );
} else {
disamPortletLink( txt.start, 't-disamAssist-page', start );
}
} );
}
};
/*
* Start the tool. Display the UI and begin looking for links to fix
*/
const start = function( e ) {
e?.preventDefault();
if ( !running ) {
console.log(Date.now())
running = true;
links = [];
pageChanges = [];
displayedPages = {};
ensureDABExists().then( function( canMark ) {
canMarkIntentionalLinks = canMark;
createUI();
addUnloadConfirm();
markDisamOptions();
checkEditLimit().then( function() {
togglePendingEditBox( false );
doPage();
} );
} );
}
};
/*
* Start DisamAssist. Disambiguate incoming links to the current page, regardless
* of the title.
*/
const startSame = function( e ) {
e.preventDefault();
forceSamePage = true;
start( e );
};
/*
* Start DisamAssist. If the page title ends with " (disambiguation)", disambiguate
* links to the primary topic article. Otherwise, disambiguate links to the current
* page.
*/
const startMain = function( e ) {
e.preventDefault();
forceSamePage = false;
start( e );
};
/*
* Create and show the user interface.
*/
const createUI = function() {
ui = {
display: $( '<div>' ).addClass( 'disamassist-box disamassist-mainbox' ),
finishedMessage: $( '<div>' ).text( txt.noMoreLinks ).hide(),
pageTitleLine: $( '<span>' ).addClass( 'disamassist-pagetitleline' ),
pendingEditCounter: $( '<div>').addClass( 'disamassist-editcounter' ),
context: $( '<pre>' ).addClass( 'disamassist-context' ),
undoButton: createButton( txt.undo, undo ),
omitButton: createButton( txt.omit, omit ),
endButton: createButton( txt.close, saveAndEnd ),
refreshButton: createButton( txt.refresh, refresh ),
titleAsTextButton: createButton( txt.titleAsText, chooseTitleFromPrompt ),
intentionalLinkButton: canMarkIntentionalLinks ? createButton( txt.intentionalLink, chooseIntentionalLink ) : null,
disamNeededButton: cfg.disamNeededText ? createButton( txt.disamNeeded, chooseDisamNeeded ) : null,
removeLinkButton: createButton( txt.removeLink, chooseLinkRemoval )
};
var top = $( '<div>' ).addClass( 'disamassist-top' )
.append( [ui.pageTitleLine, ui.pendingEditCounter] );
var leftButtons = $( '<div>' ).addClass( 'disamassist-leftbuttons' )
.append( [ui.titleAsTextButton, ui.removeLinkButton, ui.intentionalLinkButton, ui.disamNeededButton, ui.omitButton] );
var rightButtons = $( '<div>' ).addClass( 'disamassist-rightbuttons' )
.append( [ui.undoButton, ui.refreshButton, ui.endButton] );
var allButtons = $( '<div>' ).addClass( 'disamassist-allbuttons' )
.append( [leftButtons, rightButtons] );
ui.display.append( [top, ui.finishedMessage, ui.context, allButtons] );
updateEditCounter();
toggleActionButtons( false );
// Insert the UI in the page
$( '#mw-content-text' ).before( ui.display );
ui.display.hide().fadeIn();
};
/**
* Add button role and related keyboard event
* @param {Element} el
*/
const addButtonEvents = ( el ) => {
el?.setAttribute( 'role', 'button' );
el?.addEventListener( 'keydown', function( e ) {
if ( [ 'Enter', 'Space' ].includes( e.code ) ) {
e.preventDefault();
this.click();
}
} );
}
/*
* If there are pending changes, show a confirm dialog before closing
*/
const addUnloadConfirm = function() {
mw.confirmCloseWindow( {
test: () => {
if ( running && checkActualChanges() ) {
return txt.pending;
} else if ( editCount !== 0 ) {
return txt.editInProgress;
}
}
} );
};
/*
* Mark the disambiguation options as such
*/
const markDisamOptions = function() {
var optionPageTitles = [];
var optionMarkers = [];
getDisamOptions().each( function() {
var link = $( this );
var title = extractPageName( link );
const optionMarker = document.createElement( 'a' );
optionMarker.classList.add( 'disamassist-optionmarker' );
optionMarker.href = link.attr( 'href' );
optionMarker.setAttribute( 'data-title', title );
optionMarker.textContent = txt.optionMarker;
optionMarker.addEventListener( 'click', ( e ) => {
e.preventDefault();
chooseReplacement( title );
} );
addButtonEvents( optionMarker );
link.after( optionMarker );
optionMarkers.push( optionMarker );
optionPageTitles.push( title );
} );
// Now check the disambiguation options and display a different message for those that are
// actually the same as the target page where the links go, as choosing those options doesn't really
// accomplish anything (except bypassing redirects, which might be useful in some cases)
var targetPage = getTargetPage();
fetchRedirects( optionPageTitles.concat( targetPage ) ).done( function( redirects ) {
var endTargetPage = resolveRedirect( targetPage, redirects );
for ( var ii = 0; ii < optionPageTitles.length; ii++ ) {
var endOptionTitle = resolveRedirect( optionPageTitles[ii], redirects );
if ( isSamePage( optionPageTitles[ii], targetPage ) ) {
$( optionMarkers[ii] ).text( txt.targetOptionMarker ).addClass( 'disamassist-curroptionmarker');
} else if ( isSamePage( endOptionTitle, endTargetPage ) ) {
$( optionMarkers[ii] ).text( txt.redirectOptionMarker ).addClass( 'disamassist-curroptionmarker');
}
}
} ).fail( error );
};
/*
* Check whether intentional links to disambiguation pages can be explicitly marked
* as such in this wiki. If so, ensure that a "Foo (disambiguation)" page exists.
* Returns a jQuery promise
*/
const ensureDABExists = function() {
var dfd = new $.Deferred();
var title = getTitle();
// That concept doesn't exist in this wiki.
if ( !cfg.intentionalLinkOption ) {
dfd.resolve( false );
// "Foo (disambiguation)" exists: it's the current page.
} else if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
dfd.resolve( true );
} else {
var disamTitle = cfg.disamFormat.replace( '$1', title );
loadPage( disamTitle ).done( function( page ) {
// "Foo (disambiguation)" doesn't exist.
if ( page.missing ) {
// We try to create it
page.content = cfg.redirectToDisam.replace( '$1', title );
var summary = txt.redirectSummary.replace( '$1', title );
savePage( disamTitle, page, summary, false, true ).done( function() {
dfd.resolve( true );
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
// It does exist
} else {
dfd.resolve( true );
}
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
}
return dfd.promise();
};
/*
* Check whether the edit cooldown applies and sets editLimit accordingly.
* Returns a jQuery promise
*/
const checkEditLimit = function() {
var dfd = new $.Deferred();
if ( cfg.editCooldown <= 0 ) {
editLimit = false;
dfd.resolve();
} else {
fetchRights().done( function( rights ) {
editLimit = !rights?.includes( 'bot' );
} ).fail( function( description ) {
error( description );
editLimit = true;
} ).always( function() {
dfd.resolve();
} );
}
return dfd.promise();
};
/*
* Find and ask the user to fix all the incoming links to the disambiguation ("target")
* page from a single "origin" page
*/
const doPage = function() {
if ( pageChanges.length > cfg.historySize ) {
applyChange( pageChanges.shift() );
}
if ( links.length === 0 ) {
var targetPage = getTargetPage();
getBacklinks( targetPage ).done( function( backlinks, pageTitles ) {
var pending = {};
$.each( pendingSaves, function() {
pending[this[0]] = true;
} );
possibleBacklinkDestinations = $.grep( pageTitles, function( t, ii) {
if ( t == targetPage ) {
return true;
}
return removeDisam(t) != targetPage;
} );
titleRegex = new RegExp('\\[\\[([ _]*?(?:' + possibleBacklinkDestinations.join('|').replaceAll('(', '\\(').replaceAll(')','\\)').replaceAll(' ', '[ _]') + ')[ _]*?)(#.*?)?(?:\\|(.*?))?\\]\\]', 'gi');
// Only incoming links from pages we haven't seen yet and we aren't currently
// saving (displayedPages is reset when the tool is closed and opened again,
// while the list of pending changes isn't; if the edit cooldown is disabled,
// it will be empty)
links = $.grep( backlinks, function( el, ii ) {
return !displayedPages[el] && !pending[el];
} );
if ( links.length === 0 ) {
updateContext();
} else {
doPage();
}
} ).fail( error );
} else {
currentPageTitle = links.shift();
displayedPages[currentPageTitle] = true;
toggleActionButtons( false );
loadPage( currentPageTitle ).done( function( data ) {
currentPageParameters = data;
currentLink = null;
doLink();
} ).fail( error );
}
};
/*
* Find and ask the user to fix a single incoming link to the disambiguation ("target")
* page
*/
const doLink = function() {
if (!currentPageParameters.redirect) {
currentLink = extractLinkToPage( currentPageParameters.content,
possibleBacklinkDestinations, currentLink ? currentLink.end : 0 );
if ( currentLink ) {
updateContext();
} else {
doPage();
}
} else {
doPage();
}
};
/*
* Replace the target of a link with a different one
* pageTitle: New link target
* extra: Additional text after the link (optional)
* summary: Change summary (optional)
*/
const chooseReplacement = function( pageTitle, extra, summary ) {
if ( choosing ) {
choosing = false;
if ( !summary ) {
if ( pageTitle ) {
summary = txt.summaryChanged.replace( '$1', pageTitle );
} else {
summary = txt.summaryOmitted;
}
}
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
if ( pageTitle && ( pageTitle !== getTargetPage() || extra ) ) {
if ( currentPageParameters.redirect ) {
currentLink.description = pageTitle;
}
currentPageParameters.content =
replaceLink( currentPageParameters.content, pageTitle, currentLink, extra || '' );
}
doLink();
}
};
/*
* Replace the link with an explicit link to the disambiguation page
*/
const chooseIntentionalLink = function() {
const targetPage = getTargetPage();
let title = cfg.disamFormat.replace( '$1', targetPage );
if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
title = targetPage;
}
chooseReplacement( title, '', txt.summaryIntentional );
};
/*
* Prompt for an alternative link target and use it as a replacement
*/
const chooseTitleFromPrompt = function() {
var title = prompt( txt.titleAsTextPrompt );
if ( title !== null ) {
chooseReplacement( title );
}
};
/*
* Remove the current link, leaving the text unchanged
*/
const chooseLinkRemoval = function() {
if ( choosing ) {
var summary = txt.summaryRemoved;
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
currentPageParameters.content = removeLink( currentPageParameters.content, currentLink );
doLink();
}
};
/*
* Add a "disambiguation needed" template after the link
*/
const chooseDisamNeeded = function() {
chooseReplacement( currentLink.title, cfg.disamNeededText, txt.summaryHelpNeeded );
};
/*
* Undo the last change
*/
const undo = function() {
if ( pageChanges.length !== 0 ) {
var lastPage = pageChanges[pageChanges.length - 1];
if ( currentPageTitle !== lastPage.title ) {
links.unshift( currentPageTitle );
currentPageTitle = lastPage.title;
}
currentPageParameters = lastPage.page;
currentPageParameters.content = lastPage.contentBefore.pop();
currentLink = lastPage.links.pop();
lastPage.summary.pop();
if ( lastPage.contentBefore.length === 0 ) {
pageChanges.pop();
}
updateContext();
}
};
/*
* Omit the current link without making a change
*/
const omit = function() {
chooseReplacement( null );
};
/*
* Save all the pending changes and restart the tool.
*/
const refresh = function() {
saveAndEnd();
start();
};
/*
* Enable or disable the buttons that can perform actions on a page or change the current link.
* enabled: Whether to enable or disable the buttons
*/
const toggleActionButtons = function( enabled ) {
var affectedButtons = [ui.omitButton, ui.titleAsTextButton, ui.removeLinkButton,
ui.intentionalLinkButton, ui.disamNeededButton, ui.undoButton];
$.each( affectedButtons, function( ii, button ) {
button?.prop( 'disabled', !enabled );
} );
};
/*
* Show or hide the 'no more links' message
* show: Whether to show or hide the message
*/
const toggleFinishedMessage = function( show ) {
toggleActionButtons( !show );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.finishedMessage.toggle( show );
ui.pageTitleLine.toggle( !show );
ui.context.toggle( !show );
if ( show ) console.log(Date.now())
};
const togglePendingEditBox = function( show ) {
if ( pendingEditBox === null ) {
pendingEditBox = $( '<div>' ).addClass( 'disamassist-box disamassist-pendingeditbox' );
pendingEditBoxText = $( '<div>' );
pendingEditBox.append( pendingEditBoxText ).hide();
if ( editLimit ) {
pendingEditBox.append( $( '<div>' ).text( txt.pendingEditBoxLimited )
.addClass( 'disamassist-subtitle' ) );
}
$( '#mw-content-text' ).before( pendingEditBox );
updateEditCounter();
}
if ( show ) {
pendingEditBox.fadeIn();
} else {
pendingEditBox.fadeOut();
}
};
const notifyCompletion = function() {
var oldTitle = document.title;
document.title = txt.notifyCharacter + document.title;
$( document.body ).one( 'mousemove', function() {
document.title = oldTitle;
} );
};
/*
* Update the displayed information to match the current link
* or lack thereof
*/
const updateContext = function() {
updateEditCounter();
if ( !currentLink ) {
toggleFinishedMessage( true );
} else {
ui.pageTitleLine.html( txt.pageTitleLine.replace( '$1',
mw.util.getUrl( currentPageTitle, {redirect: 'no'} ) ).replace( '$2', mw.html.escape( currentPageTitle ) ) );
var context = extractContext( currentPageParameters.content, currentLink );
ui.context.empty()
.append( $( '<span>' ).text( context[0] ) )
.append( $( '<span>' ).text( context[1] ).addClass( 'disamassist-inclink' ) )
.append( $( '<span>' ).text( context[2] ) );
toggleFinishedMessage( false );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.removeLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.intentionalLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.disamNeededButton?.prop( 'disabled', currentPageParameters.redirect || currentLink.hasDisamTemplate );
choosing = true;
}
};
/*
* Update the count of pending changes
*/
const updateEditCounter = function() {
if ( ui.pendingEditCounter ) {
ui.pendingEditCounter.text( txt.pendingEditCounter.replace( '$1', editCount )
.replace( '$2', countActuallyChangedFullyCheckedPages() ) );
}
if ( pendingEditBox ) {
if ( editCount === 0 && !running ) {
togglePendingEditBox( false );
notifyCompletion();
}
var textContent = editCount;
if ( editLimit ) {
textContent = txt.pendingEditBoxTimeEstimation.replace( '$1', editCount )
.replace( '$2', secondsToHHMMSS( cfg.editCooldown * editCount ) );
}
pendingEditBoxText.text( txt.pendingEditBox.replace( '$1', textContent ) );
}
};
/*
* Apply the changes made to an "origin" page
* pageChange: Change that will be saved
*/
const applyChange = function( pageChange ) {
if ( pageChange.page.content !== pageChange.contentBefore[0] ) {
editCount++;
// Filter duplicates and join
var changeSummaries = pageChange.summary
.filter( ( val, i, arr ) => val && arr.indexOf( val ) === i )
.join( txt.summarySeparator );
var summary = txt.summary.replace( '$1', getTargetPage() ).replace( '$2', changeSummaries );
var save = editLimit ? saveWithCooldown : savePage;
save( pageChange.title, pageChange.page, summary, true, true ).always( function() {
if ( editCount > 0 ) {
editCount--;
}
updateEditCounter();
} ).fail( error );
updateEditCounter();
}
};
/*
* Save all the pending changes
*/
const applyAllChanges = function() {
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
applyChange( pageChanges[ii] );
}
pageChanges = [];
};
/*
* Record a new pending change
* pageTitle: Title of the page
* page: Content of the page
* oldContent: Content of the page before the change
* link: Link that has been changed
* summary: Change summary
*/
const addChange = function( pageTitle, page, oldContent, link, summary ) {
if ( ( pageChanges.length === 0 ) || ( pageChanges[pageChanges.length - 1].title !== pageTitle ) ) {
pageChanges.push( {
title: pageTitle,
page: page,
contentBefore: [],
links: [],
summary: []
} );
}
var lastPageChange = pageChanges[pageChanges.length - 1];
lastPageChange.contentBefore.push( oldContent );
lastPageChange.links.push( link );
lastPageChange.summary.push( summary );
};
/*
* Check whether actual changes are stored in the history array
*/
const checkActualChanges = function() {
return countActualChanges() !== 0;
};
/*
* Return the number of entries in the history array that represent actual changes
*/
const countActualChanges = function() {
var changeCount = 0;
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
if ( pageChanges[ii].page.content !== pageChanges[ii].contentBefore[0] ) {
changeCount++;
}
}
return changeCount;
};
/*
* Return the number of changed pages in the history array, ignoring the last entry
* if we aren't done with that page yet
*/
const countActuallyChangedFullyCheckedPages = function() {
var changeCount = countActualChanges();
if ( pageChanges.length !== 0 ) {
var lastChange = pageChanges[pageChanges.length - 1];
if ( lastChange.title === currentPageTitle && currentLink !== null
&& lastChange.page.content !== lastChange.contentBefore[0] ) {
changeCount--;
}
}
return changeCount;
};
/*
* Find the links to disambiguation options in a disambiguation page
*/
const getDisamOptions = function() {
return $( '#mw-content-text a' ).filter( function() {
return !!extractPageName( $( this ) );
} );
};
/*
* Save all the pending changes and close the tool
*/
const saveAndEnd = function() {
applyAllChanges();
end();
};
/*
* Close the tool
*/
const end = function() {
var currentToolUI = ui.display;
choosing = false;
running = false;
$( '.disamassist-optionmarker' ).remove();
currentToolUI.fadeOut( { complete: function() {
currentToolUI.remove();
if ( editCount !== 0 ) {
togglePendingEditBox( true );
}
} } );
};
/*
* Display an error message
*/
const error = function( errorDescription ) {
var errorBox = $( '<div>' ).addClass( 'disamassist-box disamassist-errorbox' );
errorBox.text( txt.error.replace( '$1', errorDescription ) );
errorBox.append( createButton( txt.dismissError, function() {
errorBox.fadeOut();
} ).addClass( 'disamassist-errorbutton' ) );
var uiIsInPlace = ui && $.contains( document.documentElement, ui.display[0] );
var nextElement = uiIsInPlace ? ui.display : $( '#mw-content-text' );
nextElement.before( errorBox );
errorBox.hide().fadeIn();
}
/*
* Change a link so that it points to the title
* text: The wikitext of the whole page
* title: The new destination of the link
* link: The link that will be modified
* extra: Text that will be added after the link (optional)
*/
const replaceLink = function( text, title, link, extra ) {
var newContent;
if ( link.type === 'sortnaam' ) {
newContent = '{{sortnaam|' + link.first + '|' + link.last + '|' + title + '}}';
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + ( extra || '' ) + linkEnd;
}
if ( isSamePage( title, link.description ) ) {
// [[B|A]] should be replaced with [[A]] rather than [[A|A]]
newContent = link.description;
} else {
newContent = title + '|' + link.description;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + '[[' + newContent + ']]' + link.afterDescription + ( extra || '' ) + linkEnd;
};
/*
* Remove a link from the text
* text: The wikitext of the whole page
* link: The link that will be removed
*/
const removeLink = function( text, link ) {
if ( link.type === 'sortnaam' ) {
var newContent = '{{sortnaam|' + link.first + '|' + link.last + '}}';
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + linkEnd;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + link.description + link.afterDescription + linkEnd;
};
/*
* Extract a link from a string in wiki format,
* starting from a given index. Return a link if one can be found,
* otherwise return null. The "link" includes "disambiguation needed"
* templates inmediately following the link proper
* text: Text from which the link will be extracted
* lastIndex: Index from which the search will start
*/
const extractLink = function( text, lastIndex ) {
// Zoek eerst naar gewone wikitlinks
titleRegex.lastIndex = lastIndex;
var wikiMatch = titleRegex.exec( text );
// Zoek naar sortnaam-templates (case-insensitief)
var sortnaamRegex = new RegExp('{{\\s*sortnaam\\s*\\|\\s*([^|]+?)\\s*\\|\\s*([^|]+?)\\s*(?:\\|\\s*([^|}]*?)\\s*)?}}', 'gi');
sortnaamRegex.lastIndex = lastIndex;
var sortnaamMatch = sortnaamRegex.exec( text );
var matchType = null;
var matchIndex = Infinity;
if ( wikiMatch !== null && wikiMatch.index >= lastIndex ) {
matchIndex = wikiMatch.index;
matchType = 'wiki';
}
if ( sortnaamMatch !== null && sortnaamMatch.index >= lastIndex && sortnaamMatch.index < matchIndex ) {
matchIndex = sortnaamMatch.index;
matchType = 'sortnaam';
}
if ( matchType === 'wiki' ) {
var possiblyAmbiguous = true;
var hasDisamTemplate = false;
var end = wikiMatch.index + 4 + wikiMatch[1].length + ( wikiMatch[3] ? 1 + wikiMatch[3].length : 0 );
var afterDescription = '';
var rest = text.substring( end );
var templateRegex = /^(\w*[.,:;?!)}\s]*){{\s*([^|{}]+?)\s*(?:\|[^{]*?)?}}/g;
var templateMatch = templateRegex.exec( rest );
if ( templateMatch !== null ) {
var templateTitle = getCanonicalTitle( templateMatch[2] );
if ( cfg.disamLinkTemplates?.includes( templateTitle ) ) {
end += templateMatch[0].length;
afterDescription = templateMatch[1].replace(/\s$/, '');
hasDisamTemplate = true;
} else if ( cfg.disamLinkIgnoreTemplates?.includes( templateTitle ) ) {
possiblyAmbiguous = false;
}
}
return {
type: 'wiki',
start: wikiMatch.index,
end: end,
possiblyAmbiguous: possiblyAmbiguous,
hasDisamTemplate: hasDisamTemplate,
title: wikiMatch[1],
description: wikiMatch[3] ? wikiMatch[3] : wikiMatch[1],
afterDescription: afterDescription
};
} else if ( matchType === 'sortnaam' ) {
var first = sortnaamMatch[1].trim();
var last = sortnaamMatch[2].trim();
var explicitTarget = sortnaamMatch[3] ? sortnaamMatch[3].trim() : '';
var target = explicitTarget !== '' ? explicitTarget : ( first + ' ' + last );
return {
type: 'sortnaam',
start: sortnaamMatch.index,
end: sortnaamMatch.index + sortnaamMatch[0].length,
possiblyAmbiguous: true,
hasDisamTemplate: false,
title: target,
description: sortnaamMatch[0],
first: first,
last: last,
explicitTarget: explicitTarget,
afterDescription: ''
};
}
return null;
};
/*
* Extract a link to one of a number of destination pages from a string
* ("text") in wiki format, starting from a given index ("lastIndex").
* "Disambiguation needed" templates are included as part of the links.
* text: Page in wiki format
* destinations: Array of page titles to look for
* lastIndex: Index from which the search will start
*/
const extractLinkToPage = function( text, destinations, lastIndex ) {
var link, title;
do {
link = extractLink( text, lastIndex );
if ( link !== null ) {
lastIndex = link.end;
title = getCanonicalTitle( link.title );
}
} while (
link !== null
&& ( !link.possiblyAmbiguous || !destinations?.includes( title ) )
);
return link;
};
/*
* Find the "target" page: either the one we are in or the "main" one found by extracting
* the title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const getTargetPage = function() {
var title = getTitle();
return forceSamePage ? title : removeDisam( title );
};
/*
* Get the page title, with the namespace prefix if any.
*/
const getTitle = function() {
return mw.config.get( 'wgPageName' ).replace(/_/g, ' ')
}
/*
* Extract a "main" title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const removeDisam = function( title ) {
var match = new RegExp( cfg.disamRegExp ).exec( title );
return match ? match[1] : title;
};
/*
* Check whether two page titles are the same
*/
const isSamePage = function( title1, title2 ) {
return getCanonicalTitle( title1 ) === getCanonicalTitle( title2 );
};
/*
* Return the 'canonical title' of a page
*/
const getCanonicalTitle = function( title ) {
try {
title = new mw.Title( title );
// Check that page name matches the current namespace
if ( title.getNamespaceId() !== mw.config.get( 'wgNamespaceNumber' ) ) {
return;
}
const fragment = title.getFragment();
return title.getPrefixedText() + ( fragment ? `#${ fragment }` : '' );
} catch ( ex ) {
// mw.Title seems to be buggy, and some valid titles are rejected
// FIXME: This may cause false negatives
}
};
/*
* Extract the context around a given link in a text string
*/
const extractContext = function( text, link ) {
var contextStart = link.start - cfg.radius;
var contextEnd = link.end + cfg.radius;
var contextPrev = text.substring( contextStart, link.start );
const ellipsis = '…';
if ( contextStart > 0 ) {
contextPrev = ellipsis + contextPrev;
}
var contextNext = text.substring( link.end, contextEnd );
if ( contextEnd < text.length ) {
contextNext = contextNext + ellipsis;
}
return [contextPrev, text.substring( link.start, link.end ), contextNext];
};
/*
* Extract the prefixed page name from a link
*/
const extractPageName = function( link ) {
var pageName = extractPageNameRaw( link );
if ( pageName ) {
return getCanonicalTitle( pageName );
}
return null;
};
/*
* Extract the page name from a link, as is
*/
const extractPageNameRaw = function( link ) {
// Ignore images and interwiki links
if ( link.hasClass( 'image' ) || link.hasClass( 'extiw' ) ) {
return null;
}
var href = link.attr( 'href' );
// Red links in old parser format
if ( link.hasClass( 'new' ) ) {
const oldRedLink = mw.util.getParamValue( 'title', href );
if ( oldRedLink !== null ) {
return oldRedLink;
}
}
// Check that external links are coming from this wiki
if ( href.indexOf( '//' ) === 0 || href.indexOf( 'https://' ) === 0 ) {
try {
const fullUrl = href.indexOf( '//' ) === 0 ? location.protocol + href : href;
let urlData = new URL( fullUrl );
if ( urlData.hostname !== location.hostname ) {
return null;
}
} catch( e ) {
return null;
}
}
var articlePathRegexp = new RegExp(
mw.config.get( 'wgArticlePath' ).replace( '$1', '([^?]*)' )
);
var result = articlePathRegexp.exec( href );
if ( Array.isArray( result ) && result.length > 1 ) {
return decodeURIComponent( result[ 1 ] );
}
return null;
};
/*
* Check whether this is a disambiguation page
*/
const isDisam = function() {
const categories = mw.config.get( 'wgCategories', [] );
return cfg.disamCategories.some( c => categories.includes( c ) );
};
const secondsToHHMMSS = function( totalSeconds ) {
var hhmmss = '';
var hours = Math.floor( totalSeconds / 3600 );
var minutes = Math.floor( totalSeconds % 3600 / 60 );
var seconds = Math.floor( totalSeconds % 3600 % 60 );
if ( hours >= 1 ) {
hhmmss = pad( hours, '0', 2 ) + ':';
}
hhmmss += pad( minutes, '0', 2 ) + ':' + pad( seconds, '0', 2 );
return hhmmss;
};
const pad = function( str, z, width ) {
str = str.toString();
if ( str.length >= width ) {
return str;
} else {
return new Array( width - str.length + 1 ).join( z ) + str;
}
}
/*
* Create a new button
* text: Text that will be displayed on the button
* onClick: Function that will be called when the button is clicked
*/
const createButton = function( text, onClick ) {
var button = $( '<button>', { 'type': 'button' } );
button.text( text );
button.addClass( 'disamassist-button' ).click( onClick );
return button;
};
/*
* Given a page title and an array of possible redirects {from, to} ("canonical titles"), find the page
* at the end of the redirect chain, if there is one. Otherwise, return the page title that was passed
*/
const resolveRedirect = function( pageTitle, possibleRedirects ) {
var appliedRedirect = true;
var visitedPages = {};
var currentPage = getCanonicalTitle( pageTitle );
while ( appliedRedirect ) {
appliedRedirect = false;
for ( var ii = 0; ii < possibleRedirects.length; ii++ ) {
if ( possibleRedirects[ii].from === currentPage ) {
if ( visitedPages[possibleRedirects[ii].to] ) {
// Redirect chain detected
return pageTitle;
}
visitedPages[currentPage] = true;
appliedRedirect = true;
currentPage = possibleRedirects[ii].to;
}
}
}
// No redirect rules applied for an iteration of the outer loop:
// no more redirects. We are done
return currentPage;
};
/*
* Fetch the incoming links to a page. Returns a jQuery promise
* (success - array of titles of pages that contain links to the target page and
* array of "canonical titles" of possible destinations of the backlinks (either
* the target page or redirects to the target page), failure - error description)
* page: Target page
*/
const getBacklinks = function( page ) {
var dfd = new $.Deferred();
api.get( {
'action': 'query',
'list': 'backlinks',
'bltitle': page,
'blredirect': true,
'bllimit': cfg.backlinkLimit,
'blnamespace': cfg.targetNamespaces,
} ).done( function( data ) {
// There might be duplicate entries in some corner cases. We don't care,
// since we are going to check later, anyway
var backlinks = [];
var linkTitles = [getCanonicalTitle( page )];
$.each( data.query.backlinks, function() {
backlinks.push( this.title );
if ( this.redirlinks ) {
linkTitles.push( this.title );
$.each( this.redirlinks, function() {
backlinks.push( this.title );
} );
}
} );
dfd.resolve( backlinks, linkTitles );
} ).fail( function( code, data ) {
dfd.reject( txt.getBacklinksError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download a list of redirects for some pages. Returns a jQuery callback (success -
* array of redirects ({from, to}), failure - error description )
* pageTitles: Array of page titles
*/
const fetchRedirects = function( pageTitles ) {
var dfd = new $.Deferred();
var currentTitles = pageTitles.slice( 0, cfg.queryTitleLimit );
var restTitles = pageTitles.slice( cfg.queryTitleLimit );
api.get( {
action: 'query',
titles: currentTitles,
redirects: true,
} ).done( function( data ) {
var theseRedirects = data.query.redirects ? data.query.redirects : [];
if ( restTitles.length !== 0 ) {
fetchRedirects( restTitles ).done( function( redirects ) {
dfd.resolve( theseRedirects.concat( redirects ) );
} ).fail( function( description ) {
dfd.reject( description );
} );
} else {
dfd.resolve( theseRedirects );
}
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRedirectsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download the list of user rights for the current user. Returns a
* jQuery promise (success - array of right names, error - error description)
*/
const fetchRights = function() {
var dfd = $.Deferred();
api.get( {
action: 'query',
meta: 'userinfo',
uiprop: 'rights',
} ).done( function( data ) {
dfd.resolve( data.query.userinfo.rights );
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRightsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Load the raw page text for a given title. Returns a jQuery promise (success - page
* content, failure - error description)
* pageTitle: Title of the page
*/
const loadPage = function( pageTitle ) {
var dfd = new $.Deferred();
api.get( {
action: 'query',
titles: pageTitle,
prop: 'revisions',
rvprop: 'timestamp|content',
meta: 'tokens',
type: 'csrf',
} ).done( function( data ) {
var pages = data.query.pages;
for ( var key in pages ) {
if ( pages.hasOwnProperty( key ) ) {
break;
}
}
var rawPage = data.query.pages[key];
var page = {};
page.missing = rawPage.missing !== undefined;
if ( rawPage.revisions ) {
page.content = rawPage.revisions[0]['*'];
// Assume anything with # at the start is a redirect
if ( page.content.match( /^#[^\s]*?\s*\[\[/ ) ) {
page.redirect = true;
}
page.baseTimeStamp = rawPage.revisions[0].timestamp;
} else {
page.content = '';
page.baseTimeStamp = null;
}
page.startTimeStamp = rawPage.starttimestamp;
page.editToken = data.query.tokens.csrftoken;
dfd.resolve( page );
} ).fail( function( code, data ) {
dfd.reject( txt.loadPageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
/*
* Register changes to a page, to be saved later. Returns a jQuery promise
* (success - no params, failure - error description). Takes the same parameters
* as savePage
*/
const saveWithCooldown = function() {
var deferred = new $.Deferred();
pendingSaves.push( {args: arguments, dfd: deferred} );
if ( !runningSaves ) {
checkAndSave();
}
return deferred.promise();
};
/*
* Save the first set of changes in the list of pending changes, providing that
* enough time has passed since the last edit
*/
const checkAndSave = function() {
if ( pendingSaves.length === 0 ) {
runningSaves = false;
return;
}
runningSaves = true;
var millisSinceLast = new Date().getTime() - lastEditMillis;
if ( millisSinceLast < cfg.editCooldown * 1000 ) {
setTimeout( checkAndSave, cfg.editCooldown * 1000 - millisSinceLast );
} else {
// The last edit started at least cfg.editCooldown seconds ago
var save = pendingSaves.shift();
savePage.apply( this, save.args ).done( function() {
checkAndSave();
save.dfd.resolve();
} ).fail( function( description ) {
checkAndSave();
save.dfd.reject( description );
} );
// We'll use the time since the last edit started
lastEditMillis = new Date().getTime();
}
};
/*
* Save the changes made to a page. Returns a jQuery promise (success - no params,
* failure - error description)
* pageTitle: Title of the page
* page: Page data
* summary: Summary of the changes made to the page
* minorEdit: Whether to mark the edit as 'minor'
* botEdit: Whether to mark the edit as 'bot'
*/
const savePage = function( pageTitle, page, summary, minorEdit, botEdit ) {
var dfd = new $.Deferred();
api.post( {
action: 'edit',
title: pageTitle,
token: page.editToken,
text: page.content,
basetimestamp: page.baseTimeStamp,
starttimestamp: page.startTimeStamp,
summary: summary,
watchlist: cfg.watch ? cfg.watch : 'nochange',
assertuser: mw.config.get( 'wgUserName' ),
minor: minorEdit,
bot: botEdit,
} ).done( function() {
dfd.resolve();
} ).fail( function( code, data ) {
dfd.reject( txt.savePageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
install();
} )();
// </nowiki>
o9wpwfi8bv7dow28110ftqwcxk4q52g
781207
781206
2026-10-27T16:31:49Z
Rots61
44293
781207
javascript
text/javascript
/*
* DisamAssist: a tool for repairing links from articles to disambiguation pages.
*/
// <nowiki>
// @ts-nocheck
( () => {
var cfg = {};
var txt = {};
var ui;
var links, pageChanges;
var currentPageTitle, currentPageParameters, currentLink;
var possibleBacklinkDestinations;
var titleRegex;
var forceSamePage = false;
var running = false;
var choosing = false;
var canMarkIntentionalLinks = false;
var displayedPages = {};
var editCount = 0;
var editLimit;
var pendingSaves = [];
var pendingEditBox = null;
var pendingEditBoxText;
var lastEditMillis = 0;
var runningSaves = false;
var api;
const dependencies = [
'mediawiki.api',
'mediawiki.confirmCloseWindow',
'mediawiki.Title',
'mediawiki.util',
];
/**
* Add an item in Tools menu
* @param {string} text
* @param {string} id
* @param {(e: MouseEvent) => void} callback
*/
const disamPortletLink = ( text, id, callback ) => {
const portlet = mw.util.addPortletLink(
'p-tb',
cfg.helpPage ? mw.util.getUrl( cfg.helpPage ) : '#',
text,
id
);
const link = portlet?.querySelector( 'a' );
link?.addEventListener( 'click', callback );
addButtonEvents( link );
}
/*
* Entry point. Check whether we are in a disambiguation page. If so, add a link to start the tool
*/
const install = function() {
cfg = window.DisamAssist.cfg;
txt = window.DisamAssist.txt;
if ( mw.config.get( 'wgAction' ) === 'view' && isDisam() ) {
mw.loader.using( dependencies, () => {
api = new mw.Api();
mw.loader.load( 'https://ru.wikipedia.org/w/index.php?title=Участник:Stjn/DisamAssist.css&action=raw&ctype=text/css', 'text/css' );
// This is a " (disambiguation)" page
if ( new RegExp( cfg.disamRegExp ).exec( getTitle() ) ) {
disamPortletLink( txt.startMain, 't-disamAssist-main', startMain );
disamPortletLink( txt.startSame, 't-disamAssist-same', startSame );
} else {
disamPortletLink( txt.start, 't-disamAssist-page', start );
}
} );
}
};
/*
* Start the tool. Display the UI and begin looking for links to fix
*/
const start = function( e ) {
e?.preventDefault();
if ( !running ) {
console.log(Date.now())
running = true;
links = [];
pageChanges = [];
displayedPages = {};
ensureDABExists().then( function( canMark ) {
canMarkIntentionalLinks = canMark;
createUI();
addUnloadConfirm();
markDisamOptions();
checkEditLimit().then( function() {
togglePendingEditBox( false );
doPage();
} );
} );
}
};
/*
* Start DisamAssist. Disambiguate incoming links to the current page, regardless
* of the title.
*/
const startSame = function( e ) {
e.preventDefault();
forceSamePage = true;
start( e );
};
/*
* Start DisamAssist. If the page title ends with " (disambiguation)", disambiguate
* links to the primary topic article. Otherwise, disambiguate links to the current
* page.
*/
const startMain = function( e ) {
e.preventDefault();
forceSamePage = false;
start( e );
};
/*
* Create and show the user interface.
*/
const createUI = function() {
ui = {
display: $( '<div>' ).addClass( 'disamassist-box disamassist-mainbox' ),
finishedMessage: $( '<div>' ).text( txt.noMoreLinks ).hide(),
pageTitleLine: $( '<span>' ).addClass( 'disamassist-pagetitleline' ),
pendingEditCounter: $( '<div>').addClass( 'disamassist-editcounter' ),
context: $( '<pre>' ).addClass( 'disamassist-context' ),
undoButton: createButton( txt.undo, undo ),
omitButton: createButton( txt.omit, omit ),
endButton: createButton( txt.close, saveAndEnd ),
refreshButton: createButton( txt.refresh, refresh ),
titleAsTextButton: createButton( txt.titleAsText, chooseTitleFromPrompt ),
intentionalLinkButton: canMarkIntentionalLinks ? createButton( txt.intentionalLink, chooseIntentionalLink ) : null,
disamNeededButton: cfg.disamNeededText ? createButton( txt.disamNeeded, chooseDisamNeeded ) : null,
removeLinkButton: createButton( txt.removeLink, chooseLinkRemoval )
};
var top = $( '<div>' ).addClass( 'disamassist-top' )
.append( [ui.pageTitleLine, ui.pendingEditCounter] );
var leftButtons = $( '<div>' ).addClass( 'disamassist-leftbuttons' )
.append( [ui.titleAsTextButton, ui.removeLinkButton, ui.intentionalLinkButton, ui.disamNeededButton, ui.omitButton] );
var rightButtons = $( '<div>' ).addClass( 'disamassist-rightbuttons' )
.append( [ui.undoButton, ui.refreshButton, ui.endButton] );
var allButtons = $( '<div>' ).addClass( 'disamassist-allbuttons' )
.append( [leftButtons, rightButtons] );
ui.display.append( [top, ui.finishedMessage, ui.context, allButtons] );
updateEditCounter();
toggleActionButtons( false );
// Insert the UI in the page
$( '#mw-content-text' ).before( ui.display );
ui.display.hide().fadeIn();
};
/**
* Add button role and related keyboard event
* @param {Element} el
*/
const addButtonEvents = ( el ) => {
el?.setAttribute( 'role', 'button' );
el?.addEventListener( 'keydown', function( e ) {
if ( [ 'Enter', 'Space' ].includes( e.code ) ) {
e.preventDefault();
this.click();
}
} );
}
/*
* If there are pending changes, show a confirm dialog before closing
*/
const addUnloadConfirm = function() {
mw.confirmCloseWindow( {
test: () => {
if ( running && checkActualChanges() ) {
return txt.pending;
} else if ( editCount !== 0 ) {
return txt.editInProgress;
}
}
} );
};
/*
* Mark the disambiguation options as such
*/
const markDisamOptions = function() {
var optionPageTitles = [];
var optionMarkers = [];
getDisamOptions().each( function() {
var link = $( this );
var title = extractPageName( link );
const optionMarker = document.createElement( 'a' );
optionMarker.classList.add( 'disamassist-optionmarker' );
optionMarker.href = link.attr( 'href' );
optionMarker.setAttribute( 'data-title', title );
optionMarker.textContent = txt.optionMarker;
optionMarker.addEventListener( 'click', ( e ) => {
e.preventDefault();
chooseReplacement( title );
} );
addButtonEvents( optionMarker );
link.after( optionMarker );
optionMarkers.push( optionMarker );
optionPageTitles.push( title );
} );
// Now check the disambiguation options and display a different message for those that are
// actually the same as the target page where the links go, as choosing those options doesn't really
// accomplish anything (except bypassing redirects, which might be useful in some cases)
var targetPage = getTargetPage();
fetchRedirects( optionPageTitles.concat( targetPage ) ).done( function( redirects ) {
var endTargetPage = resolveRedirect( targetPage, redirects );
for ( var ii = 0; ii < optionPageTitles.length; ii++ ) {
var endOptionTitle = resolveRedirect( optionPageTitles[ii], redirects );
if ( isSamePage( optionPageTitles[ii], targetPage ) ) {
$( optionMarkers[ii] ).text( txt.targetOptionMarker ).addClass( 'disamassist-curroptionmarker');
} else if ( isSamePage( endOptionTitle, endTargetPage ) ) {
$( optionMarkers[ii] ).text( txt.redirectOptionMarker ).addClass( 'disamassist-curroptionmarker');
}
}
} ).fail( error );
};
/*
* Check whether intentional links to disambiguation pages can be explicitly marked
* as such in this wiki. If so, ensure that a "Foo (disambiguation)" page exists.
* Returns a jQuery promise
*/
const ensureDABExists = function() {
var dfd = new $.Deferred();
var title = getTitle();
// That concept doesn't exist in this wiki.
if ( !cfg.intentionalLinkOption ) {
dfd.resolve( false );
// "Foo (disambiguation)" exists: it's the current page.
} else if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
dfd.resolve( true );
} else {
var disamTitle = cfg.disamFormat.replace( '$1', title );
loadPage( disamTitle ).done( function( page ) {
// "Foo (disambiguation)" doesn't exist.
if ( page.missing ) {
// We try to create it
page.content = cfg.redirectToDisam.replace( '$1', title );
var summary = txt.redirectSummary.replace( '$1', title );
savePage( disamTitle, page, summary, false, true ).done( function() {
dfd.resolve( true );
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
// It does exist
} else {
dfd.resolve( true );
}
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
}
return dfd.promise();
};
/*
* Check whether the edit cooldown applies and sets editLimit accordingly.
* Returns a jQuery promise
*/
const checkEditLimit = function() {
var dfd = new $.Deferred();
if ( cfg.editCooldown <= 0 ) {
editLimit = false;
dfd.resolve();
} else {
fetchRights().done( function( rights ) {
editLimit = !rights?.includes( 'bot' );
} ).fail( function( description ) {
error( description );
editLimit = true;
} ).always( function() {
dfd.resolve();
} );
}
return dfd.promise();
};
/*
* Find and ask the user to fix all the incoming links to the disambiguation ("target")
* page from a single "origin" page
*/
const doPage = function() {
if ( pageChanges.length > cfg.historySize ) {
applyChange( pageChanges.shift() );
}
if ( links.length === 0 ) {
var targetPage = getTargetPage();
getBacklinks( targetPage ).done( function( backlinks, pageTitles ) {
var pending = {};
$.each( pendingSaves, function() {
pending[this[0]] = true;
} );
possibleBacklinkDestinations = $.grep( pageTitles, function( t, ii) {
if ( t == targetPage ) {
return true;
}
return removeDisam(t) != targetPage;
} );
titleRegex = new RegExp('\\[\\[([ _]*?(?:' + possibleBacklinkDestinations.join('|').replaceAll('(', '\\(').replaceAll(')','\\)').replaceAll(' ', '[ _]') + ')[ _]*?)(#.*?)?(?:\\|(.*?))?\\]\\]', 'gi');
// Only incoming links from pages we haven't seen yet and we aren't currently
// saving (displayedPages is reset when the tool is closed and opened again,
// while the list of pending changes isn't; if the edit cooldown is disabled,
// it will be empty)
links = $.grep( backlinks, function( el, ii ) {
return !displayedPages[el] && !pending[el];
} );
if ( links.length === 0 ) {
updateContext();
} else {
doPage();
}
} ).fail( error );
} else {
currentPageTitle = links.shift();
displayedPages[currentPageTitle] = true;
toggleActionButtons( false );
loadPage( currentPageTitle ).done( function( data ) {
currentPageParameters = data;
currentLink = null;
doLink();
} ).fail( error );
}
};
/*
* Find and ask the user to fix a single incoming link to the disambiguation ("target")
* page
*/
const doLink = function() {
if (!currentPageParameters.redirect) {
currentLink = extractLinkToPage( currentPageParameters.content,
possibleBacklinkDestinations, currentLink ? currentLink.end : 0 );
if ( currentLink ) {
updateContext();
} else {
doPage();
}
} else {
doPage();
}
};
/*
* Replace the target of a link with a different one
* pageTitle: New link target
* extra: Additional text after the link (optional)
* summary: Change summary (optional)
*/
const chooseReplacement = function( pageTitle, extra, summary ) {
if ( choosing ) {
choosing = false;
if ( !summary ) {
if ( pageTitle ) {
summary = txt.summaryChanged.replace( '$1', pageTitle );
} else {
summary = txt.summaryOmitted;
}
}
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
if ( pageTitle && ( pageTitle !== getTargetPage() || extra ) ) {
if ( currentPageParameters.redirect ) {
currentLink.description = pageTitle;
}
currentPageParameters.content =
replaceLink( currentPageParameters.content, pageTitle, currentLink, extra || '' );
}
doLink();
}
};
/*
* Replace the link with an explicit link to the disambiguation page
*/
const chooseIntentionalLink = function() {
const targetPage = getTargetPage();
let title = cfg.disamFormat.replace( '$1', targetPage );
if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
title = targetPage;
}
chooseReplacement( title, '', txt.summaryIntentional );
};
/*
* Prompt for an alternative link target and use it as a replacement
*/
const chooseTitleFromPrompt = function() {
var title = prompt( txt.titleAsTextPrompt );
if ( title !== null ) {
chooseReplacement( title );
}
};
/*
* Remove the current link, leaving the text unchanged
*/
const chooseLinkRemoval = function() {
if ( choosing ) {
var summary = txt.summaryRemoved;
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
currentPageParameters.content = removeLink( currentPageParameters.content, currentLink );
doLink();
}
};
/*
* Add a "disambiguation needed" template after the link
*/
const chooseDisamNeeded = function() {
chooseReplacement( currentLink.title, cfg.disamNeededText, txt.summaryHelpNeeded );
};
/*
* Undo the last change
*/
const undo = function() {
if ( pageChanges.length !== 0 ) {
var lastPage = pageChanges[pageChanges.length - 1];
if ( currentPageTitle !== lastPage.title ) {
links.unshift( currentPageTitle );
currentPageTitle = lastPage.title;
}
currentPageParameters = lastPage.page;
currentPageParameters.content = lastPage.contentBefore.pop();
currentLink = lastPage.links.pop();
lastPage.summary.pop();
if ( lastPage.contentBefore.length === 0 ) {
pageChanges.pop();
}
updateContext();
}
};
/*
* Omit the current link without making a change
*/
const omit = function() {
chooseReplacement( null );
};
/*
* Save all the pending changes and restart the tool.
*/
const refresh = function() {
saveAndEnd();
start();
};
/*
* Enable or disable the buttons that can perform actions on a page or change the current link.
* enabled: Whether to enable or disable the buttons
*/
const toggleActionButtons = function( enabled ) {
var affectedButtons = [ui.omitButton, ui.titleAsTextButton, ui.removeLinkButton,
ui.intentionalLinkButton, ui.disamNeededButton, ui.undoButton];
$.each( affectedButtons, function( ii, button ) {
button?.prop( 'disabled', !enabled );
} );
};
/*
* Show or hide the 'no more links' message
* show: Whether to show or hide the message
*/
const toggleFinishedMessage = function( show ) {
toggleActionButtons( !show );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.finishedMessage.toggle( show );
ui.pageTitleLine.toggle( !show );
ui.context.toggle( !show );
if ( show ) console.log(Date.now())
};
const togglePendingEditBox = function( show ) {
if ( pendingEditBox === null ) {
pendingEditBox = $( '<div>' ).addClass( 'disamassist-box disamassist-pendingeditbox' );
pendingEditBoxText = $( '<div>' );
pendingEditBox.append( pendingEditBoxText ).hide();
if ( editLimit ) {
pendingEditBox.append( $( '<div>' ).text( txt.pendingEditBoxLimited )
.addClass( 'disamassist-subtitle' ) );
}
$( '#mw-content-text' ).before( pendingEditBox );
updateEditCounter();
}
if ( show ) {
pendingEditBox.fadeIn();
} else {
pendingEditBox.fadeOut();
}
};
const notifyCompletion = function() {
var oldTitle = document.title;
document.title = txt.notifyCharacter + document.title;
$( document.body ).one( 'mousemove', function() {
document.title = oldTitle;
} );
};
/*
* Update the displayed information to match the current link
* or lack thereof
*/
const updateContext = function() {
updateEditCounter();
if ( !currentLink ) {
toggleFinishedMessage( true );
} else {
ui.pageTitleLine.html( txt.pageTitleLine.replace( '$1',
mw.util.getUrl( currentPageTitle, {redirect: 'no'} ) ).replace( '$2', mw.html.escape( currentPageTitle ) ) );
var context = extractContext( currentPageParameters.content, currentLink );
ui.context.empty()
.append( $( '<span>' ).text( context[0] ) )
.append( $( '<span>' ).text( context[1] ).addClass( 'disamassist-inclink' ) )
.append( $( '<span>' ).text( context[2] ) );
toggleFinishedMessage( false );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.removeLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.intentionalLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.disamNeededButton?.prop( 'disabled', currentPageParameters.redirect || currentLink.hasDisamTemplate );
choosing = true;
}
};
/*
* Update the count of pending changes
*/
const updateEditCounter = function() {
if ( ui.pendingEditCounter ) {
ui.pendingEditCounter.text( txt.pendingEditCounter.replace( '$1', editCount )
.replace( '$2', countActuallyChangedFullyCheckedPages() ) );
}
if ( pendingEditBox ) {
if ( editCount === 0 && !running ) {
togglePendingEditBox( false );
notifyCompletion();
}
var textContent = editCount;
if ( editLimit ) {
textContent = txt.pendingEditBoxTimeEstimation.replace( '$1', editCount )
.replace( '$2', secondsToHHMMSS( cfg.editCooldown * editCount ) );
}
pendingEditBoxText.text( txt.pendingEditBox.replace( '$1', textContent ) );
}
};
/*
* Apply the changes made to an "origin" page
* pageChange: Change that will be saved
*/
const applyChange = function( pageChange ) {
if ( pageChange.page.content !== pageChange.contentBefore[0] ) {
editCount++;
// Filter duplicates and join
var changeSummaries = pageChange.summary
.filter( ( val, i, arr ) => val && arr.indexOf( val ) === i )
.join( txt.summarySeparator );
var summary = txt.summary.replace( '$1', getTargetPage() ).replace( '$2', changeSummaries );
var save = editLimit ? saveWithCooldown : savePage;
save( pageChange.title, pageChange.page, summary, true, true ).always( function() {
if ( editCount > 0 ) {
editCount--;
}
updateEditCounter();
} ).fail( error );
updateEditCounter();
}
};
/*
* Save all the pending changes
*/
const applyAllChanges = function() {
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
applyChange( pageChanges[ii] );
}
pageChanges = [];
};
/*
* Record a new pending change
* pageTitle: Title of the page
* page: Content of the page
* oldContent: Content of the page before the change
* link: Link that has been changed
* summary: Change summary
*/
const addChange = function( pageTitle, page, oldContent, link, summary ) {
if ( ( pageChanges.length === 0 ) || ( pageChanges[pageChanges.length - 1].title !== pageTitle ) ) {
pageChanges.push( {
title: pageTitle,
page: page,
contentBefore: [],
links: [],
summary: []
} );
}
var lastPageChange = pageChanges[pageChanges.length - 1];
lastPageChange.contentBefore.push( oldContent );
lastPageChange.links.push( link );
lastPageChange.summary.push( summary );
};
/*
* Check whether actual changes are stored in the history array
*/
const checkActualChanges = function() {
return countActualChanges() !== 0;
};
/*
* Return the number of entries in the history array that represent actual changes
*/
const countActualChanges = function() {
var changeCount = 0;
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
if ( pageChanges[ii].page.content !== pageChanges[ii].contentBefore[0] ) {
changeCount++;
}
}
return changeCount;
};
/*
* Return the number of changed pages in the history array, ignoring the last entry
* if we aren't done with that page yet
*/
const countActuallyChangedFullyCheckedPages = function() {
var changeCount = countActualChanges();
if ( pageChanges.length !== 0 ) {
var lastChange = pageChanges[pageChanges.length - 1];
if ( lastChange.title === currentPageTitle && currentLink !== null
&& lastChange.page.content !== lastChange.contentBefore[0] ) {
changeCount--;
}
}
return changeCount;
};
/*
* Find the links to disambiguation options in a disambiguation page
*/
const getDisamOptions = function() {
return $( '#mw-content-text a' ).filter( function() {
return !!extractPageName( $( this ) );
} );
};
/*
* Save all the pending changes and close the tool
*/
const saveAndEnd = function() {
applyAllChanges();
end();
};
/*
* Close the tool
*/
const end = function() {
var currentToolUI = ui.display;
choosing = false;
running = false;
$( '.disamassist-optionmarker' ).remove();
currentToolUI.fadeOut( { complete: function() {
currentToolUI.remove();
if ( editCount !== 0 ) {
togglePendingEditBox( true );
}
} } );
};
/*
* Display an error message
*/
const error = function( errorDescription ) {
var errorBox = $( '<div>' ).addClass( 'disamassist-box disamassist-errorbox' );
errorBox.text( txt.error.replace( '$1', errorDescription ) );
errorBox.append( createButton( txt.dismissError, function() {
errorBox.fadeOut();
} ).addClass( 'disamassist-errorbutton' ) );
var uiIsInPlace = ui && $.contains( document.documentElement, ui.display[0] );
var nextElement = uiIsInPlace ? ui.display : $( '#mw-content-text' );
nextElement.before( errorBox );
errorBox.hide().fadeIn();
}
/*
* Change a link so that it points to the title
* text: The wikitext of the whole page
* title: The new destination of the link
* link: The link that will be modified
* extra: Text that will be added after the link (optional)
*/
const replaceLink = function( text, title, link, extra ) {
var newContent;
if ( isSamePage( title, link.description ) ) {
// [[B|A]] should be replaced with [[A]] rather than [[A|A]]
newContent = link.description;
} else {
newContent = title + '|' + link.description;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + '[[' + newContent + ']]' + link.afterDescription + ( extra || '' ) + linkEnd;
};
/*
* Remove a link from the text
* text: The wikitext of the whole page
* link: The link that will be removed
*/
const removeLink = function( text, link ) {
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + link.description + link.afterDescription + linkEnd;
};
/*
* Extract a link from a string in wiki format,
* starting from a given index. Return a link if one can be found,
* otherwise return null. The "link" includes "disambiguation needed"
* templates inmediately following the link proper
* text: Text from which the link will be extracted
* lastIndex: Index from which the search will start
*/
const extractLink = function( text, lastIndex ) {
// Ditto for the template regex. Disambiguation link templates
// should be simple enough for this not to matter, though.
var templateRegex = /^(\w*[.,:;?!)}\s]*){{\s*([^|{}]+?)\s*(?:\|[^{]*?)?}}/g;
titleRegex.lastIndex = lastIndex;
var match = titleRegex.exec( text );
if ( match !== null && match.index !== -1 ) {
var possiblyAmbiguous = true;
var hasDisamTemplate = false;
var end = match.index + 4 + match[1].length + ( match[3] ? 1 + match[3].length : 0 );
var afterDescription = '';
var rest = text.substring( end );
var templateMatch = templateRegex.exec( rest );
if ( templateMatch !== null ) {
var templateTitle = getCanonicalTitle( templateMatch[2] );
if ( cfg.disamLinkTemplates?.includes( templateTitle ) ) {
end += templateMatch[0].length;
afterDescription = templateMatch[1].replace(/\s$/, '');
hasDisamTemplate = true;
} else if ( cfg.disamLinkIgnoreTemplates?.includes( templateTitle ) ) {
possiblyAmbiguous = false;
}
}
return {
start: match.index,
end: end,
possiblyAmbiguous: possiblyAmbiguous,
hasDisamTemplate: hasDisamTemplate,
title: match[1],
description: match[3] ? match[3] : match[1],
afterDescription: afterDescription
};
}
return null;
};
/*
* Extract a link to one of a number of destination pages from a string
* ("text") in wiki format, starting from a given index ("lastIndex").
* "Disambiguation needed" templates are included as part of the links.
* text: Page in wiki format
* destinations: Array of page titles to look for
* lastIndex: Index from which the search will start
*/
const extractLinkToPage = function( text, destinations, lastIndex ) {
var link, title;
do {
link = extractLink( text, lastIndex );
if ( link !== null ) {
lastIndex = link.end;
title = getCanonicalTitle( link.title );
}
} while (
link !== null
&& ( !link.possiblyAmbiguous || !destinations?.includes( title ) )
);
return link;
};
/*
* Find the "target" page: either the one we are in or the "main" one found by extracting
* the title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const getTargetPage = function() {
var title = getTitle();
return forceSamePage ? title : removeDisam( title );
};
/*
* Get the page title, with the namespace prefix if any.
*/
const getTitle = function() {
return mw.config.get( 'wgPageName' ).replace(/_/g, ' ')
}
/*
* Extract a "main" title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const removeDisam = function( title ) {
var match = new RegExp( cfg.disamRegExp ).exec( title );
return match ? match[1] : title;
};
/*
* Check whether two page titles are the same
*/
const isSamePage = function( title1, title2 ) {
return getCanonicalTitle( title1 ) === getCanonicalTitle( title2 );
};
/*
* Return the 'canonical title' of a page
*/
const getCanonicalTitle = function( title ) {
try {
title = new mw.Title( title );
// Check that page name matches the current namespace
if ( title.getNamespaceId() !== mw.config.get( 'wgNamespaceNumber' ) ) {
return;
}
const fragment = title.getFragment();
return title.getPrefixedText() + ( fragment ? `#${ fragment }` : '' );
} catch ( ex ) {
// mw.Title seems to be buggy, and some valid titles are rejected
// FIXME: This may cause false negatives
}
};
/*
* Extract the context around a given link in a text string
*/
const extractContext = function( text, link ) {
var contextStart = link.start - cfg.radius;
var contextEnd = link.end + cfg.radius;
var contextPrev = text.substring( contextStart, link.start );
const ellipsis = '…';
if ( contextStart > 0 ) {
contextPrev = ellipsis + contextPrev;
}
var contextNext = text.substring( link.end, contextEnd );
if ( contextEnd < text.length ) {
contextNext = contextNext + ellipsis;
}
return [contextPrev, text.substring( link.start, link.end ), contextNext];
};
/*
* Extract the prefixed page name from a link
*/
const extractPageName = function( link ) {
var pageName = extractPageNameRaw( link );
if ( pageName ) {
return getCanonicalTitle( pageName );
}
return null;
};
/*
* Extract the page name from a link, as is
*/
const extractPageNameRaw = function( link ) {
// Ignore images and interwiki links
if ( link.hasClass( 'image' ) || link.hasClass( 'extiw' ) ) {
return null;
}
var href = link.attr( 'href' );
// Red links in old parser format
if ( link.hasClass( 'new' ) ) {
const oldRedLink = mw.util.getParamValue( 'title', href );
if ( oldRedLink !== null ) {
return oldRedLink;
}
}
// Check that external links are coming from this wiki
if ( href.indexOf( '//' ) === 0 || href.indexOf( 'https://' ) === 0 ) {
try {
const fullUrl = href.indexOf( '//' ) === 0 ? location.protocol + href : href;
let urlData = new URL( fullUrl );
if ( urlData.hostname !== location.hostname ) {
return null;
}
} catch( e ) {
return null;
}
}
var articlePathRegexp = new RegExp(
mw.config.get( 'wgArticlePath' ).replace( '$1', '([^?]*)' )
);
var result = articlePathRegexp.exec( href );
if ( Array.isArray( result ) && result.length > 1 ) {
return decodeURIComponent( result[ 1 ] );
}
return null;
};
/*
* Check whether this is a disambiguation page
*/
const isDisam = function() {
const categories = mw.config.get( 'wgCategories', [] );
return cfg.disamCategories.some( c => categories.includes( c ) );
};
const secondsToHHMMSS = function( totalSeconds ) {
var hhmmss = '';
var hours = Math.floor( totalSeconds / 3600 );
var minutes = Math.floor( totalSeconds % 3600 / 60 );
var seconds = Math.floor( totalSeconds % 3600 % 60 );
if ( hours >= 1 ) {
hhmmss = pad( hours, '0', 2 ) + ':';
}
hhmmss += pad( minutes, '0', 2 ) + ':' + pad( seconds, '0', 2 );
return hhmmss;
};
const pad = function( str, z, width ) {
str = str.toString();
if ( str.length >= width ) {
return str;
} else {
return new Array( width - str.length + 1 ).join( z ) + str;
}
}
/*
* Create a new button
* text: Text that will be displayed on the button
* onClick: Function that will be called when the button is clicked
*/
const createButton = function( text, onClick ) {
var button = $( '<button>', { 'type': 'button' } );
button.text( text );
button.addClass( 'disamassist-button' ).click( onClick );
return button;
};
/*
* Given a page title and an array of possible redirects {from, to} ("canonical titles"), find the page
* at the end of the redirect chain, if there is one. Otherwise, return the page title that was passed
*/
const resolveRedirect = function( pageTitle, possibleRedirects ) {
var appliedRedirect = true;
var visitedPages = {};
var currentPage = getCanonicalTitle( pageTitle );
while ( appliedRedirect ) {
appliedRedirect = false;
for ( var ii = 0; ii < possibleRedirects.length; ii++ ) {
if ( possibleRedirects[ii].from === currentPage ) {
if ( visitedPages[possibleRedirects[ii].to] ) {
// Redirect chain detected
return pageTitle;
}
visitedPages[currentPage] = true;
appliedRedirect = true;
currentPage = possibleRedirects[ii].to;
}
}
}
// No redirect rules applied for an iteration of the outer loop:
// no more redirects. We are done
return currentPage;
};
/*
* Fetch the incoming links to a page. Returns a jQuery promise
* (success - array of titles of pages that contain links to the target page and
* array of "canonical titles" of possible destinations of the backlinks (either
* the target page or redirects to the target page), failure - error description)
* page: Target page
*/
const getBacklinks = function( page ) {
var dfd = new $.Deferred();
api.get( {
'action': 'query',
'list': 'backlinks',
'bltitle': page,
'blredirect': true,
'bllimit': cfg.backlinkLimit,
'blnamespace': cfg.targetNamespaces,
} ).done( function( data ) {
// There might be duplicate entries in some corner cases. We don't care,
// since we are going to check later, anyway
var backlinks = [];
var linkTitles = [getCanonicalTitle( page )];
$.each( data.query.backlinks, function() {
backlinks.push( this.title );
if ( this.redirlinks ) {
linkTitles.push( this.title );
$.each( this.redirlinks, function() {
backlinks.push( this.title );
} );
}
} );
dfd.resolve( backlinks, linkTitles );
} ).fail( function( code, data ) {
dfd.reject( txt.getBacklinksError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download a list of redirects for some pages. Returns a jQuery callback (success -
* array of redirects ({from, to}), failure - error description )
* pageTitles: Array of page titles
*/
const fetchRedirects = function( pageTitles ) {
var dfd = new $.Deferred();
var currentTitles = pageTitles.slice( 0, cfg.queryTitleLimit );
var restTitles = pageTitles.slice( cfg.queryTitleLimit );
api.get( {
action: 'query',
titles: currentTitles,
redirects: true,
} ).done( function( data ) {
var theseRedirects = data.query.redirects ? data.query.redirects : [];
if ( restTitles.length !== 0 ) {
fetchRedirects( restTitles ).done( function( redirects ) {
dfd.resolve( theseRedirects.concat( redirects ) );
} ).fail( function( description ) {
dfd.reject( description );
} );
} else {
dfd.resolve( theseRedirects );
}
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRedirectsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download the list of user rights for the current user. Returns a
* jQuery promise (success - array of right names, error - error description)
*/
const fetchRights = function() {
var dfd = $.Deferred();
api.get( {
action: 'query',
meta: 'userinfo',
uiprop: 'rights',
} ).done( function( data ) {
dfd.resolve( data.query.userinfo.rights );
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRightsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Load the raw page text for a given title. Returns a jQuery promise (success - page
* content, failure - error description)
* pageTitle: Title of the page
*/
const loadPage = function( pageTitle ) {
var dfd = new $.Deferred();
api.get( {
action: 'query',
titles: pageTitle,
prop: 'revisions',
rvprop: 'timestamp|content',
meta: 'tokens',
type: 'csrf',
} ).done( function( data ) {
var pages = data.query.pages;
for ( var key in pages ) {
if ( pages.hasOwnProperty( key ) ) {
break;
}
}
var rawPage = data.query.pages[key];
var page = {};
page.missing = rawPage.missing !== undefined;
if ( rawPage.revisions ) {
page.content = rawPage.revisions[0]['*'];
// Assume anything with # at the start is a redirect
if ( page.content.match( /^#[^\s]*?\s*\[\[/ ) ) {
page.redirect = true;
}
page.baseTimeStamp = rawPage.revisions[0].timestamp;
} else {
page.content = '';
page.baseTimeStamp = null;
}
page.startTimeStamp = rawPage.starttimestamp;
page.editToken = data.query.tokens.csrftoken;
dfd.resolve( page );
} ).fail( function( code, data ) {
dfd.reject( txt.loadPageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
/*
* Register changes to a page, to be saved later. Returns a jQuery promise
* (success - no params, failure - error description). Takes the same parameters
* as savePage
*/
const saveWithCooldown = function() {
var deferred = new $.Deferred();
pendingSaves.push( {args: arguments, dfd: deferred} );
if ( !runningSaves ) {
checkAndSave();
}
return deferred.promise();
};
/*
* Save the first set of changes in the list of pending changes, providing that
* enough time has passed since the last edit
*/
const checkAndSave = function() {
if ( pendingSaves.length === 0 ) {
runningSaves = false;
return;
}
runningSaves = true;
var millisSinceLast = new Date().getTime() - lastEditMillis;
if ( millisSinceLast < cfg.editCooldown * 1000 ) {
setTimeout( checkAndSave, cfg.editCooldown * 1000 - millisSinceLast );
} else {
// The last edit started at least cfg.editCooldown seconds ago
var save = pendingSaves.shift();
savePage.apply( this, save.args ).done( function() {
checkAndSave();
save.dfd.resolve();
} ).fail( function( description ) {
checkAndSave();
save.dfd.reject( description );
} );
// We'll use the time since the last edit started
lastEditMillis = new Date().getTime();
}
};
/*
* Save the changes made to a page. Returns a jQuery promise (success - no params,
* failure - error description)
* pageTitle: Title of the page
* page: Page data
* summary: Summary of the changes made to the page
* minorEdit: Whether to mark the edit as 'minor'
* botEdit: Whether to mark the edit as 'bot'
*/
const savePage = function( pageTitle, page, summary, minorEdit, botEdit ) {
var dfd = new $.Deferred();
api.post( {
action: 'edit',
title: pageTitle,
token: page.editToken,
text: page.content,
basetimestamp: page.baseTimeStamp,
starttimestamp: page.startTimeStamp,
summary: summary,
watchlist: cfg.watch ? cfg.watch : 'nochange',
assertuser: mw.config.get( 'wgUserName' ),
minor: minorEdit,
bot: botEdit,
} ).done( function() {
dfd.resolve();
} ).fail( function( code, data ) {
dfd.reject( txt.savePageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
install();
} )();
// </nowiki>
fdbrrqadgjxuivyvunoy2i6cpab5wem
781216
781207
2026-10-27T16:40:42Z
Rots61
44293
781216
javascript
text/javascript
/*
* DisamAssist: a tool for repairing links from articles to disambiguation pages.
*/
// <nowiki>
// @ts-nocheck
( () => {
var cfg = {};
var txt = {};
var ui;
var links, pageChanges;
var currentPageTitle, currentPageParameters, currentLink;
var possibleBacklinkDestinations;
var titleRegex;
var forceSamePage = false;
var running = false;
var choosing = false;
var canMarkIntentionalLinks = false;
var displayedPages = {};
var editCount = 0;
var editLimit;
var pendingSaves = [];
var pendingEditBox = null;
var pendingEditBoxText;
var lastEditMillis = 0;
var runningSaves = false;
var api;
const dependencies = [
'mediawiki.api',
'mediawiki.confirmCloseWindow',
'mediawiki.Title',
'mediawiki.util',
];
/**
* Add an item in Tools menu
* @param {string} text
* @param {string} id
* @param {(e: MouseEvent) => void} callback
*/
const disamPortletLink = ( text, id, callback ) => {
const portlet = mw.util.addPortletLink(
'p-tb',
cfg.helpPage ? mw.util.getUrl( cfg.helpPage ) : '#',
text,
id
);
const link = portlet?.querySelector( 'a' );
link?.addEventListener( 'click', callback );
addButtonEvents( link );
}
/*
* Entry point. Check whether we are in a disambiguation page. If so, add a link to start the tool
*/
const install = function() {
cfg = window.DisamAssist.cfg;
txt = window.DisamAssist.txt;
if ( mw.config.get( 'wgAction' ) === 'view' && isDisam() ) {
mw.loader.using( dependencies, () => {
api = new mw.Api();
mw.loader.load( 'https://ru.wikipedia.org/w/index.php?title=Участник:Stjn/DisamAssist.css&action=raw&ctype=text/css', 'text/css' );
// This is a " (disambiguation)" page
if ( new RegExp( cfg.disamRegExp ).exec( getTitle() ) ) {
disamPortletLink( txt.startMain, 't-disamAssist-main', startMain );
disamPortletLink( txt.startSame, 't-disamAssist-same', startSame );
} else {
disamPortletLink( txt.start, 't-disamAssist-page', start );
}
} );
}
};
/*
* Start the tool. Display the UI and begin looking for links to fix
*/
const start = function( e ) {
e?.preventDefault();
if ( !running ) {
console.log(Date.now())
running = true;
links = [];
pageChanges = [];
displayedPages = {};
ensureDABExists().then( function( canMark ) {
canMarkIntentionalLinks = canMark;
createUI();
addUnloadConfirm();
markDisamOptions();
checkEditLimit().then( function() {
togglePendingEditBox( false );
doPage();
} );
} );
}
};
/*
* Start DisamAssist. Disambiguate incoming links to the current page, regardless
* of the title.
*/
const startSame = function( e ) {
e.preventDefault();
forceSamePage = true;
start( e );
};
/*
* Start DisamAssist. If the page title ends with " (disambiguation)", disambiguate
* links to the primary topic article. Otherwise, disambiguate links to the current
* page.
*/
const startMain = function( e ) {
e.preventDefault();
forceSamePage = false;
start( e );
};
/*
* Create and show the user interface.
*/
const createUI = function() {
ui = {
display: $( '<div>' ).addClass( 'disamassist-box disamassist-mainbox' ),
finishedMessage: $( '<div>' ).text( txt.noMoreLinks ).hide(),
pageTitleLine: $( '<span>' ).addClass( 'disamassist-pagetitleline' ),
pendingEditCounter: $( '<div>').addClass( 'disamassist-editcounter' ),
context: $( '<pre>' ).addClass( 'disamassist-context' ),
undoButton: createButton( txt.undo, undo ),
omitButton: createButton( txt.omit, omit ),
endButton: createButton( txt.close, saveAndEnd ),
refreshButton: createButton( txt.refresh, refresh ),
titleAsTextButton: createButton( txt.titleAsText, chooseTitleFromPrompt ),
intentionalLinkButton: canMarkIntentionalLinks ? createButton( txt.intentionalLink, chooseIntentionalLink ) : null,
disamNeededButton: cfg.disamNeededText ? createButton( txt.disamNeeded, chooseDisamNeeded ) : null,
removeLinkButton: createButton( txt.removeLink, chooseLinkRemoval )
};
var top = $( '<div>' ).addClass( 'disamassist-top' )
.append( [ui.pageTitleLine, ui.pendingEditCounter] );
var leftButtons = $( '<div>' ).addClass( 'disamassist-leftbuttons' )
.append( [ui.titleAsTextButton, ui.removeLinkButton, ui.intentionalLinkButton, ui.disamNeededButton, ui.omitButton] );
var rightButtons = $( '<div>' ).addClass( 'disamassist-rightbuttons' )
.append( [ui.undoButton, ui.refreshButton, ui.endButton] );
var allButtons = $( '<div>' ).addClass( 'disamassist-allbuttons' )
.append( [leftButtons, rightButtons] );
ui.display.append( [top, ui.finishedMessage, ui.context, allButtons] );
updateEditCounter();
toggleActionButtons( false );
// Insert the UI in the page
$( '#mw-content-text' ).before( ui.display );
ui.display.hide().fadeIn();
};
/**
* Add button role and related keyboard event
* @param {Element} el
*/
const addButtonEvents = ( el ) => {
el?.setAttribute( 'role', 'button' );
el?.addEventListener( 'keydown', function( e ) {
if ( [ 'Enter', 'Space' ].includes( e.code ) ) {
e.preventDefault();
this.click();
}
} );
}
/*
* If there are pending changes, show a confirm dialog before closing
*/
const addUnloadConfirm = function() {
mw.confirmCloseWindow( {
test: () => {
if ( running && checkActualChanges() ) {
return txt.pending;
} else if ( editCount !== 0 ) {
return txt.editInProgress;
}
}
} );
};
/*
* Mark the disambiguation options as such
*/
const markDisamOptions = function() {
var optionPageTitles = [];
var optionMarkers = [];
getDisamOptions().each( function() {
var link = $( this );
var title = extractPageName( link );
const optionMarker = document.createElement( 'a' );
optionMarker.classList.add( 'disamassist-optionmarker' );
optionMarker.href = link.attr( 'href' );
optionMarker.setAttribute( 'data-title', title );
optionMarker.textContent = txt.optionMarker;
optionMarker.addEventListener( 'click', ( e ) => {
e.preventDefault();
chooseReplacement( title );
} );
addButtonEvents( optionMarker );
link.after( optionMarker );
optionMarkers.push( optionMarker );
optionPageTitles.push( title );
} );
// Now check the disambiguation options and display a different message for those that are
// actually the same as the target page where the links go, as choosing those options doesn't really
// accomplish anything (except bypassing redirects, which might be useful in some cases)
var targetPage = getTargetPage();
fetchRedirects( optionPageTitles.concat( targetPage ) ).done( function( redirects ) {
var endTargetPage = resolveRedirect( targetPage, redirects );
for ( var ii = 0; ii < optionPageTitles.length; ii++ ) {
var endOptionTitle = resolveRedirect( optionPageTitles[ii], redirects );
if ( isSamePage( optionPageTitles[ii], targetPage ) ) {
$( optionMarkers[ii] ).text( txt.targetOptionMarker ).addClass( 'disamassist-curroptionmarker');
} else if ( isSamePage( endOptionTitle, endTargetPage ) ) {
$( optionMarkers[ii] ).text( txt.redirectOptionMarker ).addClass( 'disamassist-curroptionmarker');
}
}
} ).fail( error );
};
/*
* Check whether intentional links to disambiguation pages can be explicitly marked
* as such in this wiki. If so, ensure that a "Foo (disambiguation)" page exists.
* Returns a jQuery promise
*/
const ensureDABExists = function() {
var dfd = new $.Deferred();
var title = getTitle();
// That concept doesn't exist in this wiki.
if ( !cfg.intentionalLinkOption ) {
dfd.resolve( false );
// "Foo (disambiguation)" exists: it's the current page.
} else if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
dfd.resolve( true );
} else {
var disamTitle = cfg.disamFormat.replace( '$1', title );
loadPage( disamTitle ).done( function( page ) {
// "Foo (disambiguation)" doesn't exist.
if ( page.missing ) {
// We try to create it
page.content = cfg.redirectToDisam.replace( '$1', title );
var summary = txt.redirectSummary.replace( '$1', title );
savePage( disamTitle, page, summary, false, true ).done( function() {
dfd.resolve( true );
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
// It does exist
} else {
dfd.resolve( true );
}
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
}
return dfd.promise();
};
/*
* Check whether the edit cooldown applies and sets editLimit accordingly.
* Returns a jQuery promise
*/
const checkEditLimit = function() {
var dfd = new $.Deferred();
if ( cfg.editCooldown <= 0 ) {
editLimit = false;
dfd.resolve();
} else {
fetchRights().done( function( rights ) {
editLimit = !rights?.includes( 'bot' );
} ).fail( function( description ) {
error( description );
editLimit = true;
} ).always( function() {
dfd.resolve();
} );
}
return dfd.promise();
};
/*
* Find and ask the user to fix all the incoming links to the disambiguation ("target")
* page from a single "origin" page
*/
const doPage = function() {
if ( pageChanges.length > cfg.historySize ) {
applyChange( pageChanges.shift() );
}
if ( links.length === 0 ) {
var targetPage = getTargetPage();
getBacklinks( targetPage ).done( function( backlinks, pageTitles ) {
var pending = {};
$.each( pendingSaves, function() {
pending[this[0]] = true;
} );
possibleBacklinkDestinations = $.grep( pageTitles, function( t, ii) {
if ( t == targetPage ) {
return true;
}
return removeDisam(t) != targetPage;
} );
titleRegex = new RegExp('\\[\\[([ _]*?(?:' + possibleBacklinkDestinations.join('|').replaceAll('(', '\\(').replaceAll(')','\\)').replaceAll(' ', '[ _]') + ')[ _]*?)(#.*?)?(?:\\|(.*?))?\\]\\]', 'gi');
// Only incoming links from pages we haven't seen yet and we aren't currently
// saving (displayedPages is reset when the tool is closed and opened again,
// while the list of pending changes isn't; if the edit cooldown is disabled,
// it will be empty)
links = $.grep( backlinks, function( el, ii ) {
return !displayedPages[el] && !pending[el];
} );
if ( links.length === 0 ) {
updateContext();
} else {
doPage();
}
} ).fail( error );
} else {
currentPageTitle = links.shift();
displayedPages[currentPageTitle] = true;
toggleActionButtons( false );
loadPage( currentPageTitle ).done( function( data ) {
currentPageParameters = data;
currentLink = null;
doLink();
} ).fail( error );
}
};
/*
* Find and ask the user to fix a single incoming link to the disambiguation ("target")
* page
*/
const doLink = function() {
if (!currentPageParameters.redirect) {
currentLink = extractLinkToPage( currentPageParameters.content,
possibleBacklinkDestinations, currentLink ? currentLink.end : 0 );
if ( currentLink ) {
updateContext();
} else {
doPage();
}
} else {
doPage();
}
};
/*
* Replace the target of a link with a different one
* pageTitle: New link target
* extra: Additional text after the link (optional)
* summary: Change summary (optional)
*/
const chooseReplacement = function( pageTitle, extra, summary ) {
if ( choosing ) {
choosing = false;
if ( !summary ) {
if ( pageTitle ) {
summary = txt.summaryChanged.replace( '$1', pageTitle );
} else {
summary = txt.summaryOmitted;
}
}
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
if ( pageTitle && ( pageTitle !== getTargetPage() || extra ) ) {
if ( currentPageParameters.redirect ) {
currentLink.description = pageTitle;
}
currentPageParameters.content =
replaceLink( currentPageParameters.content, pageTitle, currentLink, extra || '' );
}
doLink();
}
};
/*
* Replace the link with an explicit link to the disambiguation page
*/
const chooseIntentionalLink = function() {
const targetPage = getTargetPage();
let title = cfg.disamFormat.replace( '$1', targetPage );
if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
title = targetPage;
}
chooseReplacement( title, '', txt.summaryIntentional );
};
/*
* Prompt for an alternative link target and use it as a replacement
*/
const chooseTitleFromPrompt = function() {
var title = prompt( txt.titleAsTextPrompt );
if ( title !== null ) {
chooseReplacement( title );
}
};
/*
* Remove the current link, leaving the text unchanged
*/
const chooseLinkRemoval = function() {
if ( choosing ) {
var summary = txt.summaryRemoved;
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
currentPageParameters.content = removeLink( currentPageParameters.content, currentLink );
doLink();
}
};
/*
* Add a "disambiguation needed" template after the link
*/
const chooseDisamNeeded = function() {
chooseReplacement( currentLink.title, cfg.disamNeededText, txt.summaryHelpNeeded );
};
/*
* Undo the last change
*/
const undo = function() {
if ( pageChanges.length !== 0 ) {
var lastPage = pageChanges[pageChanges.length - 1];
if ( currentPageTitle !== lastPage.title ) {
links.unshift( currentPageTitle );
currentPageTitle = lastPage.title;
}
currentPageParameters = lastPage.page;
currentPageParameters.content = lastPage.contentBefore.pop();
currentLink = lastPage.links.pop();
lastPage.summary.pop();
if ( lastPage.contentBefore.length === 0 ) {
pageChanges.pop();
}
updateContext();
}
};
/*
* Omit the current link without making a change
*/
const omit = function() {
chooseReplacement( null );
};
/*
* Save all the pending changes and restart the tool.
*/
const refresh = function() {
saveAndEnd();
start();
};
/*
* Enable or disable the buttons that can perform actions on a page or change the current link.
* enabled: Whether to enable or disable the buttons
*/
const toggleActionButtons = function( enabled ) {
var affectedButtons = [ui.omitButton, ui.titleAsTextButton, ui.removeLinkButton,
ui.intentionalLinkButton, ui.disamNeededButton, ui.undoButton];
$.each( affectedButtons, function( ii, button ) {
button?.prop( 'disabled', !enabled );
} );
};
/*
* Show or hide the 'no more links' message
* show: Whether to show or hide the message
*/
const toggleFinishedMessage = function( show ) {
toggleActionButtons( !show );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.finishedMessage.toggle( show );
ui.pageTitleLine.toggle( !show );
ui.context.toggle( !show );
if ( show ) console.log(Date.now())
};
const togglePendingEditBox = function( show ) {
if ( pendingEditBox === null ) {
pendingEditBox = $( '<div>' ).addClass( 'disamassist-box disamassist-pendingeditbox' );
pendingEditBoxText = $( '<div>' );
pendingEditBox.append( pendingEditBoxText ).hide();
if ( editLimit ) {
pendingEditBox.append( $( '<div>' ).text( txt.pendingEditBoxLimited )
.addClass( 'disamassist-subtitle' ) );
}
$( '#mw-content-text' ).before( pendingEditBox );
updateEditCounter();
}
if ( show ) {
pendingEditBox.fadeIn();
} else {
pendingEditBox.fadeOut();
}
};
const notifyCompletion = function() {
var oldTitle = document.title;
document.title = txt.notifyCharacter + document.title;
$( document.body ).one( 'mousemove', function() {
document.title = oldTitle;
} );
};
/*
* Update the displayed information to match the current link
* or lack thereof
*/
const updateContext = function() {
updateEditCounter();
if ( !currentLink ) {
toggleFinishedMessage( true );
} else {
ui.pageTitleLine.html( txt.pageTitleLine.replace( '$1',
mw.util.getUrl( currentPageTitle, {redirect: 'no'} ) ).replace( '$2', mw.html.escape( currentPageTitle ) ) );
var context = extractContext( currentPageParameters.content, currentLink );
ui.context.empty()
.append( $( '<span>' ).text( context[0] ) )
.append( $( '<span>' ).text( context[1] ).addClass( 'disamassist-inclink' ) )
.append( $( '<span>' ).text( context[2] ) );
toggleFinishedMessage( false );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.removeLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.intentionalLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.disamNeededButton?.prop( 'disabled', currentPageParameters.redirect || currentLink.hasDisamTemplate );
choosing = true;
}
};
/*
* Update the count of pending changes
*/
const updateEditCounter = function() {
if ( ui.pendingEditCounter ) {
ui.pendingEditCounter.text( txt.pendingEditCounter.replace( '$1', editCount )
.replace( '$2', countActuallyChangedFullyCheckedPages() ) );
}
if ( pendingEditBox ) {
if ( editCount === 0 && !running ) {
togglePendingEditBox( false );
notifyCompletion();
}
var textContent = editCount;
if ( editLimit ) {
textContent = txt.pendingEditBoxTimeEstimation.replace( '$1', editCount )
.replace( '$2', secondsToHHMMSS( cfg.editCooldown * editCount ) );
}
pendingEditBoxText.text( txt.pendingEditBox.replace( '$1', textContent ) );
}
};
/*
* Apply the changes made to an "origin" page
* pageChange: Change that will be saved
*/
const applyChange = function( pageChange ) {
if ( pageChange.page.content !== pageChange.contentBefore[0] ) {
editCount++;
// Filter duplicates and join
var changeSummaries = pageChange.summary
.filter( ( val, i, arr ) => val && arr.indexOf( val ) === i )
.join( txt.summarySeparator );
var summary = txt.summary.replace( '$1', getTargetPage() ).replace( '$2', changeSummaries );
var save = editLimit ? saveWithCooldown : savePage;
save( pageChange.title, pageChange.page, summary, true, true ).always( function() {
if ( editCount > 0 ) {
editCount--;
}
updateEditCounter();
} ).fail( error );
updateEditCounter();
}
};
/*
* Save all the pending changes
*/
const applyAllChanges = function() {
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
applyChange( pageChanges[ii] );
}
pageChanges = [];
};
/*
* Record a new pending change
* pageTitle: Title of the page
* page: Content of the page
* oldContent: Content of the page before the change
* link: Link that has been changed
* summary: Change summary
*/
const addChange = function( pageTitle, page, oldContent, link, summary ) {
if ( ( pageChanges.length === 0 ) || ( pageChanges[pageChanges.length - 1].title !== pageTitle ) ) {
pageChanges.push( {
title: pageTitle,
page: page,
contentBefore: [],
links: [],
summary: []
} );
}
var lastPageChange = pageChanges[pageChanges.length - 1];
lastPageChange.contentBefore.push( oldContent );
lastPageChange.links.push( link );
lastPageChange.summary.push( summary );
};
/*
* Check whether actual changes are stored in the history array
*/
const checkActualChanges = function() {
return countActualChanges() !== 0;
};
/*
* Return the number of entries in the history array that represent actual changes
*/
const countActualChanges = function() {
var changeCount = 0;
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
if ( pageChanges[ii].page.content !== pageChanges[ii].contentBefore[0] ) {
changeCount++;
}
}
return changeCount;
};
/*
* Return the number of changed pages in the history array, ignoring the last entry
* if we aren't done with that page yet
*/
const countActuallyChangedFullyCheckedPages = function() {
var changeCount = countActualChanges();
if ( pageChanges.length !== 0 ) {
var lastChange = pageChanges[pageChanges.length - 1];
if ( lastChange.title === currentPageTitle && currentLink !== null
&& lastChange.page.content !== lastChange.contentBefore[0] ) {
changeCount--;
}
}
return changeCount;
};
/*
* Find the links to disambiguation options in a disambiguation page
*/
const getDisamOptions = function() {
return $( '#mw-content-text a' ).filter( function() {
return !!extractPageName( $( this ) );
} );
};
/*
* Save all the pending changes and close the tool
*/
const saveAndEnd = function() {
applyAllChanges();
end();
};
/*
* Close the tool
*/
const end = function() {
var currentToolUI = ui.display;
choosing = false;
running = false;
$( '.disamassist-optionmarker' ).remove();
currentToolUI.fadeOut( { complete: function() {
currentToolUI.remove();
if ( editCount !== 0 ) {
togglePendingEditBox( true );
}
} } );
};
/*
* Display an error message
*/
const error = function( errorDescription ) {
var errorBox = $( '<div>' ).addClass( 'disamassist-box disamassist-errorbox' );
errorBox.text( txt.error.replace( '$1', errorDescription ) );
errorBox.append( createButton( txt.dismissError, function() {
errorBox.fadeOut();
} ).addClass( 'disamassist-errorbutton' ) );
var uiIsInPlace = ui && $.contains( document.documentElement, ui.display[0] );
var nextElement = uiIsInPlace ? ui.display : $( '#mw-content-text' );
nextElement.before( errorBox );
errorBox.hide().fadeIn();
}
/*
* Change a link so that it points to the title
* text: The wikitext of the whole page
* title: The new destination of the link
* link: The link that will be modified
* extra: Text that will be added after the link (optional)
*/
const replaceLink = function( text, title, link, extra ) {
var anchor = link.anchor || '';
var newContent;
if ( link.type === 'sortnaam' ) {
newContent = '{{sortnaam|' + link.first + '|' + link.last + '|' + title + anchor + '}}';
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + ( extra || '' ) + linkEnd;
}
if ( link.type === 'ill' ) {
newContent = '{{ill|' + title + anchor + '|' + link.lang + '|' + link.other + ( link.rest || '' ) + '}}';
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + ( extra || '' ) + linkEnd;
}
if ( isSamePage( title, link.description ) ) {
newContent = link.description + anchor;
} else {
newContent = title + anchor + '|' + link.description;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + '[[' + newContent + ']]' + link.afterDescription + ( extra || '' ) + linkEnd;
};
/*
* Remove a link from the text
* text: The wikitext of the whole page
* link: The link that will be removed
*/
const removeLink = function( text, link ) {
var anchor = link.anchor || '';
if ( link.type === 'sortnaam' ) {
var newContent = '{{sortnaam|' + link.first + '|' + link.last + '}}' + anchor;
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + linkEnd;
}
if ( link.type === 'ill' ) {
var newContent = link.title + anchor;
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + linkEnd;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + link.description + anchor + link.afterDescription + linkEnd;
};
/*
* Extract a link from a string in wiki format,
* starting from a given index. Return a link if one can be found,
* otherwise return null.
*/
const extractLink = function( text, lastIndex ) {
// 1. Zoek naar gewone wikitlinks (inclusief optioneel anker #...)
titleRegex.lastIndex = lastIndex;
var wikiMatch = titleRegex.exec( text );
// 2. Zoek naar sortnaam-templates
var sortnaamRegex = new RegExp('{{\\s*sortnaam\\s*\\|\\s*([^|]+?)\\s*\\|\\s*([^|]+?)\\s*(?:\\|\\s*([^|#}]*?)\\s*)?(#[^|}]*)?}}', 'gi');
sortnaamRegex.lastIndex = lastIndex;
var sortnaamMatch = sortnaamRegex.exec( text );
// 3. Zoek naar ill-templates (ondersteunt ook nieuwe regels en extra parameters)
var illRegex = new RegExp('{{\\s*ill\\s*\\|([\\s\\S]*?)}}', 'gi');
illRegex.lastIndex = lastIndex;
var illMatch = illRegex.exec( text );
var matchType = null;
var matchIndex = Infinity;
if ( wikiMatch !== null && wikiMatch.index >= lastIndex ) {
matchIndex = wikiMatch.index;
matchType = 'wiki';
}
if ( sortnaamMatch !== null && sortnaamMatch.index >= lastIndex && sortnaamMatch.index < matchIndex ) {
matchIndex = sortnaamMatch.index;
matchType = 'sortnaam';
}
if ( illMatch !== null && illMatch.index >= lastIndex && illMatch.index < matchIndex ) {
matchIndex = illMatch.index;
matchType = 'ill';
}
if ( matchType === 'wiki' ) {
var possiblyAmbiguous = true;
var hasDisamTemplate = false;
var fullMatchStr = wikiMatch[0];
var anchor = wikiMatch[2] ? wikiMatch[2] : '';
var end = wikiMatch.index + fullMatchStr.length;
var afterDescription = '';
var rest = text.substring( end );
var templateRegex = /^(\w*[.,:;?!)}\s]*){{\s*([^|{}]+?)\s*(?:\|[^{]*?)?}}/g;
var templateMatch = templateRegex.exec( rest );
if ( templateMatch !== null ) {
var templateTitle = getCanonicalTitle( templateMatch[2] );
if ( cfg.disamLinkTemplates?.includes( templateTitle ) ) {
end += templateMatch[0].length;
afterDescription = templateMatch[1].replace(/\s$/, '');
hasDisamTemplate = true;
} else if ( cfg.disamLinkIgnoreTemplates?.includes( templateTitle ) ) {
possiblyAmbiguous = false;
}
}
return {
type: 'wiki',
start: wikiMatch.index,
end: end,
possiblyAmbiguous: possiblyAmbiguous,
hasDisamTemplate: hasDisamTemplate,
title: wikiMatch[1],
description: wikiMatch[3] ? wikiMatch[3] : wikiMatch[1],
anchor: anchor,
afterDescription: afterDescription
};
} else if ( matchType === 'sortnaam' ) {
var first = sortnaamMatch[1].trim();
var last = sortnaamMatch[2].trim();
var explicitTarget = sortnaamMatch[3] ? sortnaamMatch[3].trim() : '';
var anchor = sortnaamMatch[4] ? sortnaamMatch[4].trim() : '';
var target = explicitTarget !== '' ? explicitTarget : ( first + ' ' + last );
return {
type: 'sortnaam',
start: sortnaamMatch.index,
end: sortnaamMatch.index + sortnaamMatch[0].length,
possiblyAmbiguous: true,
hasDisamTemplate: false,
title: target,
description: sortnaamMatch[0],
first: first,
last: last,
explicitTarget: explicitTarget,
anchor: anchor,
afterDescription: ''
};
} else if ( matchType === 'ill' ) {
var inner = illMatch[1];
var parts = inner.split('|');
var firstParam = parts[0].trim();
var anchorMatch = firstParam.match(/^(.*?)(#.*)$/);
var title = anchorMatch ? anchorMatch[1].trim() : firstParam;
var anchor = anchorMatch ? anchorMatch[2] : '';
var lang = parts[1] ? parts[1].trim() : '';
var other = parts[2] ? parts[2].trim() : '';
var restParts = parts.slice(3);
var rest = restParts.length > 0 ? '|' + restParts.join('|') : '';
return {
type: 'ill',
start: illMatch.index,
end: illMatch.index + illMatch[0].length,
possiblyAmbiguous: true,
hasDisamTemplate: false,
title: title,
description: title,
anchor: anchor,
lang: lang,
other: other,
rest: rest,
afterDescription: ''
};
}
return null;
};
/*
* Extract a link to one of a number of destination pages from a string
* ("text") in wiki format, starting from a given index ("lastIndex").
* "Disambiguation needed" templates are included as part of the links.
* text: Page in wiki format
* destinations: Array of page titles to look for
* lastIndex: Index from which the search will start
*/
const extractLinkToPage = function( text, destinations, lastIndex ) {
var link, title;
do {
link = extractLink( text, lastIndex );
if ( link !== null ) {
lastIndex = link.end;
title = getCanonicalTitle( link.title );
}
} while (
link !== null
&& ( !link.possiblyAmbiguous || !destinations?.includes( title ) )
);
return link;
};
/*
* Find the "target" page: either the one we are in or the "main" one found by extracting
* the title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const getTargetPage = function() {
var title = getTitle();
return forceSamePage ? title : removeDisam( title );
};
/*
* Get the page title, with the namespace prefix if any.
*/
const getTitle = function() {
return mw.config.get( 'wgPageName' ).replace(/_/g, ' ')
}
/*
* Extract a "main" title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const removeDisam = function( title ) {
var match = new RegExp( cfg.disamRegExp ).exec( title );
return match ? match[1] : title;
};
/*
* Check whether two page titles are the same
*/
const isSamePage = function( title1, title2 ) {
return getCanonicalTitle( title1 ) === getCanonicalTitle( title2 );
};
/*
* Return the 'canonical title' of a page
*/
const getCanonicalTitle = function( title ) {
try {
title = new mw.Title( title );
// Check that page name matches the current namespace
if ( title.getNamespaceId() !== mw.config.get( 'wgNamespaceNumber' ) ) {
return;
}
const fragment = title.getFragment();
return title.getPrefixedText() + ( fragment ? `#${ fragment }` : '' );
} catch ( ex ) {
// mw.Title seems to be buggy, and some valid titles are rejected
// FIXME: This may cause false negatives
}
};
/*
* Extract the context around a given link in a text string
*/
const extractContext = function( text, link ) {
var contextStart = link.start - cfg.radius;
var contextEnd = link.end + cfg.radius;
var contextPrev = text.substring( contextStart, link.start );
const ellipsis = '…';
if ( contextStart > 0 ) {
contextPrev = ellipsis + contextPrev;
}
var contextNext = text.substring( link.end, contextEnd );
if ( contextEnd < text.length ) {
contextNext = contextNext + ellipsis;
}
return [contextPrev, text.substring( link.start, link.end ), contextNext];
};
/*
* Extract the prefixed page name from a link
*/
const extractPageName = function( link ) {
var pageName = extractPageNameRaw( link );
if ( pageName ) {
return getCanonicalTitle( pageName );
}
return null;
};
/*
* Extract the page name from a link, as is
*/
const extractPageNameRaw = function( link ) {
// Ignore images and interwiki links
if ( link.hasClass( 'image' ) || link.hasClass( 'extiw' ) ) {
return null;
}
var href = link.attr( 'href' );
// Red links in old parser format
if ( link.hasClass( 'new' ) ) {
const oldRedLink = mw.util.getParamValue( 'title', href );
if ( oldRedLink !== null ) {
return oldRedLink;
}
}
// Check that external links are coming from this wiki
if ( href.indexOf( '//' ) === 0 || href.indexOf( 'https://' ) === 0 ) {
try {
const fullUrl = href.indexOf( '//' ) === 0 ? location.protocol + href : href;
let urlData = new URL( fullUrl );
if ( urlData.hostname !== location.hostname ) {
return null;
}
} catch( e ) {
return null;
}
}
var articlePathRegexp = new RegExp(
mw.config.get( 'wgArticlePath' ).replace( '$1', '([^?]*)' )
);
var result = articlePathRegexp.exec( href );
if ( Array.isArray( result ) && result.length > 1 ) {
return decodeURIComponent( result[ 1 ] );
}
return null;
};
/*
* Check whether this is a disambiguation page
*/
const isDisam = function() {
const categories = mw.config.get( 'wgCategories', [] );
return cfg.disamCategories.some( c => categories.includes( c ) );
};
const secondsToHHMMSS = function( totalSeconds ) {
var hhmmss = '';
var hours = Math.floor( totalSeconds / 3600 );
var minutes = Math.floor( totalSeconds % 3600 / 60 );
var seconds = Math.floor( totalSeconds % 3600 % 60 );
if ( hours >= 1 ) {
hhmmss = pad( hours, '0', 2 ) + ':';
}
hhmmss += pad( minutes, '0', 2 ) + ':' + pad( seconds, '0', 2 );
return hhmmss;
};
const pad = function( str, z, width ) {
str = str.toString();
if ( str.length >= width ) {
return str;
} else {
return new Array( width - str.length + 1 ).join( z ) + str;
}
}
/*
* Create a new button
* text: Text that will be displayed on the button
* onClick: Function that will be called when the button is clicked
*/
const createButton = function( text, onClick ) {
var button = $( '<button>', { 'type': 'button' } );
button.text( text );
button.addClass( 'disamassist-button' ).click( onClick );
return button;
};
/*
* Given a page title and an array of possible redirects {from, to} ("canonical titles"), find the page
* at the end of the redirect chain, if there is one. Otherwise, return the page title that was passed
*/
const resolveRedirect = function( pageTitle, possibleRedirects ) {
var appliedRedirect = true;
var visitedPages = {};
var currentPage = getCanonicalTitle( pageTitle );
while ( appliedRedirect ) {
appliedRedirect = false;
for ( var ii = 0; ii < possibleRedirects.length; ii++ ) {
if ( possibleRedirects[ii].from === currentPage ) {
if ( visitedPages[possibleRedirects[ii].to] ) {
// Redirect chain detected
return pageTitle;
}
visitedPages[currentPage] = true;
appliedRedirect = true;
currentPage = possibleRedirects[ii].to;
}
}
}
// No redirect rules applied for an iteration of the outer loop:
// no more redirects. We are done
return currentPage;
};
/*
* Fetch the incoming links to a page. Returns a jQuery promise
* (success - array of titles of pages that contain links to the target page and
* array of "canonical titles" of possible destinations of the backlinks (either
* the target page or redirects to the target page), failure - error description)
* page: Target page
*/
const getBacklinks = function( page ) {
var dfd = new $.Deferred();
api.get( {
'action': 'query',
'list': 'backlinks',
'bltitle': page,
'blredirect': true,
'bllimit': cfg.backlinkLimit,
'blnamespace': cfg.targetNamespaces,
} ).done( function( data ) {
// There might be duplicate entries in some corner cases. We don't care,
// since we are going to check later, anyway
var backlinks = [];
var linkTitles = [getCanonicalTitle( page )];
$.each( data.query.backlinks, function() {
backlinks.push( this.title );
if ( this.redirlinks ) {
linkTitles.push( this.title );
$.each( this.redirlinks, function() {
backlinks.push( this.title );
} );
}
} );
dfd.resolve( backlinks, linkTitles );
} ).fail( function( code, data ) {
dfd.reject( txt.getBacklinksError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download a list of redirects for some pages. Returns a jQuery callback (success -
* array of redirects ({from, to}), failure - error description )
* pageTitles: Array of page titles
*/
const fetchRedirects = function( pageTitles ) {
var dfd = new $.Deferred();
var currentTitles = pageTitles.slice( 0, cfg.queryTitleLimit );
var restTitles = pageTitles.slice( cfg.queryTitleLimit );
api.get( {
action: 'query',
titles: currentTitles,
redirects: true,
} ).done( function( data ) {
var theseRedirects = data.query.redirects ? data.query.redirects : [];
if ( restTitles.length !== 0 ) {
fetchRedirects( restTitles ).done( function( redirects ) {
dfd.resolve( theseRedirects.concat( redirects ) );
} ).fail( function( description ) {
dfd.reject( description );
} );
} else {
dfd.resolve( theseRedirects );
}
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRedirectsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download the list of user rights for the current user. Returns a
* jQuery promise (success - array of right names, error - error description)
*/
const fetchRights = function() {
var dfd = $.Deferred();
api.get( {
action: 'query',
meta: 'userinfo',
uiprop: 'rights',
} ).done( function( data ) {
dfd.resolve( data.query.userinfo.rights );
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRightsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Load the raw page text for a given title. Returns a jQuery promise (success - page
* content, failure - error description)
* pageTitle: Title of the page
*/
const loadPage = function( pageTitle ) {
var dfd = new $.Deferred();
api.get( {
action: 'query',
titles: pageTitle,
prop: 'revisions',
rvprop: 'timestamp|content',
meta: 'tokens',
type: 'csrf',
} ).done( function( data ) {
var pages = data.query.pages;
for ( var key in pages ) {
if ( pages.hasOwnProperty( key ) ) {
break;
}
}
var rawPage = data.query.pages[key];
var page = {};
page.missing = rawPage.missing !== undefined;
if ( rawPage.revisions ) {
page.content = rawPage.revisions[0]['*'];
// Assume anything with # at the start is a redirect
if ( page.content.match( /^#[^\s]*?\s*\[\[/ ) ) {
page.redirect = true;
}
page.baseTimeStamp = rawPage.revisions[0].timestamp;
} else {
page.content = '';
page.baseTimeStamp = null;
}
page.startTimeStamp = rawPage.starttimestamp;
page.editToken = data.query.tokens.csrftoken;
dfd.resolve( page );
} ).fail( function( code, data ) {
dfd.reject( txt.loadPageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
/*
* Register changes to a page, to be saved later. Returns a jQuery promise
* (success - no params, failure - error description). Takes the same parameters
* as savePage
*/
const saveWithCooldown = function() {
var deferred = new $.Deferred();
pendingSaves.push( {args: arguments, dfd: deferred} );
if ( !runningSaves ) {
checkAndSave();
}
return deferred.promise();
};
/*
* Save the first set of changes in the list of pending changes, providing that
* enough time has passed since the last edit
*/
const checkAndSave = function() {
if ( pendingSaves.length === 0 ) {
runningSaves = false;
return;
}
runningSaves = true;
var millisSinceLast = new Date().getTime() - lastEditMillis;
if ( millisSinceLast < cfg.editCooldown * 1000 ) {
setTimeout( checkAndSave, cfg.editCooldown * 1000 - millisSinceLast );
} else {
// The last edit started at least cfg.editCooldown seconds ago
var save = pendingSaves.shift();
savePage.apply( this, save.args ).done( function() {
checkAndSave();
save.dfd.resolve();
} ).fail( function( description ) {
checkAndSave();
save.dfd.reject( description );
} );
// We'll use the time since the last edit started
lastEditMillis = new Date().getTime();
}
};
/*
* Save the changes made to a page. Returns a jQuery promise (success - no params,
* failure - error description)
* pageTitle: Title of the page
* page: Page data
* summary: Summary of the changes made to the page
* minorEdit: Whether to mark the edit as 'minor'
* botEdit: Whether to mark the edit as 'bot'
*/
const savePage = function( pageTitle, page, summary, minorEdit, botEdit ) {
var dfd = new $.Deferred();
api.post( {
action: 'edit',
title: pageTitle,
token: page.editToken,
text: page.content,
basetimestamp: page.baseTimeStamp,
starttimestamp: page.startTimeStamp,
summary: summary,
watchlist: cfg.watch ? cfg.watch : 'nochange',
assertuser: mw.config.get( 'wgUserName' ),
minor: minorEdit,
bot: botEdit,
} ).done( function() {
dfd.resolve();
} ).fail( function( code, data ) {
dfd.reject( txt.savePageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
install();
} )();
// </nowiki>
rinfy42nb1vgcitq4ksgf8er6krq7aa
781221
781216
2026-10-27T16:50:03Z
Rots61
44293
781221
javascript
text/javascript
/*
* DisamAssist: a tool for repairing links from articles to disambiguation pages.
*/
// <nowiki>
// @ts-nocheck
( () => {
var cfg = {};
var txt = {};
var ui;
var links, pageChanges;
var currentPageTitle, currentPageParameters, currentLink;
var possibleBacklinkDestinations;
var titleRegex;
var forceSamePage = false;
var running = false;
var choosing = false;
var canMarkIntentionalLinks = false;
var displayedPages = {};
var editCount = 0;
var editLimit;
var pendingSaves = [];
var pendingEditBox = null;
var pendingEditBoxText;
var lastEditMillis = 0;
var runningSaves = false;
var api;
const dependencies = [
'mediawiki.api',
'mediawiki.confirmCloseWindow',
'mediawiki.Title',
'mediawiki.util',
];
/**
* Add an item in Tools menu
* @param {string} text
* @param {string} id
* @param {(e: MouseEvent) => void} callback
*/
const disamPortletLink = ( text, id, callback ) => {
const portlet = mw.util.addPortletLink(
'p-tb',
cfg.helpPage ? mw.util.getUrl( cfg.helpPage ) : '#',
text,
id
);
const link = portlet?.querySelector( 'a' );
link?.addEventListener( 'click', callback );
addButtonEvents( link );
}
/*
* Entry point. Check whether we are in a disambiguation page. If so, add a link to start the tool
*/
const install = function() {
cfg = window.DisamAssist.cfg;
txt = window.DisamAssist.txt;
if ( mw.config.get( 'wgAction' ) === 'view' && isDisam() ) {
mw.loader.using( dependencies, () => {
api = new mw.Api();
mw.loader.load( 'https://ru.wikipedia.org/w/index.php?title=Участник:Stjn/DisamAssist.css&action=raw&ctype=text/css', 'text/css' );
// This is a " (disambiguation)" page
if ( new RegExp( cfg.disamRegExp ).exec( getTitle() ) ) {
disamPortletLink( txt.startMain, 't-disamAssist-main', startMain );
disamPortletLink( txt.startSame, 't-disamAssist-same', startSame );
} else {
disamPortletLink( txt.start, 't-disamAssist-page', start );
}
} );
}
};
/*
* Start the tool. Display the UI and begin looking for links to fix
*/
const start = function( e ) {
e?.preventDefault();
if ( !running ) {
console.log(Date.now())
running = true;
links = [];
pageChanges = [];
displayedPages = {};
ensureDABExists().then( function( canMark ) {
canMarkIntentionalLinks = canMark;
createUI();
addUnloadConfirm();
markDisamOptions();
checkEditLimit().then( function() {
togglePendingEditBox( false );
doPage();
} );
} );
}
};
/*
* Start DisamAssist. Disambiguate incoming links to the current page, regardless
* of the title.
*/
const startSame = function( e ) {
e.preventDefault();
forceSamePage = true;
start( e );
};
/*
* Start DisamAssist. If the page title ends with " (disambiguation)", disambiguate
* links to the primary topic article. Otherwise, disambiguate links to the current
* page.
*/
const startMain = function( e ) {
e.preventDefault();
forceSamePage = false;
start( e );
};
/*
* Create and show the user interface.
*/
const createUI = function() {
ui = {
display: $( '<div>' ).addClass( 'disamassist-box disamassist-mainbox' ),
finishedMessage: $( '<div>' ).text( txt.noMoreLinks ).hide(),
pageTitleLine: $( '<span>' ).addClass( 'disamassist-pagetitleline' ),
pendingEditCounter: $( '<div>').addClass( 'disamassist-editcounter' ),
context: $( '<pre>' ).addClass( 'disamassist-context' ),
undoButton: createButton( txt.undo, undo ),
omitButton: createButton( txt.omit, omit ),
endButton: createButton( txt.close, saveAndEnd ),
refreshButton: createButton( txt.refresh, refresh ),
titleAsTextButton: createButton( txt.titleAsText, chooseTitleFromPrompt ),
intentionalLinkButton: canMarkIntentionalLinks ? createButton( txt.intentionalLink, chooseIntentionalLink ) : null,
disamNeededButton: cfg.disamNeededText ? createButton( txt.disamNeeded, chooseDisamNeeded ) : null,
removeLinkButton: createButton( txt.removeLink, chooseLinkRemoval )
};
var top = $( '<div>' ).addClass( 'disamassist-top' )
.append( [ui.pageTitleLine, ui.pendingEditCounter] );
var leftButtons = $( '<div>' ).addClass( 'disamassist-leftbuttons' )
.append( [ui.titleAsTextButton, ui.removeLinkButton, ui.intentionalLinkButton, ui.disamNeededButton, ui.omitButton] );
var rightButtons = $( '<div>' ).addClass( 'disamassist-rightbuttons' )
.append( [ui.undoButton, ui.refreshButton, ui.endButton] );
var allButtons = $( '<div>' ).addClass( 'disamassist-allbuttons' )
.append( [leftButtons, rightButtons] );
ui.display.append( [top, ui.finishedMessage, ui.context, allButtons] );
updateEditCounter();
toggleActionButtons( false );
// Insert the UI in the page
$( '#mw-content-text' ).before( ui.display );
ui.display.hide().fadeIn();
};
/**
* Add button role and related keyboard event
* @param {Element} el
*/
const addButtonEvents = ( el ) => {
el?.setAttribute( 'role', 'button' );
el?.addEventListener( 'keydown', function( e ) {
if ( [ 'Enter', 'Space' ].includes( e.code ) ) {
e.preventDefault();
this.click();
}
} );
}
/*
* If there are pending changes, show a confirm dialog before closing
*/
const addUnloadConfirm = function() {
mw.confirmCloseWindow( {
test: () => {
if ( running && checkActualChanges() ) {
return txt.pending;
} else if ( editCount !== 0 ) {
return txt.editInProgress;
}
}
} );
};
/*
* Mark the disambiguation options as such
*/
const markDisamOptions = function() {
var optionPageTitles = [];
var optionMarkers = [];
getDisamOptions().each( function() {
var link = $( this );
var title = extractPageName( link );
const optionMarker = document.createElement( 'a' );
optionMarker.classList.add( 'disamassist-optionmarker' );
optionMarker.href = link.attr( 'href' );
optionMarker.setAttribute( 'data-title', title );
optionMarker.textContent = txt.optionMarker;
optionMarker.addEventListener( 'click', ( e ) => {
e.preventDefault();
chooseReplacement( title );
} );
addButtonEvents( optionMarker );
link.after( optionMarker );
optionMarkers.push( optionMarker );
optionPageTitles.push( title );
} );
// Now check the disambiguation options and display a different message for those that are
// actually the same as the target page where the links go, as choosing those options doesn't really
// accomplish anything (except bypassing redirects, which might be useful in some cases)
var targetPage = getTargetPage();
fetchRedirects( optionPageTitles.concat( targetPage ) ).done( function( redirects ) {
var endTargetPage = resolveRedirect( targetPage, redirects );
for ( var ii = 0; ii < optionPageTitles.length; ii++ ) {
var endOptionTitle = resolveRedirect( optionPageTitles[ii], redirects );
if ( isSamePage( optionPageTitles[ii], targetPage ) ) {
$( optionMarkers[ii] ).text( txt.targetOptionMarker ).addClass( 'disamassist-curroptionmarker');
} else if ( isSamePage( endOptionTitle, endTargetPage ) ) {
$( optionMarkers[ii] ).text( txt.redirectOptionMarker ).addClass( 'disamassist-curroptionmarker');
}
}
} ).fail( error );
};
/*
* Check whether intentional links to disambiguation pages can be explicitly marked
* as such in this wiki. If so, ensure that a "Foo (disambiguation)" page exists.
* Returns a jQuery promise
*/
const ensureDABExists = function() {
var dfd = new $.Deferred();
var title = getTitle();
// That concept doesn't exist in this wiki.
if ( !cfg.intentionalLinkOption ) {
dfd.resolve( false );
// "Foo (disambiguation)" exists: it's the current page.
} else if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
dfd.resolve( true );
} else {
var disamTitle = cfg.disamFormat.replace( '$1', title );
loadPage( disamTitle ).done( function( page ) {
// "Foo (disambiguation)" doesn't exist.
if ( page.missing ) {
// We try to create it
page.content = cfg.redirectToDisam.replace( '$1', title );
var summary = txt.redirectSummary.replace( '$1', title );
savePage( disamTitle, page, summary, false, true ).done( function() {
dfd.resolve( true );
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
// It does exist
} else {
dfd.resolve( true );
}
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
}
return dfd.promise();
};
/*
* Check whether the edit cooldown applies and sets editLimit accordingly.
* Returns a jQuery promise
*/
const checkEditLimit = function() {
var dfd = new $.Deferred();
if ( cfg.editCooldown <= 0 ) {
editLimit = false;
dfd.resolve();
} else {
fetchRights().done( function( rights ) {
editLimit = !rights?.includes( 'bot' );
} ).fail( function( description ) {
error( description );
editLimit = true;
} ).always( function() {
dfd.resolve();
} );
}
return dfd.promise();
};
/*
* Find and ask the user to fix all the incoming links to the disambiguation ("target")
* page from a single "origin" page
*/
const doPage = function() {
if ( pageChanges.length > cfg.historySize ) {
applyChange( pageChanges.shift() );
}
if ( links.length === 0 ) {
var targetPage = getTargetPage();
getBacklinks( targetPage ).done( function( backlinks, pageTitles ) {
var pending = {};
$.each( pendingSaves, function() {
pending[this[0]] = true;
} );
possibleBacklinkDestinations = $.grep( pageTitles, function( t, ii) {
if ( t == targetPage ) {
return true;
}
return removeDisam(t) != targetPage;
} );
titleRegex = new RegExp('\\[\\[([ _]*?(?:' + possibleBacklinkDestinations.join('|').replaceAll('(', '\\(').replaceAll(')','\\)').replaceAll(' ', '[ _]') + ')[ _]*?)(#.*?)?(?:\\|(.*?))?\\]\\]', 'gi');
// Only incoming links from pages we haven't seen yet and we aren't currently
// saving (displayedPages is reset when the tool is closed and opened again,
// while the list of pending changes isn't; if the edit cooldown is disabled,
// it will be empty)
links = $.grep( backlinks, function( el, ii ) {
return !displayedPages[el] && !pending[el];
} );
if ( links.length === 0 ) {
updateContext();
} else {
doPage();
}
} ).fail( error );
} else {
currentPageTitle = links.shift();
displayedPages[currentPageTitle] = true;
toggleActionButtons( false );
loadPage( currentPageTitle ).done( function( data ) {
currentPageParameters = data;
currentLink = null;
doLink();
} ).fail( error );
}
};
/*
* Find and ask the user to fix a single incoming link to the disambiguation ("target")
* page
*/
const doLink = function() {
if (!currentPageParameters.redirect) {
currentLink = extractLinkToPage( currentPageParameters.content,
possibleBacklinkDestinations, 0 );
if ( currentLink ) {
updateContext();
} else {
doPage();
}
} else {
doPage();
}
};
/*
* Replace the target of a link with a different one
* pageTitle: New link target
* extra: Additional text after the link (optional)
* summary: Change summary (optional)
*/
const chooseReplacement = function( pageTitle, extra, summary ) {
if ( choosing ) {
choosing = false;
if ( !summary ) {
if ( pageTitle ) {
summary = txt.summaryChanged.replace( '$1', pageTitle );
} else {
summary = txt.summaryOmitted;
}
}
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
if ( pageTitle && ( pageTitle !== getTargetPage() || extra ) ) {
if ( currentPageParameters.redirect ) {
currentLink.description = pageTitle;
}
currentPageParameters.content =
replaceLink( currentPageParameters.content, pageTitle, currentLink, extra || '' );
}
doLink();
}
};
/*
* Replace the link with an explicit link to the disambiguation page
*/
const chooseIntentionalLink = function() {
const targetPage = getTargetPage();
let title = cfg.disamFormat.replace( '$1', targetPage );
if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
title = targetPage;
}
chooseReplacement( title, '', txt.summaryIntentional );
};
/*
* Prompt for an alternative link target and use it as a replacement
*/
const chooseTitleFromPrompt = function() {
var title = prompt( txt.titleAsTextPrompt );
if ( title !== null ) {
chooseReplacement( title );
}
};
/*
* Remove the current link, leaving the text unchanged
*/
const chooseLinkRemoval = function() {
if ( choosing ) {
var summary = txt.summaryRemoved;
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
currentPageParameters.content = removeLink( currentPageParameters.content, currentLink );
doLink();
}
};
/*
* Add a "disambiguation needed" template after the link
*/
const chooseDisamNeeded = function() {
chooseReplacement( currentLink.title, cfg.disamNeededText, txt.summaryHelpNeeded );
};
/*
* Undo the last change
*/
const undo = function() {
if ( pageChanges.length !== 0 ) {
var lastPage = pageChanges[pageChanges.length - 1];
if ( currentPageTitle !== lastPage.title ) {
links.unshift( currentPageTitle );
currentPageTitle = lastPage.title;
}
currentPageParameters = lastPage.page;
currentPageParameters.content = lastPage.contentBefore.pop();
currentLink = lastPage.links.pop();
lastPage.summary.pop();
if ( lastPage.contentBefore.length === 0 ) {
pageChanges.pop();
}
updateContext();
}
};
/*
* Omit the current link without making a change
*/
const omit = function() {
chooseReplacement( null );
};
/*
* Save all the pending changes and restart the tool.
*/
const refresh = function() {
saveAndEnd();
start();
};
/*
* Enable or disable the buttons that can perform actions on a page or change the current link.
* enabled: Whether to enable or disable the buttons
*/
const toggleActionButtons = function( enabled ) {
var affectedButtons = [ui.omitButton, ui.titleAsTextButton, ui.removeLinkButton,
ui.intentionalLinkButton, ui.disamNeededButton, ui.undoButton];
$.each( affectedButtons, function( ii, button ) {
button?.prop( 'disabled', !enabled );
} );
};
/*
* Show or hide the 'no more links' message
* show: Whether to show or hide the message
*/
const toggleFinishedMessage = function( show ) {
toggleActionButtons( !show );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.finishedMessage.toggle( show );
ui.pageTitleLine.toggle( !show );
ui.context.toggle( !show );
if ( show ) console.log(Date.now())
};
const togglePendingEditBox = function( show ) {
if ( pendingEditBox === null ) {
pendingEditBox = $( '<div>' ).addClass( 'disamassist-box disamassist-pendingeditbox' );
pendingEditBoxText = $( '<div>' );
pendingEditBox.append( pendingEditBoxText ).hide();
if ( editLimit ) {
pendingEditBox.append( $( '<div>' ).text( txt.pendingEditBoxLimited )
.addClass( 'disamassist-subtitle' ) );
}
$( '#mw-content-text' ).before( pendingEditBox );
updateEditCounter();
}
if ( show ) {
pendingEditBox.fadeIn();
} else {
pendingEditBox.fadeOut();
}
};
const notifyCompletion = function() {
var oldTitle = document.title;
document.title = txt.notifyCharacter + document.title;
$( document.body ).one( 'mousemove', function() {
document.title = oldTitle;
} );
};
/*
* Update the displayed information to match the current link
* or lack thereof
*/
const updateContext = function() {
updateEditCounter();
if ( !currentLink ) {
toggleFinishedMessage( true );
} else {
ui.pageTitleLine.html( txt.pageTitleLine.replace( '$1',
mw.util.getUrl( currentPageTitle, {redirect: 'no'} ) ).replace( '$2', mw.html.escape( currentPageTitle ) ) );
var context = extractContext( currentPageParameters.content, currentLink );
ui.context.empty()
.append( $( '<span>' ).text( context[0] ) )
.append( $( '<span>' ).text( context[1] ).addClass( 'disamassist-inclink' ) )
.append( $( '<span>' ).text( context[2] ) );
toggleFinishedMessage( false );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.removeLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.intentionalLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.disamNeededButton?.prop( 'disabled', currentPageParameters.redirect || currentLink.hasDisamTemplate );
choosing = true;
}
};
/*
* Update the count of pending changes
*/
const updateEditCounter = function() {
if ( ui.pendingEditCounter ) {
ui.pendingEditCounter.text( txt.pendingEditCounter.replace( '$1', editCount )
.replace( '$2', countActuallyChangedFullyCheckedPages() ) );
}
if ( pendingEditBox ) {
if ( editCount === 0 && !running ) {
togglePendingEditBox( false );
notifyCompletion();
}
var textContent = editCount;
if ( editLimit ) {
textContent = txt.pendingEditBoxTimeEstimation.replace( '$1', editCount )
.replace( '$2', secondsToHHMMSS( cfg.editCooldown * editCount ) );
}
pendingEditBoxText.text( txt.pendingEditBox.replace( '$1', textContent ) );
}
};
/*
* Apply the changes made to an "origin" page
* pageChange: Change that will be saved
*/
const applyChange = function( pageChange ) {
if ( pageChange.page.content !== pageChange.contentBefore[0] ) {
editCount++;
// Filter duplicates and join
var changeSummaries = pageChange.summary
.filter( ( val, i, arr ) => val && arr.indexOf( val ) === i )
.join( txt.summarySeparator );
var summary = txt.summary.replace( '$1', getTargetPage() ).replace( '$2', changeSummaries );
var save = editLimit ? saveWithCooldown : savePage;
save( pageChange.title, pageChange.page, summary, true, true ).always( function() {
if ( editCount > 0 ) {
editCount--;
}
updateEditCounter();
} ).fail( error );
updateEditCounter();
}
};
/*
* Save all the pending changes
*/
const applyAllChanges = function() {
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
applyChange( pageChanges[ii] );
}
pageChanges = [];
};
/*
* Record a new pending change
* pageTitle: Title of the page
* page: Content of the page
* oldContent: Content of the page before the change
* link: Link that has been changed
* summary: Change summary
*/
const addChange = function( pageTitle, page, oldContent, link, summary ) {
if ( ( pageChanges.length === 0 ) || ( pageChanges[pageChanges.length - 1].title !== pageTitle ) ) {
pageChanges.push( {
title: pageTitle,
page: page,
contentBefore: [],
links: [],
summary: []
} );
}
var lastPageChange = pageChanges[pageChanges.length - 1];
lastPageChange.contentBefore.push( oldContent );
lastPageChange.links.push( link );
lastPageChange.summary.push( summary );
};
/*
* Check whether actual changes are stored in the history array
*/
const checkActualChanges = function() {
return countActualChanges() !== 0;
};
/*
* Return the number of entries in the history array that represent actual changes
*/
const countActualChanges = function() {
var changeCount = 0;
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
if ( pageChanges[ii].page.content !== pageChanges[ii].contentBefore[0] ) {
changeCount++;
}
}
return changeCount;
};
/*
* Return the number of changed pages in the history array, ignoring the last entry
* if we aren't done with that page yet
*/
const countActuallyChangedFullyCheckedPages = function() {
var changeCount = countActualChanges();
if ( pageChanges.length !== 0 ) {
var lastChange = pageChanges[pageChanges.length - 1];
if ( lastChange.title === currentPageTitle && currentLink !== null
&& lastChange.page.content !== lastChange.contentBefore[0] ) {
changeCount--;
}
}
return changeCount;
};
/*
* Find the links to disambiguation options in a disambiguation page
*/
const getDisamOptions = function() {
return $( '#mw-content-text a' ).filter( function() {
return !!extractPageName( $( this ) );
} );
};
/*
* Save all the pending changes and close the tool
*/
const saveAndEnd = function() {
applyAllChanges();
end();
};
/*
* Close the tool
*/
const end = function() {
var currentToolUI = ui.display;
choosing = false;
running = false;
$( '.disamassist-optionmarker' ).remove();
currentToolUI.fadeOut( { complete: function() {
currentToolUI.remove();
if ( editCount !== 0 ) {
togglePendingEditBox( true );
}
} } );
};
/*
* Display an error message
*/
const error = function( errorDescription ) {
var errorBox = $( '<div>' ).addClass( 'disamassist-box disamassist-errorbox' );
errorBox.text( txt.error.replace( '$1', errorDescription ) );
errorBox.append( createButton( txt.dismissError, function() {
errorBox.fadeOut();
} ).addClass( 'disamassist-errorbutton' ) );
var uiIsInPlace = ui && $.contains( document.documentElement, ui.display[0] );
var nextElement = uiIsInPlace ? ui.display : $( '#mw-content-text' );
nextElement.before( errorBox );
errorBox.hide().fadeIn();
}
/*
* Change a link so that it points to the title
* text: The wikitext of the whole page
* title: The new destination of the link
* link: The link that will be modified
* extra: Text that will be added after the link (optional)
*/
const replaceLink = function( text, title, link, extra ) {
var anchor = link.anchor || '';
var newContent;
if ( link.type === 'sortnaam' ) {
newContent = '{{sortnaam|' + link.first + '|' + link.last + '|' + title + anchor + '}}';
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + ( extra || '' ) + linkEnd;
}
if ( link.type === 'ill' ) {
newContent = '{{ill|' + title + anchor + '|' + link.lang + '|' + link.other + ( link.rest || '' ) + '}}';
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + ( extra || '' ) + linkEnd;
}
if ( isSamePage( title, link.description ) ) {
newContent = link.description + anchor;
} else {
newContent = title + anchor + '|' + link.description;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + '[[' + newContent + ']]' + link.afterDescription + ( extra || '' ) + linkEnd;
};
/*
* Remove a link from the text
* text: The wikitext of the whole page
* link: The link that will be removed
*/
const removeLink = function( text, link ) {
var anchor = link.anchor || '';
if ( link.type === 'sortnaam' ) {
var newContent = '{{sortnaam|' + link.first + '|' + link.last + '}}' + anchor;
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + linkEnd;
}
if ( link.type === 'ill' ) {
var newContent = link.title + anchor;
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + linkEnd;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + link.description + anchor + link.afterDescription + linkEnd;
};
/*
* Extract a link from a string in wiki format,
* starting from a given index. Return a link if one can be found,
* otherwise return null.
*/
const extractLink = function( text, lastIndex ) {
// 1. Zoek naar gewone wikitlinks (inclusief optioneel anker #...)
titleRegex.lastIndex = lastIndex;
var wikiMatch = titleRegex.exec( text );
// 2. Zoek naar sortnaam-templates
var sortnaamRegex = new RegExp('{{\\s*sortnaam\\s*\\|\\s*([^|]+?)\\s*\\|\\s*([^|]+?)\\s*(?:\\|\\s*([^|#}]*?)\\s*)?(#[^|}]*)?}}', 'gi');
sortnaamRegex.lastIndex = lastIndex;
var sortnaamMatch = sortnaamRegex.exec( text );
// 3. Zoek naar ill-templates (ondersteunt ook nieuwe regels en extra parameters)
var illRegex = new RegExp('{{\\s*ill\\s*\\|([\\s\\S]*?)}}', 'gi');
illRegex.lastIndex = lastIndex;
var illMatch = illRegex.exec( text );
var matchType = null;
var matchIndex = Infinity;
if ( wikiMatch !== null && wikiMatch.index >= lastIndex ) {
matchIndex = wikiMatch.index;
matchType = 'wiki';
}
if ( sortnaamMatch !== null && sortnaamMatch.index >= lastIndex && sortnaamMatch.index < matchIndex ) {
matchIndex = sortnaamMatch.index;
matchType = 'sortnaam';
}
if ( illMatch !== null && illMatch.index >= lastIndex && illMatch.index < matchIndex ) {
matchIndex = illMatch.index;
matchType = 'ill';
}
if ( matchType === 'wiki' ) {
var possiblyAmbiguous = true;
var hasDisamTemplate = false;
var fullMatchStr = wikiMatch[0];
var anchor = wikiMatch[2] ? wikiMatch[2] : '';
var end = wikiMatch.index + fullMatchStr.length;
var afterDescription = '';
var rest = text.substring( end );
var templateRegex = /^(\w*[.,:;?!)}\s]*){{\s*([^|{}]+?)\s*(?:\|[^{]*?)?}}/g;
var templateMatch = templateRegex.exec( rest );
if ( templateMatch !== null ) {
var templateTitle = getCanonicalTitle( templateMatch[2] );
if ( cfg.disamLinkTemplates?.includes( templateTitle ) ) {
end += templateMatch[0].length;
afterDescription = templateMatch[1].replace(/\s$/, '');
hasDisamTemplate = true;
} else if ( cfg.disamLinkIgnoreTemplates?.includes( templateTitle ) ) {
possiblyAmbiguous = false;
}
}
return {
type: 'wiki',
start: wikiMatch.index,
end: end,
possiblyAmbiguous: possiblyAmbiguous,
hasDisamTemplate: hasDisamTemplate,
title: wikiMatch[1],
description: wikiMatch[3] ? wikiMatch[3] : wikiMatch[1],
anchor: anchor,
afterDescription: afterDescription
};
} else if ( matchType === 'sortnaam' ) {
var first = sortnaamMatch[1].trim();
var last = sortnaamMatch[2].trim();
var explicitTarget = sortnaamMatch[3] ? sortnaamMatch[3].trim() : '';
var anchor = sortnaamMatch[4] ? sortnaamMatch[4].trim() : '';
var target = explicitTarget !== '' ? explicitTarget : ( first + ' ' + last );
return {
type: 'sortnaam',
start: sortnaamMatch.index,
end: sortnaamMatch.index + sortnaamMatch[0].length,
possiblyAmbiguous: true,
hasDisamTemplate: false,
title: target,
description: sortnaamMatch[0],
first: first,
last: last,
explicitTarget: explicitTarget,
anchor: anchor,
afterDescription: ''
};
} else if ( matchType === 'ill' ) {
var inner = illMatch[1];
var parts = inner.split('|');
var firstParam = parts[0].trim();
var anchorMatch = firstParam.match(/^(.*?)(#.*)$/);
var title = anchorMatch ? anchorMatch[1].trim() : firstParam;
var anchor = anchorMatch ? anchorMatch[2] : '';
var lang = parts[1] ? parts[1].trim() : '';
var other = parts[2] ? parts[2].trim() : '';
var restParts = parts.slice(3);
var rest = restParts.length > 0 ? '|' + restParts.join('|') : '';
return {
type: 'ill',
start: illMatch.index,
end: illMatch.index + illMatch[0].length,
possiblyAmbiguous: true,
hasDisamTemplate: false,
title: title,
description: title,
anchor: anchor,
lang: lang,
other: other,
rest: rest,
afterDescription: ''
};
}
return null;
};
/*
* Extract a link to one of a number of destination pages from a string
* ("text") in wiki format, starting from a given index ("lastIndex").
* "Disambiguation needed" templates are included as part of the links.
* text: Page in wiki format
* destinations: Array of page titles to look for
* lastIndex: Index from which the search will start
*/
const extractLinkToPage = function( text, destinations, lastIndex ) {
var link, title;
do {
link = extractLink( text, lastIndex );
if ( link !== null ) {
lastIndex = link.end;
title = getCanonicalTitle( link.title );
}
} while (
link !== null
&& ( !link.possiblyAmbiguous || !destinations?.includes( title ) )
);
return link;
};
/*
* Find the "target" page: either the one we are in or the "main" one found by extracting
* the title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const getTargetPage = function() {
var title = getTitle();
return forceSamePage ? title : removeDisam( title );
};
/*
* Get the page title, with the namespace prefix if any.
*/
const getTitle = function() {
return mw.config.get( 'wgPageName' ).replace(/_/g, ' ')
}
/*
* Extract a "main" title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const removeDisam = function( title ) {
var match = new RegExp( cfg.disamRegExp ).exec( title );
return match ? match[1] : title;
};
/*
* Check whether two page titles are the same
*/
const isSamePage = function( title1, title2 ) {
return getCanonicalTitle( title1 ) === getCanonicalTitle( title2 );
};
/*
* Return the 'canonical title' of a page
*/
const getCanonicalTitle = function( title ) {
try {
title = new mw.Title( title );
// Check that page name matches the current namespace
if ( title.getNamespaceId() !== mw.config.get( 'wgNamespaceNumber' ) ) {
return;
}
const fragment = title.getFragment();
return title.getPrefixedText() + ( fragment ? `#${ fragment }` : '' );
} catch ( ex ) {
// mw.Title seems to be buggy, and some valid titles are rejected
// FIXME: This may cause false negatives
}
};
/*
* Extract the context around a given link in a text string
*/
const extractContext = function( text, link ) {
var contextStart = link.start - cfg.radius;
var contextEnd = link.end + cfg.radius;
var contextPrev = text.substring( contextStart, link.start );
const ellipsis = '…';
if ( contextStart > 0 ) {
contextPrev = ellipsis + contextPrev;
}
var contextNext = text.substring( link.end, contextEnd );
if ( contextEnd < text.length ) {
contextNext = contextNext + ellipsis;
}
return [contextPrev, text.substring( link.start, link.end ), contextNext];
};
/*
* Extract the prefixed page name from a link
*/
const extractPageName = function( link ) {
var pageName = extractPageNameRaw( link );
if ( pageName ) {
return getCanonicalTitle( pageName );
}
return null;
};
/*
* Extract the page name from a link, as is
*/
const extractPageNameRaw = function( link ) {
// Ignore images and interwiki links
if ( link.hasClass( 'image' ) || link.hasClass( 'extiw' ) ) {
return null;
}
var href = link.attr( 'href' );
// Red links in old parser format
if ( link.hasClass( 'new' ) ) {
const oldRedLink = mw.util.getParamValue( 'title', href );
if ( oldRedLink !== null ) {
return oldRedLink;
}
}
// Check that external links are coming from this wiki
if ( href.indexOf( '//' ) === 0 || href.indexOf( 'https://' ) === 0 ) {
try {
const fullUrl = href.indexOf( '//' ) === 0 ? location.protocol + href : href;
let urlData = new URL( fullUrl );
if ( urlData.hostname !== location.hostname ) {
return null;
}
} catch( e ) {
return null;
}
}
var articlePathRegexp = new RegExp(
mw.config.get( 'wgArticlePath' ).replace( '$1', '([^?]*)' )
);
var result = articlePathRegexp.exec( href );
if ( Array.isArray( result ) && result.length > 1 ) {
return decodeURIComponent( result[ 1 ] );
}
return null;
};
/*
* Check whether this is a disambiguation page
*/
const isDisam = function() {
const categories = mw.config.get( 'wgCategories', [] );
return cfg.disamCategories.some( c => categories.includes( c ) );
};
const secondsToHHMMSS = function( totalSeconds ) {
var hhmmss = '';
var hours = Math.floor( totalSeconds / 3600 );
var minutes = Math.floor( totalSeconds % 3600 / 60 );
var seconds = Math.floor( totalSeconds % 3600 % 60 );
if ( hours >= 1 ) {
hhmmss = pad( hours, '0', 2 ) + ':';
}
hhmmss += pad( minutes, '0', 2 ) + ':' + pad( seconds, '0', 2 );
return hhmmss;
};
const pad = function( str, z, width ) {
str = str.toString();
if ( str.length >= width ) {
return str;
} else {
return new Array( width - str.length + 1 ).join( z ) + str;
}
}
/*
* Create a new button
* text: Text that will be displayed on the button
* onClick: Function that will be called when the button is clicked
*/
const createButton = function( text, onClick ) {
var button = $( '<button>', { 'type': 'button' } );
button.text( text );
button.addClass( 'disamassist-button' ).click( onClick );
return button;
};
/*
* Given a page title and an array of possible redirects {from, to} ("canonical titles"), find the page
* at the end of the redirect chain, if there is one. Otherwise, return the page title that was passed
*/
const resolveRedirect = function( pageTitle, possibleRedirects ) {
var appliedRedirect = true;
var visitedPages = {};
var currentPage = getCanonicalTitle( pageTitle );
while ( appliedRedirect ) {
appliedRedirect = false;
for ( var ii = 0; ii < possibleRedirects.length; ii++ ) {
if ( possibleRedirects[ii].from === currentPage ) {
if ( visitedPages[possibleRedirects[ii].to] ) {
// Redirect chain detected
return pageTitle;
}
visitedPages[currentPage] = true;
appliedRedirect = true;
currentPage = possibleRedirects[ii].to;
}
}
}
// No redirect rules applied for an iteration of the outer loop:
// no more redirects. We are done
return currentPage;
};
/*
* Fetch the incoming links to a page. Returns a jQuery promise
* (success - array of titles of pages that contain links to the target page and
* array of "canonical titles" of possible destinations of the backlinks (either
* the target page or redirects to the target page), failure - error description)
* page: Target page
*/
const getBacklinks = function( page ) {
var dfd = new $.Deferred();
api.get( {
'action': 'query',
'list': 'backlinks',
'bltitle': page,
'blredirect': true,
'bllimit': cfg.backlinkLimit,
'blnamespace': cfg.targetNamespaces,
} ).done( function( data ) {
// There might be duplicate entries in some corner cases. We don't care,
// since we are going to check later, anyway
var backlinks = [];
var linkTitles = [getCanonicalTitle( page )];
$.each( data.query.backlinks, function() {
backlinks.push( this.title );
if ( this.redirlinks ) {
linkTitles.push( this.title );
$.each( this.redirlinks, function() {
backlinks.push( this.title );
} );
}
} );
dfd.resolve( backlinks, linkTitles );
} ).fail( function( code, data ) {
dfd.reject( txt.getBacklinksError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download a list of redirects for some pages. Returns a jQuery callback (success -
* array of redirects ({from, to}), failure - error description )
* pageTitles: Array of page titles
*/
const fetchRedirects = function( pageTitles ) {
var dfd = new $.Deferred();
var currentTitles = pageTitles.slice( 0, cfg.queryTitleLimit );
var restTitles = pageTitles.slice( cfg.queryTitleLimit );
api.get( {
action: 'query',
titles: currentTitles,
redirects: true,
} ).done( function( data ) {
var theseRedirects = data.query.redirects ? data.query.redirects : [];
if ( restTitles.length !== 0 ) {
fetchRedirects( restTitles ).done( function( redirects ) {
dfd.resolve( theseRedirects.concat( redirects ) );
} ).fail( function( description ) {
dfd.reject( description );
} );
} else {
dfd.resolve( theseRedirects );
}
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRedirectsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download the list of user rights for the current user. Returns a
* jQuery promise (success - array of right names, error - error description)
*/
const fetchRights = function() {
var dfd = $.Deferred();
api.get( {
action: 'query',
meta: 'userinfo',
uiprop: 'rights',
} ).done( function( data ) {
dfd.resolve( data.query.userinfo.rights );
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRightsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Load the raw page text for a given title. Returns a jQuery promise (success - page
* content, failure - error description)
* pageTitle: Title of the page
*/
const loadPage = function( pageTitle ) {
var dfd = new $.Deferred();
api.get( {
action: 'query',
titles: pageTitle,
prop: 'revisions',
rvprop: 'timestamp|content',
meta: 'tokens',
type: 'csrf',
} ).done( function( data ) {
var pages = data.query.pages;
for ( var key in pages ) {
if ( pages.hasOwnProperty( key ) ) {
break;
}
}
var rawPage = data.query.pages[key];
var page = {};
page.missing = rawPage.missing !== undefined;
if ( rawPage.revisions ) {
page.content = rawPage.revisions[0]['*'];
// Assume anything with # at the start is a redirect
if ( page.content.match( /^#[^\s]*?\s*\[\[/ ) ) {
page.redirect = true;
}
page.baseTimeStamp = rawPage.revisions[0].timestamp;
} else {
page.content = '';
page.baseTimeStamp = null;
}
page.startTimeStamp = rawPage.starttimestamp;
page.editToken = data.query.tokens.csrftoken;
dfd.resolve( page );
} ).fail( function( code, data ) {
dfd.reject( txt.loadPageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
/*
* Register changes to a page, to be saved later. Returns a jQuery promise
* (success - no params, failure - error description). Takes the same parameters
* as savePage
*/
const saveWithCooldown = function() {
var deferred = new $.Deferred();
pendingSaves.push( {args: arguments, dfd: deferred} );
if ( !runningSaves ) {
checkAndSave();
}
return deferred.promise();
};
/*
* Save the first set of changes in the list of pending changes, providing that
* enough time has passed since the last edit
*/
const checkAndSave = function() {
if ( pendingSaves.length === 0 ) {
runningSaves = false;
return;
}
runningSaves = true;
var millisSinceLast = new Date().getTime() - lastEditMillis;
if ( millisSinceLast < cfg.editCooldown * 1000 ) {
setTimeout( checkAndSave, cfg.editCooldown * 1000 - millisSinceLast );
} else {
// The last edit started at least cfg.editCooldown seconds ago
var save = pendingSaves.shift();
savePage.apply( this, save.args ).done( function() {
checkAndSave();
save.dfd.resolve();
} ).fail( function( description ) {
checkAndSave();
save.dfd.reject( description );
} );
// We'll use the time since the last edit started
lastEditMillis = new Date().getTime();
}
};
/*
* Save the changes made to a page. Returns a jQuery promise (success - no params,
* failure - error description)
* pageTitle: Title of the page
* page: Page data
* summary: Summary of the changes made to the page
* minorEdit: Whether to mark the edit as 'minor'
* botEdit: Whether to mark the edit as 'bot'
*/
const savePage = function( pageTitle, page, summary, minorEdit, botEdit ) {
var dfd = new $.Deferred();
api.post( {
action: 'edit',
title: pageTitle,
token: page.editToken,
text: page.content,
basetimestamp: page.baseTimeStamp,
starttimestamp: page.startTimeStamp,
summary: summary,
watchlist: cfg.watch ? cfg.watch : 'nochange',
assertuser: mw.config.get( 'wgUserName' ),
minor: minorEdit,
bot: botEdit,
} ).done( function() {
dfd.resolve();
} ).fail( function( code, data ) {
dfd.reject( txt.savePageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
install();
} )();
// </nowiki>
05fiw2niu8tbduhgyqthjlqxxzk40pc
781225
781221
2026-10-27T16:55:54Z
Rots61
44293
781225
javascript
text/javascript
/*
* DisamAssist: a tool for repairing links from articles to disambiguation pages.
*/
// <nowiki>
// @ts-nocheck
( () => {
var cfg = {};
var txt = {};
var ui;
var links, pageChanges;
var currentPageTitle, currentPageParameters, currentLink;
var possibleBacklinkDestinations;
var titleRegex;
var forceSamePage = false;
var running = false;
var choosing = false;
var canMarkIntentionalLinks = false;
var displayedPages = {};
var editCount = 0;
var editLimit;
var pendingSaves = [];
var pendingEditBox = null;
var pendingEditBoxText;
var lastEditMillis = 0;
var runningSaves = false;
var api;
const dependencies = [
'mediawiki.api',
'mediawiki.confirmCloseWindow',
'mediawiki.Title',
'mediawiki.util',
];
/**
* Add an item in Tools menu
* @param {string} text
* @param {string} id
* @param {(e: MouseEvent) => void} callback
*/
const disamPortletLink = ( text, id, callback ) => {
const portlet = mw.util.addPortletLink(
'p-tb',
cfg.helpPage ? mw.util.getUrl( cfg.helpPage ) : '#',
text,
id
);
const link = portlet?.querySelector( 'a' );
link?.addEventListener( 'click', callback );
addButtonEvents( link );
}
/*
* Entry point. Check whether we are in a disambiguation page. If so, add a link to start the tool
*/
const install = function() {
cfg = window.DisamAssist.cfg;
txt = window.DisamAssist.txt;
if ( mw.config.get( 'wgAction' ) === 'view' && isDisam() ) {
mw.loader.using( dependencies, () => {
api = new mw.Api();
mw.loader.load( 'https://ru.wikipedia.org/w/index.php?title=Участник:Stjn/DisamAssist.css&action=raw&ctype=text/css', 'text/css' );
// This is a " (disambiguation)" page
if ( new RegExp( cfg.disamRegExp ).exec( getTitle() ) ) {
disamPortletLink( txt.startMain, 't-disamAssist-main', startMain );
disamPortletLink( txt.startSame, 't-disamAssist-same', startSame );
} else {
disamPortletLink( txt.start, 't-disamAssist-page', start );
}
} );
}
};
/*
* Start the tool. Display the UI and begin looking for links to fix
*/
const start = function( e ) {
e?.preventDefault();
if ( !running ) {
console.log(Date.now())
running = true;
links = [];
pageChanges = [];
displayedPages = {};
ensureDABExists().then( function( canMark ) {
canMarkIntentionalLinks = canMark;
createUI();
addUnloadConfirm();
markDisamOptions();
checkEditLimit().then( function() {
togglePendingEditBox( false );
doPage();
} );
} );
}
};
/*
* Start DisamAssist. Disambiguate incoming links to the current page, regardless
* of the title.
*/
const startSame = function( e ) {
e.preventDefault();
forceSamePage = true;
start( e );
};
/*
* Start DisamAssist. If the page title ends with " (disambiguation)", disambiguate
* links to the primary topic article. Otherwise, disambiguate links to the current
* page.
*/
const startMain = function( e ) {
e.preventDefault();
forceSamePage = false;
start( e );
};
/*
* Create and show the user interface.
*/
const createUI = function() {
ui = {
display: $( '<div>' ).addClass( 'disamassist-box disamassist-mainbox' ),
finishedMessage: $( '<div>' ).text( txt.noMoreLinks ).hide(),
pageTitleLine: $( '<span>' ).addClass( 'disamassist-pagetitleline' ),
pendingEditCounter: $( '<div>').addClass( 'disamassist-editcounter' ),
context: $( '<pre>' ).addClass( 'disamassist-context' ),
undoButton: createButton( txt.undo, undo ),
omitButton: createButton( txt.omit, omit ),
endButton: createButton( txt.close, saveAndEnd ),
refreshButton: createButton( txt.refresh, refresh ),
titleAsTextButton: createButton( txt.titleAsText, chooseTitleFromPrompt ),
intentionalLinkButton: canMarkIntentionalLinks ? createButton( txt.intentionalLink, chooseIntentionalLink ) : null,
disamNeededButton: cfg.disamNeededText ? createButton( txt.disamNeeded, chooseDisamNeeded ) : null,
removeLinkButton: createButton( txt.removeLink, chooseLinkRemoval )
};
var top = $( '<div>' ).addClass( 'disamassist-top' )
.append( [ui.pageTitleLine, ui.pendingEditCounter] );
var leftButtons = $( '<div>' ).addClass( 'disamassist-leftbuttons' )
.append( [ui.titleAsTextButton, ui.removeLinkButton, ui.intentionalLinkButton, ui.disamNeededButton, ui.omitButton] );
var rightButtons = $( '<div>' ).addClass( 'disamassist-rightbuttons' )
.append( [ui.undoButton, ui.refreshButton, ui.endButton] );
var allButtons = $( '<div>' ).addClass( 'disamassist-allbuttons' )
.append( [leftButtons, rightButtons] );
ui.display.append( [top, ui.finishedMessage, ui.context, allButtons] );
updateEditCounter();
toggleActionButtons( false );
// Insert the UI in the page
$( '#mw-content-text' ).before( ui.display );
ui.display.hide().fadeIn();
};
/**
* Add button role and related keyboard event
* @param {Element} el
*/
const addButtonEvents = ( el ) => {
el?.setAttribute( 'role', 'button' );
el?.addEventListener( 'keydown', function( e ) {
if ( [ 'Enter', 'Space' ].includes( e.code ) ) {
e.preventDefault();
this.click();
}
} );
}
/*
* If there are pending changes, show a confirm dialog before closing
*/
const addUnloadConfirm = function() {
mw.confirmCloseWindow( {
test: () => {
if ( running && checkActualChanges() ) {
return txt.pending;
} else if ( editCount !== 0 ) {
return txt.editInProgress;
}
}
} );
};
/*
* Mark the disambiguation options as such
*/
const markDisamOptions = function() {
var optionPageTitles = [];
var optionMarkers = [];
getDisamOptions().each( function() {
var link = $( this );
var title = extractPageName( link );
const optionMarker = document.createElement( 'a' );
optionMarker.classList.add( 'disamassist-optionmarker' );
optionMarker.href = link.attr( 'href' );
optionMarker.setAttribute( 'data-title', title );
optionMarker.textContent = txt.optionMarker;
optionMarker.addEventListener( 'click', ( e ) => {
e.preventDefault();
chooseReplacement( title );
} );
addButtonEvents( optionMarker );
link.after( optionMarker );
optionMarkers.push( optionMarker );
optionPageTitles.push( title );
} );
// Now check the disambiguation options and display a different message for those that are
// actually the same as the target page where the links go, as choosing those options doesn't really
// accomplish anything (except bypassing redirects, which might be useful in some cases)
var targetPage = getTargetPage();
fetchRedirects( optionPageTitles.concat( targetPage ) ).done( function( redirects ) {
var endTargetPage = resolveRedirect( targetPage, redirects );
for ( var ii = 0; ii < optionPageTitles.length; ii++ ) {
var endOptionTitle = resolveRedirect( optionPageTitles[ii], redirects );
if ( isSamePage( optionPageTitles[ii], targetPage ) ) {
$( optionMarkers[ii] ).text( txt.targetOptionMarker ).addClass( 'disamassist-curroptionmarker');
} else if ( isSamePage( endOptionTitle, endTargetPage ) ) {
$( optionMarkers[ii] ).text( txt.redirectOptionMarker ).addClass( 'disamassist-curroptionmarker');
}
}
} ).fail( error );
};
/*
* Check whether intentional links to disambiguation pages can be explicitly marked
* as such in this wiki. If so, ensure that a "Foo (disambiguation)" page exists.
* Returns a jQuery promise
*/
const ensureDABExists = function() {
var dfd = new $.Deferred();
var title = getTitle();
// That concept doesn't exist in this wiki.
if ( !cfg.intentionalLinkOption ) {
dfd.resolve( false );
// "Foo (disambiguation)" exists: it's the current page.
} else if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
dfd.resolve( true );
} else {
var disamTitle = cfg.disamFormat.replace( '$1', title );
loadPage( disamTitle ).done( function( page ) {
// "Foo (disambiguation)" doesn't exist.
if ( page.missing ) {
// We try to create it
page.content = cfg.redirectToDisam.replace( '$1', title );
var summary = txt.redirectSummary.replace( '$1', title );
savePage( disamTitle, page, summary, false, true ).done( function() {
dfd.resolve( true );
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
// It does exist
} else {
dfd.resolve( true );
}
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
}
return dfd.promise();
};
/*
* Check whether the edit cooldown applies and sets editLimit accordingly.
* Returns a jQuery promise
*/
const checkEditLimit = function() {
var dfd = new $.Deferred();
if ( cfg.editCooldown <= 0 ) {
editLimit = false;
dfd.resolve();
} else {
fetchRights().done( function( rights ) {
editLimit = !rights?.includes( 'bot' );
} ).fail( function( description ) {
error( description );
editLimit = true;
} ).always( function() {
dfd.resolve();
} );
}
return dfd.promise();
};
/*
* Find and ask the user to fix all the incoming links to the disambiguation ("target")
* page from a single "origin" page
*/
const doPage = function() {
if ( pageChanges.length > cfg.historySize ) {
applyChange( pageChanges.shift() );
}
if ( links.length === 0 ) {
var targetPage = getTargetPage();
getBacklinks( targetPage ).done( function( backlinks, pageTitles ) {
var pending = {};
$.each( pendingSaves, function() {
pending[this[0]] = true;
} );
possibleBacklinkDestinations = $.grep( pageTitles, function( t, ii) {
if ( t == targetPage ) {
return true;
}
return removeDisam(t) != targetPage;
} );
titleRegex = new RegExp('\\[\\[([ _]*?(?:' + possibleBacklinkDestinations.join('|').replaceAll('(', '\\(').replaceAll(')','\\)').replaceAll(' ', '[ _]') + ')[ _]*?)(#.*?)?(?:\\|(.*?))?\\]\\]', 'gi');
// Only incoming links from pages we haven't seen yet and we aren't currently
// saving (displayedPages is reset when the tool is closed and opened again,
// while the list of pending changes isn't; if the edit cooldown is disabled,
// it will be empty)
links = $.grep( backlinks, function( el, ii ) {
return !displayedPages[el] && !pending[el];
} );
if ( links.length === 0 ) {
updateContext();
} else {
doPage();
}
} ).fail( error );
} else {
currentPageTitle = links.shift();
displayedPages[currentPageTitle] = true;
toggleActionButtons( false );
loadPage( currentPageTitle ).done( function( data ) {
currentPageParameters = data;
currentLink = null;
doLink();
} ).fail( error );
}
};
/*
* Find and ask the user to fix a single incoming link to the disambiguation ("target")
* page
*/
const doLink = function() {
if (!currentPageParameters.redirect) {
var startIndex = currentLink ? currentLink.end : 0;
currentLink = extractLinkToPage( currentPageParameters.content,
possibleBacklinkDestinations, startIndex );
if ( !currentLink && startIndex > 0 ) {
// Mocht een link aan het einde niet goed matchen door verschuivingen, probeer vanaf begin
currentLink = extractLinkToPage( currentPageParameters.content,
possibleBacklinkDestinations, 0 );
}
if ( currentLink ) {
updateContext();
} else {
doPage();
}
} else {
doPage();
}
};
/*
* Replace the target of a link with a different one
* pageTitle: New link target
* extra: Additional text after the link (optional)
* summary: Change summary (optional)
*/
const chooseReplacement = function( pageTitle, extra, summary ) {
if ( choosing ) {
choosing = false;
if ( !summary ) {
if ( pageTitle ) {
summary = txt.summaryChanged.replace( '$1', pageTitle );
} else {
summary = txt.summaryOmitted;
}
}
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
if ( pageTitle && ( pageTitle !== getTargetPage() || extra ) ) {
if ( currentPageParameters.redirect ) {
currentLink.description = pageTitle;
}
var oldEnd = currentLink.end;
currentPageParameters.content =
replaceLink( currentPageParameters.content, pageTitle, currentLink, extra || '' );
// Pas currentLink.end aan op basis van het lengteverschil zodat de volgende zoektocht correct verdergaat
// (of zet hem tijdelijk op 0 zodat hij na een vervanging de volgende identieke link kan vinden)
currentLink.end = 0;
}
doLink();
}
};
/*
* Replace the link with an explicit link to the disambiguation page
*/
const chooseIntentionalLink = function() {
const targetPage = getTargetPage();
let title = cfg.disamFormat.replace( '$1', targetPage );
if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
title = targetPage;
}
chooseReplacement( title, '', txt.summaryIntentional );
};
/*
* Prompt for an alternative link target and use it as a replacement
*/
const chooseTitleFromPrompt = function() {
var title = prompt( txt.titleAsTextPrompt );
if ( title !== null ) {
chooseReplacement( title );
}
};
/*
* Remove the current link, leaving the text unchanged
*/
const chooseLinkRemoval = function() {
if ( choosing ) {
var summary = txt.summaryRemoved;
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
currentPageParameters.content = removeLink( currentPageParameters.content, currentLink );
doLink();
}
};
/*
* Add a "disambiguation needed" template after the link
*/
const chooseDisamNeeded = function() {
chooseReplacement( currentLink.title, cfg.disamNeededText, txt.summaryHelpNeeded );
};
/*
* Undo the last change
*/
const undo = function() {
if ( pageChanges.length !== 0 ) {
var lastPage = pageChanges[pageChanges.length - 1];
if ( currentPageTitle !== lastPage.title ) {
links.unshift( currentPageTitle );
currentPageTitle = lastPage.title;
}
currentPageParameters = lastPage.page;
currentPageParameters.content = lastPage.contentBefore.pop();
currentLink = lastPage.links.pop();
lastPage.summary.pop();
if ( lastPage.contentBefore.length === 0 ) {
pageChanges.pop();
}
updateContext();
}
};
/*
* Omit the current link without making a change
*/
const omit = function() {
chooseReplacement( null );
};
/*
* Save all the pending changes and restart the tool.
*/
const refresh = function() {
saveAndEnd();
start();
};
/*
* Enable or disable the buttons that can perform actions on a page or change the current link.
* enabled: Whether to enable or disable the buttons
*/
const toggleActionButtons = function( enabled ) {
var affectedButtons = [ui.omitButton, ui.titleAsTextButton, ui.removeLinkButton,
ui.intentionalLinkButton, ui.disamNeededButton, ui.undoButton];
$.each( affectedButtons, function( ii, button ) {
button?.prop( 'disabled', !enabled );
} );
};
/*
* Show or hide the 'no more links' message
* show: Whether to show or hide the message
*/
const toggleFinishedMessage = function( show ) {
toggleActionButtons( !show );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.finishedMessage.toggle( show );
ui.pageTitleLine.toggle( !show );
ui.context.toggle( !show );
if ( show ) console.log(Date.now())
};
const togglePendingEditBox = function( show ) {
if ( pendingEditBox === null ) {
pendingEditBox = $( '<div>' ).addClass( 'disamassist-box disamassist-pendingeditbox' );
pendingEditBoxText = $( '<div>' );
pendingEditBox.append( pendingEditBoxText ).hide();
if ( editLimit ) {
pendingEditBox.append( $( '<div>' ).text( txt.pendingEditBoxLimited )
.addClass( 'disamassist-subtitle' ) );
}
$( '#mw-content-text' ).before( pendingEditBox );
updateEditCounter();
}
if ( show ) {
pendingEditBox.fadeIn();
} else {
pendingEditBox.fadeOut();
}
};
const notifyCompletion = function() {
var oldTitle = document.title;
document.title = txt.notifyCharacter + document.title;
$( document.body ).one( 'mousemove', function() {
document.title = oldTitle;
} );
};
/*
* Update the displayed information to match the current link
* or lack thereof
*/
const updateContext = function() {
updateEditCounter();
if ( !currentLink ) {
toggleFinishedMessage( true );
} else {
ui.pageTitleLine.html( txt.pageTitleLine.replace( '$1',
mw.util.getUrl( currentPageTitle, {redirect: 'no'} ) ).replace( '$2', mw.html.escape( currentPageTitle ) ) );
var context = extractContext( currentPageParameters.content, currentLink );
ui.context.empty()
.append( $( '<span>' ).text( context[0] ) )
.append( $( '<span>' ).text( context[1] ).addClass( 'disamassist-inclink' ) )
.append( $( '<span>' ).text( context[2] ) );
toggleFinishedMessage( false );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.removeLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.intentionalLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.disamNeededButton?.prop( 'disabled', currentPageParameters.redirect || currentLink.hasDisamTemplate );
choosing = true;
}
};
/*
* Update the count of pending changes
*/
const updateEditCounter = function() {
if ( ui.pendingEditCounter ) {
ui.pendingEditCounter.text( txt.pendingEditCounter.replace( '$1', editCount )
.replace( '$2', countActuallyChangedFullyCheckedPages() ) );
}
if ( pendingEditBox ) {
if ( editCount === 0 && !running ) {
togglePendingEditBox( false );
notifyCompletion();
}
var textContent = editCount;
if ( editLimit ) {
textContent = txt.pendingEditBoxTimeEstimation.replace( '$1', editCount )
.replace( '$2', secondsToHHMMSS( cfg.editCooldown * editCount ) );
}
pendingEditBoxText.text( txt.pendingEditBox.replace( '$1', textContent ) );
}
};
/*
* Apply the changes made to an "origin" page
* pageChange: Change that will be saved
*/
const applyChange = function( pageChange ) {
if ( pageChange.page.content !== pageChange.contentBefore[0] ) {
editCount++;
// Filter duplicates and join
var changeSummaries = pageChange.summary
.filter( ( val, i, arr ) => val && arr.indexOf( val ) === i )
.join( txt.summarySeparator );
var summary = txt.summary.replace( '$1', getTargetPage() ).replace( '$2', changeSummaries );
var save = editLimit ? saveWithCooldown : savePage;
save( pageChange.title, pageChange.page, summary, true, true ).always( function() {
if ( editCount > 0 ) {
editCount--;
}
updateEditCounter();
} ).fail( error );
updateEditCounter();
}
};
/*
* Save all the pending changes
*/
const applyAllChanges = function() {
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
applyChange( pageChanges[ii] );
}
pageChanges = [];
};
/*
* Record a new pending change
* pageTitle: Title of the page
* page: Content of the page
* oldContent: Content of the page before the change
* link: Link that has been changed
* summary: Change summary
*/
const addChange = function( pageTitle, page, oldContent, link, summary ) {
if ( ( pageChanges.length === 0 ) || ( pageChanges[pageChanges.length - 1].title !== pageTitle ) ) {
pageChanges.push( {
title: pageTitle,
page: page,
contentBefore: [],
links: [],
summary: []
} );
}
var lastPageChange = pageChanges[pageChanges.length - 1];
lastPageChange.contentBefore.push( oldContent );
lastPageChange.links.push( link );
lastPageChange.summary.push( summary );
};
/*
* Check whether actual changes are stored in the history array
*/
const checkActualChanges = function() {
return countActualChanges() !== 0;
};
/*
* Return the number of entries in the history array that represent actual changes
*/
const countActualChanges = function() {
var changeCount = 0;
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
if ( pageChanges[ii].page.content !== pageChanges[ii].contentBefore[0] ) {
changeCount++;
}
}
return changeCount;
};
/*
* Return the number of changed pages in the history array, ignoring the last entry
* if we aren't done with that page yet
*/
const countActuallyChangedFullyCheckedPages = function() {
var changeCount = countActualChanges();
if ( pageChanges.length !== 0 ) {
var lastChange = pageChanges[pageChanges.length - 1];
if ( lastChange.title === currentPageTitle && currentLink !== null
&& lastChange.page.content !== lastChange.contentBefore[0] ) {
changeCount--;
}
}
return changeCount;
};
/*
* Find the links to disambiguation options in a disambiguation page
*/
const getDisamOptions = function() {
return $( '#mw-content-text a' ).filter( function() {
return !!extractPageName( $( this ) );
} );
};
/*
* Save all the pending changes and close the tool
*/
const saveAndEnd = function() {
applyAllChanges();
end();
};
/*
* Close the tool
*/
const end = function() {
var currentToolUI = ui.display;
choosing = false;
running = false;
$( '.disamassist-optionmarker' ).remove();
currentToolUI.fadeOut( { complete: function() {
currentToolUI.remove();
if ( editCount !== 0 ) {
togglePendingEditBox( true );
}
} } );
};
/*
* Display an error message
*/
const error = function( errorDescription ) {
var errorBox = $( '<div>' ).addClass( 'disamassist-box disamassist-errorbox' );
errorBox.text( txt.error.replace( '$1', errorDescription ) );
errorBox.append( createButton( txt.dismissError, function() {
errorBox.fadeOut();
} ).addClass( 'disamassist-errorbutton' ) );
var uiIsInPlace = ui && $.contains( document.documentElement, ui.display[0] );
var nextElement = uiIsInPlace ? ui.display : $( '#mw-content-text' );
nextElement.before( errorBox );
errorBox.hide().fadeIn();
}
/*
* Change a link so that it points to the title
* text: The wikitext of the whole page
* title: The new destination of the link
* link: The link that will be modified
* extra: Text that will be added after the link (optional)
*/
const replaceLink = function( text, title, link, extra ) {
var anchor = link.anchor || '';
var newContent;
if ( link.type === 'sortnaam' ) {
newContent = '{{sortnaam|' + link.first + '|' + link.last + '|' + title + anchor + '}}';
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + ( extra || '' ) + linkEnd;
}
if ( link.type === 'ill' ) {
newContent = '{{ill|' + title + anchor + '|' + link.lang + '|' + link.other + ( link.rest || '' ) + '}}';
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + ( extra || '' ) + linkEnd;
}
if ( isSamePage( title, link.description ) ) {
newContent = link.description + anchor;
} else {
newContent = title + anchor + '|' + link.description;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + '[[' + newContent + ']]' + link.afterDescription + ( extra || '' ) + linkEnd;
};
/*
* Remove a link from the text
* text: The wikitext of the whole page
* link: The link that will be removed
*/
const removeLink = function( text, link ) {
var anchor = link.anchor || '';
if ( link.type === 'sortnaam' ) {
var newContent = '{{sortnaam|' + link.first + '|' + link.last + '}}' + anchor;
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + linkEnd;
}
if ( link.type === 'ill' ) {
var newContent = link.title + anchor;
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + linkEnd;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + link.description + anchor + link.afterDescription + linkEnd;
};
/*
* Extract a link from a string in wiki format,
* starting from a given index. Return a link if one can be found,
* otherwise return null.
*/
const extractLink = function( text, lastIndex ) {
// 1. Zoek naar gewone wikitlinks (inclusief optioneel anker #...)
titleRegex.lastIndex = lastIndex;
var wikiMatch = titleRegex.exec( text );
// 2. Zoek naar sortnaam-templates
var sortnaamRegex = new RegExp('{{\\s*sortnaam\\s*\\|\\s*([^|]+?)\\s*\\|\\s*([^|]+?)\\s*(?:\\|\\s*([^|#}]*?)\\s*)?(#[^|}]*)?}}', 'gi');
sortnaamRegex.lastIndex = lastIndex;
var sortnaamMatch = sortnaamRegex.exec( text );
// 3. Zoek naar ill-templates (ondersteunt ook nieuwe regels en extra parameters)
var illRegex = new RegExp('{{\\s*ill\\s*\\|([\\s\\S]*?)}}', 'gi');
illRegex.lastIndex = lastIndex;
var illMatch = illRegex.exec( text );
var matchType = null;
var matchIndex = Infinity;
if ( wikiMatch !== null && wikiMatch.index >= lastIndex ) {
matchIndex = wikiMatch.index;
matchType = 'wiki';
}
if ( sortnaamMatch !== null && sortnaamMatch.index >= lastIndex && sortnaamMatch.index < matchIndex ) {
matchIndex = sortnaamMatch.index;
matchType = 'sortnaam';
}
if ( illMatch !== null && illMatch.index >= lastIndex && illMatch.index < matchIndex ) {
matchIndex = illMatch.index;
matchType = 'ill';
}
if ( matchType === 'wiki' ) {
var possiblyAmbiguous = true;
var hasDisamTemplate = false;
var fullMatchStr = wikiMatch[0];
var anchor = wikiMatch[2] ? wikiMatch[2] : '';
var end = wikiMatch.index + fullMatchStr.length;
var afterDescription = '';
var rest = text.substring( end );
var templateRegex = /^(\w*[.,:;?!)}\s]*){{\s*([^|{}]+?)\s*(?:\|[^{]*?)?}}/g;
var templateMatch = templateRegex.exec( rest );
if ( templateMatch !== null ) {
var templateTitle = getCanonicalTitle( templateMatch[2] );
if ( cfg.disamLinkTemplates?.includes( templateTitle ) ) {
end += templateMatch[0].length;
afterDescription = templateMatch[1].replace(/\s$/, '');
hasDisamTemplate = true;
} else if ( cfg.disamLinkIgnoreTemplates?.includes( templateTitle ) ) {
possiblyAmbiguous = false;
}
}
return {
type: 'wiki',
start: wikiMatch.index,
end: end,
possiblyAmbiguous: possiblyAmbiguous,
hasDisamTemplate: hasDisamTemplate,
title: wikiMatch[1],
description: wikiMatch[3] ? wikiMatch[3] : wikiMatch[1],
anchor: anchor,
afterDescription: afterDescription
};
} else if ( matchType === 'sortnaam' ) {
var first = sortnaamMatch[1].trim();
var last = sortnaamMatch[2].trim();
var explicitTarget = sortnaamMatch[3] ? sortnaamMatch[3].trim() : '';
var anchor = sortnaamMatch[4] ? sortnaamMatch[4].trim() : '';
var target = explicitTarget !== '' ? explicitTarget : ( first + ' ' + last );
return {
type: 'sortnaam',
start: sortnaamMatch.index,
end: sortnaamMatch.index + sortnaamMatch[0].length,
possiblyAmbiguous: true,
hasDisamTemplate: false,
title: target,
description: sortnaamMatch[0],
first: first,
last: last,
explicitTarget: explicitTarget,
anchor: anchor,
afterDescription: ''
};
} else if ( matchType === 'ill' ) {
var inner = illMatch[1];
var parts = inner.split('|');
var firstParam = parts[0].trim();
var anchorMatch = firstParam.match(/^(.*?)(#.*)$/);
var title = anchorMatch ? anchorMatch[1].trim() : firstParam;
var anchor = anchorMatch ? anchorMatch[2] : '';
var lang = parts[1] ? parts[1].trim() : '';
var other = parts[2] ? parts[2].trim() : '';
var restParts = parts.slice(3);
var rest = restParts.length > 0 ? '|' + restParts.join('|') : '';
return {
type: 'ill',
start: illMatch.index,
end: illMatch.index + illMatch[0].length,
possiblyAmbiguous: true,
hasDisamTemplate: false,
title: title,
description: title,
anchor: anchor,
lang: lang,
other: other,
rest: rest,
afterDescription: ''
};
}
return null;
};
/*
* Extract a link to one of a number of destination pages from a string
* ("text") in wiki format, starting from a given index ("lastIndex").
* "Disambiguation needed" templates are included as part of the links.
* text: Page in wiki format
* destinations: Array of page titles to look for
* lastIndex: Index from which the search will start
*/
const extractLinkToPage = function( text, destinations, lastIndex ) {
var link, title;
do {
link = extractLink( text, lastIndex );
if ( link !== null ) {
lastIndex = link.end;
title = getCanonicalTitle( link.title );
}
} while (
link !== null
&& ( !link.possiblyAmbiguous || !destinations?.includes( title ) )
);
return link;
};
/*
* Find the "target" page: either the one we are in or the "main" one found by extracting
* the title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const getTargetPage = function() {
var title = getTitle();
return forceSamePage ? title : removeDisam( title );
};
/*
* Get the page title, with the namespace prefix if any.
*/
const getTitle = function() {
return mw.config.get( 'wgPageName' ).replace(/_/g, ' ')
}
/*
* Extract a "main" title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const removeDisam = function( title ) {
var match = new RegExp( cfg.disamRegExp ).exec( title );
return match ? match[1] : title;
};
/*
* Check whether two page titles are the same
*/
const isSamePage = function( title1, title2 ) {
return getCanonicalTitle( title1 ) === getCanonicalTitle( title2 );
};
/*
* Return the 'canonical title' of a page
*/
const getCanonicalTitle = function( title ) {
try {
title = new mw.Title( title );
// Check that page name matches the current namespace
if ( title.getNamespaceId() !== mw.config.get( 'wgNamespaceNumber' ) ) {
return;
}
const fragment = title.getFragment();
return title.getPrefixedText() + ( fragment ? `#${ fragment }` : '' );
} catch ( ex ) {
// mw.Title seems to be buggy, and some valid titles are rejected
// FIXME: This may cause false negatives
}
};
/*
* Extract the context around a given link in a text string
*/
const extractContext = function( text, link ) {
var contextStart = link.start - cfg.radius;
var contextEnd = link.end + cfg.radius;
var contextPrev = text.substring( contextStart, link.start );
const ellipsis = '…';
if ( contextStart > 0 ) {
contextPrev = ellipsis + contextPrev;
}
var contextNext = text.substring( link.end, contextEnd );
if ( contextEnd < text.length ) {
contextNext = contextNext + ellipsis;
}
return [contextPrev, text.substring( link.start, link.end ), contextNext];
};
/*
* Extract the prefixed page name from a link
*/
const extractPageName = function( link ) {
var pageName = extractPageNameRaw( link );
if ( pageName ) {
return getCanonicalTitle( pageName );
}
return null;
};
/*
* Extract the page name from a link, as is
*/
const extractPageNameRaw = function( link ) {
// Ignore images and interwiki links
if ( link.hasClass( 'image' ) || link.hasClass( 'extiw' ) ) {
return null;
}
var href = link.attr( 'href' );
// Red links in old parser format
if ( link.hasClass( 'new' ) ) {
const oldRedLink = mw.util.getParamValue( 'title', href );
if ( oldRedLink !== null ) {
return oldRedLink;
}
}
// Check that external links are coming from this wiki
if ( href.indexOf( '//' ) === 0 || href.indexOf( 'https://' ) === 0 ) {
try {
const fullUrl = href.indexOf( '//' ) === 0 ? location.protocol + href : href;
let urlData = new URL( fullUrl );
if ( urlData.hostname !== location.hostname ) {
return null;
}
} catch( e ) {
return null;
}
}
var articlePathRegexp = new RegExp(
mw.config.get( 'wgArticlePath' ).replace( '$1', '([^?]*)' )
);
var result = articlePathRegexp.exec( href );
if ( Array.isArray( result ) && result.length > 1 ) {
return decodeURIComponent( result[ 1 ] );
}
return null;
};
/*
* Check whether this is a disambiguation page
*/
const isDisam = function() {
const categories = mw.config.get( 'wgCategories', [] );
return cfg.disamCategories.some( c => categories.includes( c ) );
};
const secondsToHHMMSS = function( totalSeconds ) {
var hhmmss = '';
var hours = Math.floor( totalSeconds / 3600 );
var minutes = Math.floor( totalSeconds % 3600 / 60 );
var seconds = Math.floor( totalSeconds % 3600 % 60 );
if ( hours >= 1 ) {
hhmmss = pad( hours, '0', 2 ) + ':';
}
hhmmss += pad( minutes, '0', 2 ) + ':' + pad( seconds, '0', 2 );
return hhmmss;
};
const pad = function( str, z, width ) {
str = str.toString();
if ( str.length >= width ) {
return str;
} else {
return new Array( width - str.length + 1 ).join( z ) + str;
}
}
/*
* Create a new button
* text: Text that will be displayed on the button
* onClick: Function that will be called when the button is clicked
*/
const createButton = function( text, onClick ) {
var button = $( '<button>', { 'type': 'button' } );
button.text( text );
button.addClass( 'disamassist-button' ).click( onClick );
return button;
};
/*
* Given a page title and an array of possible redirects {from, to} ("canonical titles"), find the page
* at the end of the redirect chain, if there is one. Otherwise, return the page title that was passed
*/
const resolveRedirect = function( pageTitle, possibleRedirects ) {
var appliedRedirect = true;
var visitedPages = {};
var currentPage = getCanonicalTitle( pageTitle );
while ( appliedRedirect ) {
appliedRedirect = false;
for ( var ii = 0; ii < possibleRedirects.length; ii++ ) {
if ( possibleRedirects[ii].from === currentPage ) {
if ( visitedPages[possibleRedirects[ii].to] ) {
// Redirect chain detected
return pageTitle;
}
visitedPages[currentPage] = true;
appliedRedirect = true;
currentPage = possibleRedirects[ii].to;
}
}
}
// No redirect rules applied for an iteration of the outer loop:
// no more redirects. We are done
return currentPage;
};
/*
* Fetch the incoming links to a page. Returns a jQuery promise
* (success - array of titles of pages that contain links to the target page and
* array of "canonical titles" of possible destinations of the backlinks (either
* the target page or redirects to the target page), failure - error description)
* page: Target page
*/
const getBacklinks = function( page ) {
var dfd = new $.Deferred();
api.get( {
'action': 'query',
'list': 'backlinks',
'bltitle': page,
'blredirect': true,
'bllimit': cfg.backlinkLimit,
'blnamespace': cfg.targetNamespaces,
} ).done( function( data ) {
// There might be duplicate entries in some corner cases. We don't care,
// since we are going to check later, anyway
var backlinks = [];
var linkTitles = [getCanonicalTitle( page )];
$.each( data.query.backlinks, function() {
backlinks.push( this.title );
if ( this.redirlinks ) {
linkTitles.push( this.title );
$.each( this.redirlinks, function() {
backlinks.push( this.title );
} );
}
} );
dfd.resolve( backlinks, linkTitles );
} ).fail( function( code, data ) {
dfd.reject( txt.getBacklinksError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download a list of redirects for some pages. Returns a jQuery callback (success -
* array of redirects ({from, to}), failure - error description )
* pageTitles: Array of page titles
*/
const fetchRedirects = function( pageTitles ) {
var dfd = new $.Deferred();
var currentTitles = pageTitles.slice( 0, cfg.queryTitleLimit );
var restTitles = pageTitles.slice( cfg.queryTitleLimit );
api.get( {
action: 'query',
titles: currentTitles,
redirects: true,
} ).done( function( data ) {
var theseRedirects = data.query.redirects ? data.query.redirects : [];
if ( restTitles.length !== 0 ) {
fetchRedirects( restTitles ).done( function( redirects ) {
dfd.resolve( theseRedirects.concat( redirects ) );
} ).fail( function( description ) {
dfd.reject( description );
} );
} else {
dfd.resolve( theseRedirects );
}
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRedirectsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download the list of user rights for the current user. Returns a
* jQuery promise (success - array of right names, error - error description)
*/
const fetchRights = function() {
var dfd = $.Deferred();
api.get( {
action: 'query',
meta: 'userinfo',
uiprop: 'rights',
} ).done( function( data ) {
dfd.resolve( data.query.userinfo.rights );
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRightsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Load the raw page text for a given title. Returns a jQuery promise (success - page
* content, failure - error description)
* pageTitle: Title of the page
*/
const loadPage = function( pageTitle ) {
var dfd = new $.Deferred();
api.get( {
action: 'query',
titles: pageTitle,
prop: 'revisions',
rvprop: 'timestamp|content',
meta: 'tokens',
type: 'csrf',
} ).done( function( data ) {
var pages = data.query.pages;
for ( var key in pages ) {
if ( pages.hasOwnProperty( key ) ) {
break;
}
}
var rawPage = data.query.pages[key];
var page = {};
page.missing = rawPage.missing !== undefined;
if ( rawPage.revisions ) {
page.content = rawPage.revisions[0]['*'];
// Assume anything with # at the start is a redirect
if ( page.content.match( /^#[^\s]*?\s*\[\[/ ) ) {
page.redirect = true;
}
page.baseTimeStamp = rawPage.revisions[0].timestamp;
} else {
page.content = '';
page.baseTimeStamp = null;
}
page.startTimeStamp = rawPage.starttimestamp;
page.editToken = data.query.tokens.csrftoken;
dfd.resolve( page );
} ).fail( function( code, data ) {
dfd.reject( txt.loadPageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
/*
* Register changes to a page, to be saved later. Returns a jQuery promise
* (success - no params, failure - error description). Takes the same parameters
* as savePage
*/
const saveWithCooldown = function() {
var deferred = new $.Deferred();
pendingSaves.push( {args: arguments, dfd: deferred} );
if ( !runningSaves ) {
checkAndSave();
}
return deferred.promise();
};
/*
* Save the first set of changes in the list of pending changes, providing that
* enough time has passed since the last edit
*/
const checkAndSave = function() {
if ( pendingSaves.length === 0 ) {
runningSaves = false;
return;
}
runningSaves = true;
var millisSinceLast = new Date().getTime() - lastEditMillis;
if ( millisSinceLast < cfg.editCooldown * 1000 ) {
setTimeout( checkAndSave, cfg.editCooldown * 1000 - millisSinceLast );
} else {
// The last edit started at least cfg.editCooldown seconds ago
var save = pendingSaves.shift();
savePage.apply( this, save.args ).done( function() {
checkAndSave();
save.dfd.resolve();
} ).fail( function( description ) {
checkAndSave();
save.dfd.reject( description );
} );
// We'll use the time since the last edit started
lastEditMillis = new Date().getTime();
}
};
/*
* Save the changes made to a page. Returns a jQuery promise (success - no params,
* failure - error description)
* pageTitle: Title of the page
* page: Page data
* summary: Summary of the changes made to the page
* minorEdit: Whether to mark the edit as 'minor'
* botEdit: Whether to mark the edit as 'bot'
*/
const savePage = function( pageTitle, page, summary, minorEdit, botEdit ) {
var dfd = new $.Deferred();
api.post( {
action: 'edit',
title: pageTitle,
token: page.editToken,
text: page.content,
basetimestamp: page.baseTimeStamp,
starttimestamp: page.startTimeStamp,
summary: summary,
watchlist: cfg.watch ? cfg.watch : 'nochange',
assertuser: mw.config.get( 'wgUserName' ),
minor: minorEdit,
bot: botEdit,
} ).done( function() {
dfd.resolve();
} ).fail( function( code, data ) {
dfd.reject( txt.savePageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
install();
} )();
// </nowiki>
69pzjo5kgywz2qhd9uu1d2zcsl97ftd
781226
781225
2026-10-27T16:58:06Z
Rots61
44293
781226
javascript
text/javascript
/*
* DisamAssist: a tool for repairing links from articles to disambiguation pages.
*/
// <nowiki>
// @ts-nocheck
( () => {
var cfg = {};
var txt = {};
var ui;
var links, pageChanges;
var currentPageTitle, currentPageParameters, currentLink;
var possibleBacklinkDestinations;
var titleRegex;
var forceSamePage = false;
var running = false;
var choosing = false;
var canMarkIntentionalLinks = false;
var displayedPages = {};
var editCount = 0;
var editLimit;
var pendingSaves = [];
var pendingEditBox = null;
var pendingEditBoxText;
var lastEditMillis = 0;
var runningSaves = false;
var api;
const dependencies = [
'mediawiki.api',
'mediawiki.confirmCloseWindow',
'mediawiki.Title',
'mediawiki.util',
];
/**
* Add an item in Tools menu
* @param {string} text
* @param {string} id
* @param {(e: MouseEvent) => void} callback
*/
const disamPortletLink = ( text, id, callback ) => {
const portlet = mw.util.addPortletLink(
'p-tb',
cfg.helpPage ? mw.util.getUrl( cfg.helpPage ) : '#',
text,
id
);
const link = portlet?.querySelector( 'a' );
link?.addEventListener( 'click', callback );
addButtonEvents( link );
}
/*
* Entry point. Check whether we are in a disambiguation page. If so, add a link to start the tool
*/
const install = function() {
cfg = window.DisamAssist.cfg;
txt = window.DisamAssist.txt;
if ( mw.config.get( 'wgAction' ) === 'view' && isDisam() ) {
mw.loader.using( dependencies, () => {
api = new mw.Api();
mw.loader.load( 'https://ru.wikipedia.org/w/index.php?title=Участник:Stjn/DisamAssist.css&action=raw&ctype=text/css', 'text/css' );
// This is a " (disambiguation)" page
if ( new RegExp( cfg.disamRegExp ).exec( getTitle() ) ) {
disamPortletLink( txt.startMain, 't-disamAssist-main', startMain );
disamPortletLink( txt.startSame, 't-disamAssist-same', startSame );
} else {
disamPortletLink( txt.start, 't-disamAssist-page', start );
}
} );
}
};
/*
* Start the tool. Display the UI and begin looking for links to fix
*/
const start = function( e ) {
e?.preventDefault();
if ( !running ) {
console.log(Date.now())
running = true;
links = [];
pageChanges = [];
displayedPages = {};
ensureDABExists().then( function( canMark ) {
canMarkIntentionalLinks = canMark;
createUI();
addUnloadConfirm();
markDisamOptions();
checkEditLimit().then( function() {
togglePendingEditBox( false );
doPage();
} );
} );
}
};
/*
* Start DisamAssist. Disambiguate incoming links to the current page, regardless
* of the title.
*/
const startSame = function( e ) {
e.preventDefault();
forceSamePage = true;
start( e );
};
/*
* Start DisamAssist. If the page title ends with " (disambiguation)", disambiguate
* links to the primary topic article. Otherwise, disambiguate links to the current
* page.
*/
const startMain = function( e ) {
e.preventDefault();
forceSamePage = false;
start( e );
};
/*
* Create and show the user interface.
*/
const createUI = function() {
ui = {
display: $( '<div>' ).addClass( 'disamassist-box disamassist-mainbox' ),
finishedMessage: $( '<div>' ).text( txt.noMoreLinks ).hide(),
pageTitleLine: $( '<span>' ).addClass( 'disamassist-pagetitleline' ),
pendingEditCounter: $( '<div>').addClass( 'disamassist-editcounter' ),
context: $( '<pre>' ).addClass( 'disamassist-context' ),
undoButton: createButton( txt.undo, undo ),
omitButton: createButton( txt.omit, omit ),
endButton: createButton( txt.close, saveAndEnd ),
refreshButton: createButton( txt.refresh, refresh ),
titleAsTextButton: createButton( txt.titleAsText, chooseTitleFromPrompt ),
intentionalLinkButton: canMarkIntentionalLinks ? createButton( txt.intentionalLink, chooseIntentionalLink ) : null,
disamNeededButton: cfg.disamNeededText ? createButton( txt.disamNeeded, chooseDisamNeeded ) : null,
removeLinkButton: createButton( txt.removeLink, chooseLinkRemoval )
};
var top = $( '<div>' ).addClass( 'disamassist-top' )
.append( [ui.pageTitleLine, ui.pendingEditCounter] );
var leftButtons = $( '<div>' ).addClass( 'disamassist-leftbuttons' )
.append( [ui.titleAsTextButton, ui.removeLinkButton, ui.intentionalLinkButton, ui.disamNeededButton, ui.omitButton] );
var rightButtons = $( '<div>' ).addClass( 'disamassist-rightbuttons' )
.append( [ui.undoButton, ui.refreshButton, ui.endButton] );
var allButtons = $( '<div>' ).addClass( 'disamassist-allbuttons' )
.append( [leftButtons, rightButtons] );
ui.display.append( [top, ui.finishedMessage, ui.context, allButtons] );
updateEditCounter();
toggleActionButtons( false );
// Insert the UI in the page
$( '#mw-content-text' ).before( ui.display );
ui.display.hide().fadeIn();
};
/**
* Add button role and related keyboard event
* @param {Element} el
*/
const addButtonEvents = ( el ) => {
el?.setAttribute( 'role', 'button' );
el?.addEventListener( 'keydown', function( e ) {
if ( [ 'Enter', 'Space' ].includes( e.code ) ) {
e.preventDefault();
this.click();
}
} );
}
/*
* If there are pending changes, show a confirm dialog before closing
*/
const addUnloadConfirm = function() {
mw.confirmCloseWindow( {
test: () => {
if ( running && checkActualChanges() ) {
return txt.pending;
} else if ( editCount !== 0 ) {
return txt.editInProgress;
}
}
} );
};
/*
* Mark the disambiguation options as such
*/
const markDisamOptions = function() {
var optionPageTitles = [];
var optionMarkers = [];
getDisamOptions().each( function() {
var link = $( this );
var title = extractPageName( link );
const optionMarker = document.createElement( 'a' );
optionMarker.classList.add( 'disamassist-optionmarker' );
optionMarker.href = link.attr( 'href' );
optionMarker.setAttribute( 'data-title', title );
optionMarker.textContent = txt.optionMarker;
optionMarker.addEventListener( 'click', ( e ) => {
e.preventDefault();
chooseReplacement( title );
} );
addButtonEvents( optionMarker );
link.after( optionMarker );
optionMarkers.push( optionMarker );
optionPageTitles.push( title );
} );
// Now check the disambiguation options and display a different message for those that are
// actually the same as the target page where the links go, as choosing those options doesn't really
// accomplish anything (except bypassing redirects, which might be useful in some cases)
var targetPage = getTargetPage();
fetchRedirects( optionPageTitles.concat( targetPage ) ).done( function( redirects ) {
var endTargetPage = resolveRedirect( targetPage, redirects );
for ( var ii = 0; ii < optionPageTitles.length; ii++ ) {
var endOptionTitle = resolveRedirect( optionPageTitles[ii], redirects );
if ( isSamePage( optionPageTitles[ii], targetPage ) ) {
$( optionMarkers[ii] ).text( txt.targetOptionMarker ).addClass( 'disamassist-curroptionmarker');
} else if ( isSamePage( endOptionTitle, endTargetPage ) ) {
$( optionMarkers[ii] ).text( txt.redirectOptionMarker ).addClass( 'disamassist-curroptionmarker');
}
}
} ).fail( error );
};
/*
* Check whether intentional links to disambiguation pages can be explicitly marked
* as such in this wiki. If so, ensure that a "Foo (disambiguation)" page exists.
* Returns a jQuery promise
*/
const ensureDABExists = function() {
var dfd = new $.Deferred();
var title = getTitle();
// That concept doesn't exist in this wiki.
if ( !cfg.intentionalLinkOption ) {
dfd.resolve( false );
// "Foo (disambiguation)" exists: it's the current page.
} else if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
dfd.resolve( true );
} else {
var disamTitle = cfg.disamFormat.replace( '$1', title );
loadPage( disamTitle ).done( function( page ) {
// "Foo (disambiguation)" doesn't exist.
if ( page.missing ) {
// We try to create it
page.content = cfg.redirectToDisam.replace( '$1', title );
var summary = txt.redirectSummary.replace( '$1', title );
savePage( disamTitle, page, summary, false, true ).done( function() {
dfd.resolve( true );
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
// It does exist
} else {
dfd.resolve( true );
}
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
}
return dfd.promise();
};
/*
* Check whether the edit cooldown applies and sets editLimit accordingly.
* Returns a jQuery promise
*/
const checkEditLimit = function() {
var dfd = new $.Deferred();
if ( cfg.editCooldown <= 0 ) {
editLimit = false;
dfd.resolve();
} else {
fetchRights().done( function( rights ) {
editLimit = !rights?.includes( 'bot' );
} ).fail( function( description ) {
error( description );
editLimit = true;
} ).always( function() {
dfd.resolve();
} );
}
return dfd.promise();
};
/*
* Find and ask the user to fix all the incoming links to the disambiguation ("target")
* page from a single "origin" page
*/
const doPage = function() {
if ( pageChanges.length > cfg.historySize ) {
applyChange( pageChanges.shift() );
}
if ( links.length === 0 ) {
var targetPage = getTargetPage();
getBacklinks( targetPage ).done( function( backlinks, pageTitles ) {
var pending = {};
$.each( pendingSaves, function() {
pending[this[0]] = true;
} );
possibleBacklinkDestinations = $.grep( pageTitles, function( t, ii) {
if ( t == targetPage ) {
return true;
}
return removeDisam(t) != targetPage;
} );
titleRegex = new RegExp('\\[\\[([ _]*?(?:' + possibleBacklinkDestinations.join('|').replaceAll('(', '\\(').replaceAll(')','\\)').replaceAll(' ', '[ _]') + ')[ _]*?)(#.*?)?(?:\\|(.*?))?\\]\\]', 'gi');
// Only incoming links from pages we haven't seen yet and we aren't currently
// saving (displayedPages is reset when the tool is closed and opened again,
// while the list of pending changes isn't; if the edit cooldown is disabled,
// it will be empty)
links = $.grep( backlinks, function( el, ii ) {
return !displayedPages[el] && !pending[el];
} );
if ( links.length === 0 ) {
updateContext();
} else {
doPage();
}
} ).fail( error );
} else {
currentPageTitle = links.shift();
displayedPages[currentPageTitle] = true;
toggleActionButtons( false );
loadPage( currentPageTitle ).done( function( data ) {
currentPageParameters = data;
currentLink = null;
doLink();
} ).fail( error );
}
};
/*
* Find and ask the user to fix a single incoming link to the disambiguation ("target")
* page
*/
const doLink = function() {
if (!currentPageParameters.redirect) {
var startIndex = currentLink ? currentLink.end : 0;
currentLink = extractLinkToPage( currentPageParameters.content,
possibleBacklinkDestinations, startIndex );
if ( currentLink ) {
updateContext();
} else {
doPage();
}
} else {
doPage();
}
};
/*
* Replace the target of a link with a different one
* pageTitle: New link target
* extra: Additional text after the link (optional)
* summary: Change summary (optional)
*/
const chooseReplacement = function( pageTitle, extra, summary ) {
if ( choosing ) {
choosing = false;
if ( !summary ) {
if ( pageTitle ) {
summary = txt.summaryChanged.replace( '$1', pageTitle );
} else {
summary = txt.summaryOmitted;
}
}
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
if ( pageTitle && ( pageTitle !== getTargetPage() || extra ) ) {
if ( currentPageParameters.redirect ) {
currentLink.description = pageTitle;
}
currentPageParameters.content =
replaceLink( currentPageParameters.content, pageTitle, currentLink, extra || '' );
}
// Als er een daadwerkelijke vervanging is gedaan (pageTitle is niet null),
// resetten we currentLink naar null zodat de zoektocht vanaf 0 herbegimt.
// De reeds aangepaste link wordt genegeerd, waardoor de volgende dubbele link wél wordt gevonden.
if ( pageTitle !== null ) {
currentLink = null;
}
doLink();
}
};
/*
* Replace the link with an explicit link to the disambiguation page
*/
const chooseIntentionalLink = function() {
const targetPage = getTargetPage();
let title = cfg.disamFormat.replace( '$1', targetPage );
if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
title = targetPage;
}
chooseReplacement( title, '', txt.summaryIntentional );
};
/*
* Prompt for an alternative link target and use it as a replacement
*/
const chooseTitleFromPrompt = function() {
var title = prompt( txt.titleAsTextPrompt );
if ( title !== null ) {
chooseReplacement( title );
}
};
/*
* Remove the current link, leaving the text unchanged
*/
const chooseLinkRemoval = function() {
if ( choosing ) {
var summary = txt.summaryRemoved;
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
currentPageParameters.content = removeLink( currentPageParameters.content, currentLink );
currentLink = null;
doLink();
}
};
/*
* Add a "disambiguation needed" template after the link
*/
const chooseDisamNeeded = function() {
chooseReplacement( currentLink.title, cfg.disamNeededText, txt.summaryHelpNeeded );
};
/*
* Undo the last change
*/
const undo = function() {
if ( pageChanges.length !== 0 ) {
var lastPage = pageChanges[pageChanges.length - 1];
if ( currentPageTitle !== lastPage.title ) {
links.unshift( currentPageTitle );
currentPageTitle = lastPage.title;
}
currentPageParameters = lastPage.page;
currentPageParameters.content = lastPage.contentBefore.pop();
currentLink = lastPage.links.pop();
lastPage.summary.pop();
if ( lastPage.contentBefore.length === 0 ) {
pageChanges.pop();
}
updateContext();
}
};
/*
* Omit the current link without making a change
*/
const omit = function() {
chooseReplacement( null );
};
/*
* Save all the pending changes and restart the tool.
*/
const refresh = function() {
saveAndEnd();
start();
};
/*
* Enable or disable the buttons that can perform actions on a page or change the current link.
* enabled: Whether to enable or disable the buttons
*/
const toggleActionButtons = function( enabled ) {
var affectedButtons = [ui.omitButton, ui.titleAsTextButton, ui.removeLinkButton,
ui.intentionalLinkButton, ui.disamNeededButton, ui.undoButton];
$.each( affectedButtons, function( ii, button ) {
button?.prop( 'disabled', !enabled );
} );
};
/*
* Show or hide the 'no more links' message
* show: Whether to show or hide the message
*/
const toggleFinishedMessage = function( show ) {
toggleActionButtons( !show );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.finishedMessage.toggle( show );
ui.pageTitleLine.toggle( !show );
ui.context.toggle( !show );
if ( show ) console.log(Date.now())
};
const togglePendingEditBox = function( show ) {
if ( pendingEditBox === null ) {
pendingEditBox = $( '<div>' ).addClass( 'disamassist-box disamassist-pendingeditbox' );
pendingEditBoxText = $( '<div>' );
pendingEditBox.append( pendingEditBoxText ).hide();
if ( editLimit ) {
pendingEditBox.append( $( '<div>' ).text( txt.pendingEditBoxLimited )
.addClass( 'disamassist-subtitle' ) );
}
$( '#mw-content-text' ).before( pendingEditBox );
updateEditCounter();
}
if ( show ) {
pendingEditBox.fadeIn();
} else {
pendingEditBox.fadeOut();
}
};
const notifyCompletion = function() {
var oldTitle = document.title;
document.title = txt.notifyCharacter + document.title;
$( document.body ).one( 'mousemove', function() {
document.title = oldTitle;
} );
};
/*
* Update the displayed information to match the current link
* or lack thereof
*/
const updateContext = function() {
updateEditCounter();
if ( !currentLink ) {
toggleFinishedMessage( true );
} else {
ui.pageTitleLine.html( txt.pageTitleLine.replace( '$1',
mw.util.getUrl( currentPageTitle, {redirect: 'no'} ) ).replace( '$2', mw.html.escape( currentPageTitle ) ) );
var context = extractContext( currentPageParameters.content, currentLink );
ui.context.empty()
.append( $( '<span>' ).text( context[0] ) )
.append( $( '<span>' ).text( context[1] ).addClass( 'disamassist-inclink' ) )
.append( $( '<span>' ).text( context[2] ) );
toggleFinishedMessage( false );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.removeLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.intentionalLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.disamNeededButton?.prop( 'disabled', currentPageParameters.redirect || currentLink.hasDisamTemplate );
choosing = true;
}
};
/*
* Update the count of pending changes
*/
const updateEditCounter = function() {
if ( ui.pendingEditCounter ) {
ui.pendingEditCounter.text( txt.pendingEditCounter.replace( '$1', editCount )
.replace( '$2', countActuallyChangedFullyCheckedPages() ) );
}
if ( pendingEditBox ) {
if ( editCount === 0 && !running ) {
togglePendingEditBox( false );
notifyCompletion();
}
var textContent = editCount;
if ( editLimit ) {
textContent = txt.pendingEditBoxTimeEstimation.replace( '$1', editCount )
.replace( '$2', secondsToHHMMSS( cfg.editCooldown * editCount ) );
}
pendingEditBoxText.text( txt.pendingEditBox.replace( '$1', textContent ) );
}
};
/*
* Apply the changes made to an "origin" page
* pageChange: Change that will be saved
*/
const applyChange = function( pageChange ) {
if ( pageChange.page.content !== pageChange.contentBefore[0] ) {
editCount++;
// Filter duplicates and join
var changeSummaries = pageChange.summary
.filter( ( val, i, arr ) => val && arr.indexOf( val ) === i )
.join( txt.summarySeparator );
var summary = txt.summary.replace( '$1', getTargetPage() ).replace( '$2', changeSummaries );
var save = editLimit ? saveWithCooldown : savePage;
save( pageChange.title, pageChange.page, summary, true, true ).always( function() {
if ( editCount > 0 ) {
editCount--;
}
updateEditCounter();
} ).fail( error );
updateEditCounter();
}
};
/*
* Save all the pending changes
*/
const applyAllChanges = function() {
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
applyChange( pageChanges[ii] );
}
pageChanges = [];
};
/*
* Record a new pending change
* pageTitle: Title of the page
* page: Content of the page
* oldContent: Content of the page before the change
* link: Link that has been changed
* summary: Change summary
*/
const addChange = function( pageTitle, page, oldContent, link, summary ) {
if ( ( pageChanges.length === 0 ) || ( pageChanges[pageChanges.length - 1].title !== pageTitle ) ) {
pageChanges.push( {
title: pageTitle,
page: page,
contentBefore: [],
links: [],
summary: []
} );
}
var lastPageChange = pageChanges[pageChanges.length - 1];
lastPageChange.contentBefore.push( oldContent );
lastPageChange.links.push( link );
lastPageChange.summary.push( summary );
};
/*
* Check whether actual changes are stored in the history array
*/
const checkActualChanges = function() {
return countActualChanges() !== 0;
};
/*
* Return the number of entries in the history array that represent actual changes
*/
const countActualChanges = function() {
var changeCount = 0;
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
if ( pageChanges[ii].page.content !== pageChanges[ii].contentBefore[0] ) {
changeCount++;
}
}
return changeCount;
};
/*
* Return the number of changed pages in the history array, ignoring the last entry
* if we aren't done with that page yet
*/
const countActuallyChangedFullyCheckedPages = function() {
var changeCount = countActualChanges();
if ( pageChanges.length !== 0 ) {
var lastChange = pageChanges[pageChanges.length - 1];
if ( lastChange.title === currentPageTitle && currentLink !== null
&& lastChange.page.content !== lastChange.contentBefore[0] ) {
changeCount--;
}
}
return changeCount;
};
/*
* Find the links to disambiguation options in a disambiguation page
*/
const getDisamOptions = function() {
return $( '#mw-content-text a' ).filter( function() {
return !!extractPageName( $( this ) );
} );
};
/*
* Save all the pending changes and close the tool
*/
const saveAndEnd = function() {
applyAllChanges();
end();
};
/*
* Close the tool
*/
const end = function() {
var currentToolUI = ui.display;
choosing = false;
running = false;
$( '.disamassist-optionmarker' ).remove();
currentToolUI.fadeOut( { complete: function() {
currentToolUI.remove();
if ( editCount !== 0 ) {
togglePendingEditBox( true );
}
} } );
};
/*
* Display an error message
*/
const error = function( errorDescription ) {
var errorBox = $( '<div>' ).addClass( 'disamassist-box disamassist-errorbox' );
errorBox.text( txt.error.replace( '$1', errorDescription ) );
errorBox.append( createButton( txt.dismissError, function() {
errorBox.fadeOut();
} ).addClass( 'disamassist-errorbutton' ) );
var uiIsInPlace = ui && $.contains( document.documentElement, ui.display[0] );
var nextElement = uiIsInPlace ? ui.display : $( '#mw-content-text' );
nextElement.before( errorBox );
errorBox.hide().fadeIn();
}
/*
* Change a link so that it points to the title
* text: The wikitext of the whole page
* title: The new destination of the link
* link: The link that will be modified
* extra: Text that will be added after the link (optional)
*/
const replaceLink = function( text, title, link, extra ) {
var anchor = link.anchor || '';
var newContent;
if ( link.type === 'sortnaam' ) {
newContent = '{{sortnaam|' + link.first + '|' + link.last + '|' + title + anchor + '}}';
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + ( extra || '' ) + linkEnd;
}
if ( link.type === 'ill' ) {
newContent = '{{ill|' + title + anchor + '|' + link.lang + '|' + link.other + ( link.rest || '' ) + '}}';
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + ( extra || '' ) + linkEnd;
}
if ( isSamePage( title, link.description ) ) {
newContent = link.description + anchor;
} else {
newContent = title + anchor + '|' + link.description;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + '[[' + newContent + ']]' + link.afterDescription + ( extra || '' ) + linkEnd;
};
/*
* Remove a link from the text
* text: The wikitext of the whole page
* link: The link that will be removed
*/
const removeLink = function( text, link ) {
var anchor = link.anchor || '';
if ( link.type === 'sortnaam' ) {
var newContent = '{{sortnaam|' + link.first + '|' + link.last + '}}' + anchor;
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + linkEnd;
}
if ( link.type === 'ill' ) {
var newContent = link.title + anchor;
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + linkEnd;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + link.description + anchor + link.afterDescription + linkEnd;
};
/*
* Extract a link from a string in wiki format,
* starting from a given index. Return a link if one can be found,
* otherwise return null.
*/
const extractLink = function( text, lastIndex ) {
// 1. Zoek naar gewone wikitlinks (inclusief optioneel anker #...)
titleRegex.lastIndex = lastIndex;
var wikiMatch = titleRegex.exec( text );
// 2. Zoek naar sortnaam-templates
var sortnaamRegex = new RegExp('{{\\s*sortnaam\\s*\\|\\s*([^|]+?)\\s*\\|\\s*([^|]+?)\\s*(?:\\|\\s*([^|#}]*?)\\s*)?(#[^|}]*)?}}', 'gi');
sortnaamRegex.lastIndex = lastIndex;
var sortnaamMatch = sortnaamRegex.exec( text );
// 3. Zoek naar ill-templates (ondersteunt ook nieuwe regels en extra parameters)
var illRegex = new RegExp('{{\\s*ill\\s*\\|([\\s\\S]*?)}}', 'gi');
illRegex.lastIndex = lastIndex;
var illMatch = illRegex.exec( text );
var matchType = null;
var matchIndex = Infinity;
if ( wikiMatch !== null && wikiMatch.index >= lastIndex ) {
matchIndex = wikiMatch.index;
matchType = 'wiki';
}
if ( sortnaamMatch !== null && sortnaamMatch.index >= lastIndex && sortnaamMatch.index < matchIndex ) {
matchIndex = sortnaamMatch.index;
matchType = 'sortnaam';
}
if ( illMatch !== null && illMatch.index >= lastIndex && illMatch.index < matchIndex ) {
matchIndex = illMatch.index;
matchType = 'ill';
}
if ( matchType === 'wiki' ) {
var possiblyAmbiguous = true;
var hasDisamTemplate = false;
var fullMatchStr = wikiMatch[0];
var anchor = wikiMatch[2] ? wikiMatch[2] : '';
var end = wikiMatch.index + fullMatchStr.length;
var afterDescription = '';
var rest = text.substring( end );
var templateRegex = /^(\w*[.,:;?!)}\s]*){{\s*([^|{}]+?)\s*(?:\|[^{]*?)?}}/g;
var templateMatch = templateRegex.exec( rest );
if ( templateMatch !== null ) {
var templateTitle = getCanonicalTitle( templateMatch[2] );
if ( cfg.disamLinkTemplates?.includes( templateTitle ) ) {
end += templateMatch[0].length;
afterDescription = templateMatch[1].replace(/\s$/, '');
hasDisamTemplate = true;
} else if ( cfg.disamLinkIgnoreTemplates?.includes( templateTitle ) ) {
possiblyAmbiguous = false;
}
}
return {
type: 'wiki',
start: wikiMatch.index,
end: end,
possiblyAmbiguous: possiblyAmbiguous,
hasDisamTemplate: hasDisamTemplate,
title: wikiMatch[1],
description: wikiMatch[3] ? wikiMatch[3] : wikiMatch[1],
anchor: anchor,
afterDescription: afterDescription
};
} else if ( matchType === 'sortnaam' ) {
var first = sortnaamMatch[1].trim();
var last = sortnaamMatch[2].trim();
var explicitTarget = sortnaamMatch[3] ? sortnaamMatch[3].trim() : '';
var anchor = sortnaamMatch[4] ? sortnaamMatch[4].trim() : '';
var target = explicitTarget !== '' ? explicitTarget : ( first + ' ' + last );
return {
type: 'sortnaam',
start: sortnaamMatch.index,
end: sortnaamMatch.index + sortnaamMatch[0].length,
possiblyAmbiguous: true,
hasDisamTemplate: false,
title: target,
description: sortnaamMatch[0],
first: first,
last: last,
explicitTarget: explicitTarget,
anchor: anchor,
afterDescription: ''
};
} else if ( matchType === 'ill' ) {
var inner = illMatch[1];
var parts = inner.split('|');
var firstParam = parts[0].trim();
var anchorMatch = firstParam.match(/^(.*?)(#.*)$/);
var title = anchorMatch ? anchorMatch[1].trim() : firstParam;
var anchor = anchorMatch ? anchorMatch[2] : '';
var lang = parts[1] ? parts[1].trim() : '';
var other = parts[2] ? parts[2].trim() : '';
var restParts = parts.slice(3);
var rest = restParts.length > 0 ? '|' + restParts.join('|') : '';
return {
type: 'ill',
start: illMatch.index,
end: illMatch.index + illMatch[0].length,
possiblyAmbiguous: true,
hasDisamTemplate: false,
title: title,
description: title,
anchor: anchor,
lang: lang,
other: other,
rest: rest,
afterDescription: ''
};
}
return null;
};
/*
* Extract a link to one of a number of destination pages from a string
* ("text") in wiki format, starting from a given index ("lastIndex").
* "Disambiguation needed" templates are included as part of the links.
* text: Page in wiki format
* destinations: Array of page titles to look for
* lastIndex: Index from which the search will start
*/
const extractLinkToPage = function( text, destinations, lastIndex ) {
var link, title;
do {
link = extractLink( text, lastIndex );
if ( link !== null ) {
lastIndex = link.end;
title = getCanonicalTitle( link.title );
}
} while (
link !== null
&& ( !link.possiblyAmbiguous || !destinations?.includes( title ) )
);
return link;
};
/*
* Find the "target" page: either the one we are in or the "main" one found by extracting
* the title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const getTargetPage = function() {
var title = getTitle();
return forceSamePage ? title : removeDisam( title );
};
/*
* Get the page title, with the namespace prefix if any.
*/
const getTitle = function() {
return mw.config.get( 'wgPageName' ).replace(/_/g, ' ')
}
/*
* Extract a "main" title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const removeDisam = function( title ) {
var match = new RegExp( cfg.disamRegExp ).exec( title );
return match ? match[1] : title;
};
/*
* Check whether two page titles are the same
*/
const isSamePage = function( title1, title2 ) {
return getCanonicalTitle( title1 ) === getCanonicalTitle( title2 );
};
/*
* Return the 'canonical title' of a page
*/
const getCanonicalTitle = function( title ) {
try {
title = new mw.Title( title );
// Check that page name matches the current namespace
if ( title.getNamespaceId() !== mw.config.get( 'wgNamespaceNumber' ) ) {
return;
}
const fragment = title.getFragment();
return title.getPrefixedText() + ( fragment ? `#${ fragment }` : '' );
} catch ( ex ) {
// mw.Title seems to be buggy, and some valid titles are rejected
// FIXME: This may cause false negatives
}
};
/*
* Extract the context around a given link in a text string
*/
const extractContext = function( text, link ) {
var contextStart = link.start - cfg.radius;
var contextEnd = link.end + cfg.radius;
var contextPrev = text.substring( contextStart, link.start );
const ellipsis = '…';
if ( contextStart > 0 ) {
contextPrev = ellipsis + contextPrev;
}
var contextNext = text.substring( link.end, contextEnd );
if ( contextEnd < text.length ) {
contextNext = contextNext + ellipsis;
}
return [contextPrev, text.substring( link.start, link.end ), contextNext];
};
/*
* Extract the prefixed page name from a link
*/
const extractPageName = function( link ) {
var pageName = extractPageNameRaw( link );
if ( pageName ) {
return getCanonicalTitle( pageName );
}
return null;
};
/*
* Extract the page name from a link, as is
*/
const extractPageNameRaw = function( link ) {
// Ignore images and interwiki links
if ( link.hasClass( 'image' ) || link.hasClass( 'extiw' ) ) {
return null;
}
var href = link.attr( 'href' );
// Red links in old parser format
if ( link.hasClass( 'new' ) ) {
const oldRedLink = mw.util.getParamValue( 'title', href );
if ( oldRedLink !== null ) {
return oldRedLink;
}
}
// Check that external links are coming from this wiki
if ( href.indexOf( '//' ) === 0 || href.indexOf( 'https://' ) === 0 ) {
try {
const fullUrl = href.indexOf( '//' ) === 0 ? location.protocol + href : href;
let urlData = new URL( fullUrl );
if ( urlData.hostname !== location.hostname ) {
return null;
}
} catch( e ) {
return null;
}
}
var articlePathRegexp = new RegExp(
mw.config.get( 'wgArticlePath' ).replace( '$1', '([^?]*)' )
);
var result = articlePathRegexp.exec( href );
if ( Array.isArray( result ) && result.length > 1 ) {
return decodeURIComponent( result[ 1 ] );
}
return null;
};
/*
* Check whether this is a disambiguation page
*/
const isDisam = function() {
const categories = mw.config.get( 'wgCategories', [] );
return cfg.disamCategories.some( c => categories.includes( c ) );
};
const secondsToHHMMSS = function( totalSeconds ) {
var hhmmss = '';
var hours = Math.floor( totalSeconds / 3600 );
var minutes = Math.floor( totalSeconds % 3600 / 60 );
var seconds = Math.floor( totalSeconds % 3600 % 60 );
if ( hours >= 1 ) {
hhmmss = pad( hours, '0', 2 ) + ':';
}
hhmmss += pad( minutes, '0', 2 ) + ':' + pad( seconds, '0', 2 );
return hhmmss;
};
const pad = function( str, z, width ) {
str = str.toString();
if ( str.length >= width ) {
return str;
} else {
return new Array( width - str.length + 1 ).join( z ) + str;
}
}
/*
* Create a new button
* text: Text that will be displayed on the button
* onClick: Function that will be called when the button is clicked
*/
const createButton = function( text, onClick ) {
var button = $( '<button>', { 'type': 'button' } );
button.text( text );
button.addClass( 'disamassist-button' ).click( onClick );
return button;
};
/*
* Given a page title and an array of possible redirects {from, to} ("canonical titles"), find the page
* at the end of the redirect chain, if there is one. Otherwise, return the page title that was passed
*/
const resolveRedirect = function( pageTitle, possibleRedirects ) {
var appliedRedirect = true;
var visitedPages = {};
var currentPage = getCanonicalTitle( pageTitle );
while ( appliedRedirect ) {
appliedRedirect = false;
for ( var ii = 0; ii < possibleRedirects.length; ii++ ) {
if ( possibleRedirects[ii].from === currentPage ) {
if ( visitedPages[possibleRedirects[ii].to] ) {
// Redirect chain detected
return pageTitle;
}
visitedPages[currentPage] = true;
appliedRedirect = true;
currentPage = possibleRedirects[ii].to;
}
}
}
// No redirect rules applied for an iteration of the outer loop:
// no more redirects. We are done
return currentPage;
};
/*
* Fetch the incoming links to a page. Returns a jQuery promise
* (success - array of titles of pages that contain links to the target page and
* array of "canonical titles" of possible destinations of the backlinks (either
* the target page or redirects to the target page), failure - error description)
* page: Target page
*/
const getBacklinks = function( page ) {
var dfd = new $.Deferred();
api.get( {
'action': 'query',
'list': 'backlinks',
'bltitle': page,
'blredirect': true,
'bllimit': cfg.backlinkLimit,
'blnamespace': cfg.targetNamespaces,
} ).done( function( data ) {
// There might be duplicate entries in some corner cases. We don't care,
// since we are going to check later, anyway
var backlinks = [];
var linkTitles = [getCanonicalTitle( page )];
$.each( data.query.backlinks, function() {
backlinks.push( this.title );
if ( this.redirlinks ) {
linkTitles.push( this.title );
$.each( this.redirlinks, function() {
backlinks.push( this.title );
} );
}
} );
dfd.resolve( backlinks, linkTitles );
} ).fail( function( code, data ) {
dfd.reject( txt.getBacklinksError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download a list of redirects for some pages. Returns a jQuery callback (success -
* array of redirects ({from, to}), failure - error description )
* pageTitles: Array of page titles
*/
const fetchRedirects = function( pageTitles ) {
var dfd = new $.Deferred();
var currentTitles = pageTitles.slice( 0, cfg.queryTitleLimit );
var restTitles = pageTitles.slice( cfg.queryTitleLimit );
api.get( {
action: 'query',
titles: currentTitles,
redirects: true,
} ).done( function( data ) {
var theseRedirects = data.query.redirects ? data.query.redirects : [];
if ( restTitles.length !== 0 ) {
fetchRedirects( restTitles ).done( function( redirects ) {
dfd.resolve( theseRedirects.concat( redirects ) );
} ).fail( function( description ) {
dfd.reject( description );
} );
} else {
dfd.resolve( theseRedirects );
}
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRedirectsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download the list of user rights for the current user. Returns a
* jQuery promise (success - array of right names, error - error description)
*/
const fetchRights = function() {
var dfd = $.Deferred();
api.get( {
action: 'query',
meta: 'userinfo',
uiprop: 'rights',
} ).done( function( data ) {
dfd.resolve( data.query.userinfo.rights );
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRightsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Load the raw page text for a given title. Returns a jQuery promise (success - page
* content, failure - error description)
* pageTitle: Title of the page
*/
const loadPage = function( pageTitle ) {
var dfd = new $.Deferred();
api.get( {
action: 'query',
titles: pageTitle,
prop: 'revisions',
rvprop: 'timestamp|content',
meta: 'tokens',
type: 'csrf',
} ).done( function( data ) {
var pages = data.query.pages;
for ( var key in pages ) {
if ( pages.hasOwnProperty( key ) ) {
break;
}
}
var rawPage = data.query.pages[key];
var page = {};
page.missing = rawPage.missing !== undefined;
if ( rawPage.revisions ) {
page.content = rawPage.revisions[0]['*'];
// Assume anything with # at the start is a redirect
if ( page.content.match( /^#[^\s]*?\s*\[\[/ ) ) {
page.redirect = true;
}
page.baseTimeStamp = rawPage.revisions[0].timestamp;
} else {
page.content = '';
page.baseTimeStamp = null;
}
page.startTimeStamp = rawPage.starttimestamp;
page.editToken = data.query.tokens.csrftoken;
dfd.resolve( page );
} ).fail( function( code, data ) {
dfd.reject( txt.loadPageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
/*
* Register changes to a page, to be saved later. Returns a jQuery promise
* (success - no params, failure - error description). Takes the same parameters
* as savePage
*/
const saveWithCooldown = function() {
var deferred = new $.Deferred();
pendingSaves.push( {args: arguments, dfd: deferred} );
if ( !runningSaves ) {
checkAndSave();
}
return deferred.promise();
};
/*
* Save the first set of changes in the list of pending changes, providing that
* enough time has passed since the last edit
*/
const checkAndSave = function() {
if ( pendingSaves.length === 0 ) {
runningSaves = false;
return;
}
runningSaves = true;
var millisSinceLast = new Date().getTime() - lastEditMillis;
if ( millisSinceLast < cfg.editCooldown * 1000 ) {
setTimeout( checkAndSave, cfg.editCooldown * 1000 - millisSinceLast );
} else {
// The last edit started at least cfg.editCooldown seconds ago
var save = pendingSaves.shift();
savePage.apply( this, save.args ).done( function() {
checkAndSave();
save.dfd.resolve();
} ).fail( function( description ) {
checkAndSave();
save.dfd.reject( description );
} );
// We'll use the time since the last edit started
lastEditMillis = new Date().getTime();
}
};
/*
* Save the changes made to a page. Returns a jQuery promise (success - no params,
* failure - error description)
* pageTitle: Title of the page
* page: Page data
* summary: Summary of the changes made to the page
* minorEdit: Whether to mark the edit as 'minor'
* botEdit: Whether to mark the edit as 'bot'
*/
const savePage = function( pageTitle, page, summary, minorEdit, botEdit ) {
var dfd = new $.Deferred();
api.post( {
action: 'edit',
title: pageTitle,
token: page.editToken,
text: page.content,
basetimestamp: page.baseTimeStamp,
starttimestamp: page.startTimeStamp,
summary: summary,
watchlist: cfg.watch ? cfg.watch : 'nochange',
assertuser: mw.config.get( 'wgUserName' ),
minor: minorEdit,
bot: botEdit,
} ).done( function() {
dfd.resolve();
} ).fail( function( code, data ) {
dfd.reject( txt.savePageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
install();
} )();
// </nowiki>
1gz6qv0boyndwqvattr0hr5sadx4ip3
781229
781226
2026-10-27T17:05:04Z
Rots61
44293
781229
javascript
text/javascript
/*
* DisamAssist: a tool for repairing links from articles to disambiguation pages.
*/
// <nowiki>
// @ts-nocheck
( () => {
var cfg = {};
var txt = {};
var ui;
var links, pageChanges;
var currentPageTitle, currentPageParameters, currentLink;
var possibleBacklinkDestinations;
var titleRegex;
var forceSamePage = false;
var running = false;
var choosing = false;
var canMarkIntentionalLinks = false;
var displayedPages = {};
var editCount = 0;
var editLimit;
var pendingSaves = [];
var pendingEditBox = null;
var pendingEditBoxText;
var lastEditMillis = 0;
var runningSaves = false;
var api;
const dependencies = [
'mediawiki.api',
'mediawiki.confirmCloseWindow',
'mediawiki.Title',
'mediawiki.util',
];
/**
* Add an item in Tools menu
* @param {string} text
* @param {string} id
* @param {(e: MouseEvent) => void} callback
*/
const disamPortletLink = ( text, id, callback ) => {
const portlet = mw.util.addPortletLink(
'p-tb',
cfg.helpPage ? mw.util.getUrl( cfg.helpPage ) : '#',
text,
id
);
const link = portlet?.querySelector( 'a' );
link?.addEventListener( 'click', callback );
addButtonEvents( link );
}
/*
* Entry point. Check whether we are in a disambiguation page. If so, add a link to start the tool
*/
const install = function() {
cfg = window.DisamAssist.cfg;
txt = window.DisamAssist.txt;
if ( mw.config.get( 'wgAction' ) === 'view' && isDisam() ) {
mw.loader.using( dependencies, () => {
api = new mw.Api();
mw.loader.load( 'https://ru.wikipedia.org/w/index.php?title=Участник:Stjn/DisamAssist.css&action=raw&ctype=text/css', 'text/css' );
// This is a " (disambiguation)" page
if ( new RegExp( cfg.disamRegExp ).exec( getTitle() ) ) {
disamPortletLink( txt.startMain, 't-disamAssist-main', startMain );
disamPortletLink( txt.startSame, 't-disamAssist-same', startSame );
} else {
disamPortletLink( txt.start, 't-disamAssist-page', start );
}
} );
}
};
/*
* Start the tool. Display the UI and begin looking for links to fix
*/
const start = function( e ) {
e?.preventDefault();
if ( !running ) {
console.log(Date.now())
running = true;
links = [];
pageChanges = [];
displayedPages = {};
ensureDABExists().then( function( canMark ) {
canMarkIntentionalLinks = canMark;
createUI();
addUnloadConfirm();
markDisamOptions();
checkEditLimit().then( function() {
togglePendingEditBox( false );
doPage();
} );
} );
}
};
/*
* Start DisamAssist. Disambiguate incoming links to the current page, regardless
* of the title.
*/
const startSame = function( e ) {
e.preventDefault();
forceSamePage = true;
start( e );
};
/*
* Start DisamAssist. If the page title ends with " (disambiguation)", disambiguate
* links to the primary topic article. Otherwise, disambiguate links to the current
* page.
*/
const startMain = function( e ) {
e.preventDefault();
forceSamePage = false;
start( e );
};
/*
* Create and show the user interface.
*/
const createUI = function() {
ui = {
display: $( '<div>' ).addClass( 'disamassist-box disamassist-mainbox' ),
finishedMessage: $( '<div>' ).text( txt.noMoreLinks ).hide(),
pageTitleLine: $( '<span>' ).addClass( 'disamassist-pagetitleline' ),
pendingEditCounter: $( '<div>').addClass( 'disamassist-editcounter' ),
context: $( '<pre>' ).addClass( 'disamassist-context' ),
undoButton: createButton( txt.undo, undo ),
omitButton: createButton( txt.omit, omit ),
endButton: createButton( txt.close, saveAndEnd ),
refreshButton: createButton( txt.refresh, refresh ),
titleAsTextButton: createButton( txt.titleAsText, chooseTitleFromPrompt ),
intentionalLinkButton: canMarkIntentionalLinks ? createButton( txt.intentionalLink, chooseIntentionalLink ) : null,
disamNeededButton: cfg.disamNeededText ? createButton( txt.disamNeeded, chooseDisamNeeded ) : null,
removeLinkButton: createButton( txt.removeLink, chooseLinkRemoval )
};
var top = $( '<div>' ).addClass( 'disamassist-top' )
.append( [ui.pageTitleLine, ui.pendingEditCounter] );
var leftButtons = $( '<div>' ).addClass( 'disamassist-leftbuttons' )
.append( [ui.titleAsTextButton, ui.removeLinkButton, ui.intentionalLinkButton, ui.disamNeededButton, ui.omitButton] );
var rightButtons = $( '<div>' ).addClass( 'disamassist-rightbuttons' )
.append( [ui.undoButton, ui.refreshButton, ui.endButton] );
var allButtons = $( '<div>' ).addClass( 'disamassist-allbuttons' )
.append( [leftButtons, rightButtons] );
ui.display.append( [top, ui.finishedMessage, ui.context, allButtons] );
updateEditCounter();
toggleActionButtons( false );
// Insert the UI in the page
$( '#mw-content-text' ).before( ui.display );
ui.display.hide().fadeIn();
};
/**
* Add button role and related keyboard event
* @param {Element} el
*/
const addButtonEvents = ( el ) => {
el?.setAttribute( 'role', 'button' );
el?.addEventListener( 'keydown', function( e ) {
if ( [ 'Enter', 'Space' ].includes( e.code ) ) {
e.preventDefault();
this.click();
}
} );
}
/*
* If there are pending changes, show a confirm dialog before closing
*/
const addUnloadConfirm = function() {
mw.confirmCloseWindow( {
test: () => {
if ( running && checkActualChanges() ) {
return txt.pending;
} else if ( editCount !== 0 ) {
return txt.editInProgress;
}
}
} );
};
/*
* Mark the disambiguation options as such
*/
const markDisamOptions = function() {
var optionPageTitles = [];
var optionMarkers = [];
getDisamOptions().each( function() {
var link = $( this );
var title = extractPageName( link );
const optionMarker = document.createElement( 'a' );
optionMarker.classList.add( 'disamassist-optionmarker' );
optionMarker.href = link.attr( 'href' );
optionMarker.setAttribute( 'data-title', title );
optionMarker.textContent = txt.optionMarker;
optionMarker.addEventListener( 'click', ( e ) => {
e.preventDefault();
chooseReplacement( title );
} );
addButtonEvents( optionMarker );
link.after( optionMarker );
optionMarkers.push( optionMarker );
optionPageTitles.push( title );
} );
// Now check the disambiguation options and display a different message for those that are
// actually the same as the target page where the links go, as choosing those options doesn't really
// accomplish anything (except bypassing redirects, which might be useful in some cases)
var targetPage = getTargetPage();
fetchRedirects( optionPageTitles.concat( targetPage ) ).done( function( redirects ) {
var endTargetPage = resolveRedirect( targetPage, redirects );
for ( var ii = 0; ii < optionPageTitles.length; ii++ ) {
var endOptionTitle = resolveRedirect( optionPageTitles[ii], redirects );
if ( isSamePage( optionPageTitles[ii], targetPage ) ) {
$( optionMarkers[ii] ).text( txt.targetOptionMarker ).addClass( 'disamassist-curroptionmarker');
} else if ( isSamePage( endOptionTitle, endTargetPage ) ) {
$( optionMarkers[ii] ).text( txt.redirectOptionMarker ).addClass( 'disamassist-curroptionmarker');
}
}
} ).fail( error );
};
/*
* Check whether intentional links to disambiguation pages can be explicitly marked
* as such in this wiki. If so, ensure that a "Foo (disambiguation)" page exists.
* Returns a jQuery promise
*/
const ensureDABExists = function() {
var dfd = new $.Deferred();
var title = getTitle();
// That concept doesn't exist in this wiki.
if ( !cfg.intentionalLinkOption ) {
dfd.resolve( false );
// "Foo (disambiguation)" exists: it's the current page.
} else if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
dfd.resolve( true );
} else {
var disamTitle = cfg.disamFormat.replace( '$1', title );
loadPage( disamTitle ).done( function( page ) {
// "Foo (disambiguation)" doesn't exist.
if ( page.missing ) {
// We try to create it
page.content = cfg.redirectToDisam.replace( '$1', title );
var summary = txt.redirectSummary.replace( '$1', title );
savePage( disamTitle, page, summary, false, true ).done( function() {
dfd.resolve( true );
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
// It does exist
} else {
dfd.resolve( true );
}
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
}
return dfd.promise();
};
/*
* Check whether the edit cooldown applies and sets editLimit accordingly.
* Returns a jQuery promise
*/
const checkEditLimit = function() {
var dfd = new $.Deferred();
if ( cfg.editCooldown <= 0 ) {
editLimit = false;
dfd.resolve();
} else {
fetchRights().done( function( rights ) {
editLimit = !rights?.includes( 'bot' );
} ).fail( function( description ) {
error( description );
editLimit = true;
} ).always( function() {
dfd.resolve();
} );
}
return dfd.promise();
};
/*
* Find and ask the user to fix all the incoming links to the disambiguation ("target")
* page from a single "origin" page
*/
const doPage = function() {
if ( pageChanges.length > cfg.historySize ) {
applyChange( pageChanges.shift() );
}
if ( links.length === 0 ) {
var targetPage = getTargetPage();
getBacklinks( targetPage ).done( function( backlinks, pageTitles ) {
var pending = {};
$.each( pendingSaves, function() {
pending[this[0]] = true;
} );
possibleBacklinkDestinations = $.grep( pageTitles, function( t, ii) {
if ( t == targetPage ) {
return true;
}
return removeDisam(t) != targetPage;
} );
titleRegex = new RegExp('\\[\\[([ _]*?(?:' + possibleBacklinkDestinations.join('|').replaceAll('(', '\\(').replaceAll(')','\\)').replaceAll(' ', '[ _]') + ')[ _]*?)(#.*?)?(?:\\|(.*?))?\\]\\]', 'gi');
// Only incoming links from pages we haven't seen yet and we aren't currently
// saving (displayedPages is reset when the tool is closed and opened again,
// while the list of pending changes isn't; if the edit cooldown is disabled,
// it will be empty)
links = $.grep( backlinks, function( el, ii ) {
return !displayedPages[el] && !pending[el];
} );
if ( links.length === 0 ) {
updateContext();
} else {
doPage();
}
} ).fail( error );
} else {
currentPageTitle = links.shift();
displayedPages[currentPageTitle] = true;
toggleActionButtons( false );
loadPage( currentPageTitle ).done( function( data ) {
currentPageParameters = data;
currentLink = null;
doLink();
} ).fail( error );
}
};
/*
* Find and ask the user to fix a single incoming link to the disambiguation ("target")
* page
*/
const doLink = function() {
if (!currentPageParameters.redirect) {
currentLink = extractLinkToPage( currentPageParameters.content,
possibleBacklinkDestinations, currentLink ? currentLink.end : 0 );
if ( currentLink ) {
updateContext();
} else {
doPage();
}
} else {
doPage();
}
};
/*
* Replace the target of a link with a different one
* pageTitle: New link target
* extra: Additional text after the link (optional)
* summary: Change summary (optional)
*/
const chooseReplacement = function( pageTitle, extra, summary ) {
if ( choosing ) {
choosing = false;
if ( !summary ) {
if ( pageTitle ) {
summary = txt.summaryChanged.replace( '$1', pageTitle );
} else {
summary = txt.summaryOmitted;
}
}
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
if ( pageTitle && ( pageTitle !== getTargetPage() || extra ) ) {
if ( currentPageParameters.redirect ) {
currentLink.description = pageTitle;
}
currentPageParameters.content =
replaceLink( currentPageParameters.content, pageTitle, currentLink, extra || '' );
}
doLink();
}
};
/*
* Replace the link with an explicit link to the disambiguation page
*/
const chooseIntentionalLink = function() {
const targetPage = getTargetPage();
let title = cfg.disamFormat.replace( '$1', targetPage );
if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
title = targetPage;
}
chooseReplacement( title, '', txt.summaryIntentional );
};
/*
* Prompt for an alternative link target and use it as a replacement
*/
const chooseTitleFromPrompt = function() {
var title = prompt( txt.titleAsTextPrompt );
if ( title !== null ) {
chooseReplacement( title );
}
};
/*
* Remove the current link, leaving the text unchanged
*/
const chooseLinkRemoval = function() {
if ( choosing ) {
var summary = txt.summaryRemoved;
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
currentPageParameters.content = removeLink( currentPageParameters.content, currentLink );
doLink();
}
};
/*
* Add a "disambiguation needed" template after the link
*/
const chooseDisamNeeded = function() {
chooseReplacement( currentLink.title, cfg.disamNeededText, txt.summaryHelpNeeded );
};
/*
* Undo the last change
*/
const undo = function() {
if ( pageChanges.length !== 0 ) {
var lastPage = pageChanges[pageChanges.length - 1];
if ( currentPageTitle !== lastPage.title ) {
links.unshift( currentPageTitle );
currentPageTitle = lastPage.title;
}
currentPageParameters = lastPage.page;
currentPageParameters.content = lastPage.contentBefore.pop();
currentLink = lastPage.links.pop();
lastPage.summary.pop();
if ( lastPage.contentBefore.length === 0 ) {
pageChanges.pop();
}
updateContext();
}
};
/*
* Omit the current link without making a change
*/
const omit = function() {
chooseReplacement( null );
};
/*
* Save all the pending changes and restart the tool.
*/
const refresh = function() {
saveAndEnd();
start();
};
/*
* Enable or disable the buttons that can perform actions on a page or change the current link.
* enabled: Whether to enable or disable the buttons
*/
const toggleActionButtons = function( enabled ) {
var affectedButtons = [ui.omitButton, ui.titleAsTextButton, ui.removeLinkButton,
ui.intentionalLinkButton, ui.disamNeededButton, ui.undoButton];
$.each( affectedButtons, function( ii, button ) {
button?.prop( 'disabled', !enabled );
} );
};
/*
* Show or hide the 'no more links' message
* show: Whether to show or hide the message
*/
const toggleFinishedMessage = function( show ) {
toggleActionButtons( !show );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.finishedMessage.toggle( show );
ui.pageTitleLine.toggle( !show );
ui.context.toggle( !show );
if ( show ) console.log(Date.now())
};
const togglePendingEditBox = function( show ) {
if ( pendingEditBox === null ) {
pendingEditBox = $( '<div>' ).addClass( 'disamassist-box disamassist-pendingeditbox' );
pendingEditBoxText = $( '<div>' );
pendingEditBox.append( pendingEditBoxText ).hide();
if ( editLimit ) {
pendingEditBox.append( $( '<div>' ).text( txt.pendingEditBoxLimited )
.addClass( 'disamassist-subtitle' ) );
}
$( '#mw-content-text' ).before( pendingEditBox );
updateEditCounter();
}
if ( show ) {
pendingEditBox.fadeIn();
} else {
pendingEditBox.fadeOut();
}
};
const notifyCompletion = function() {
var oldTitle = document.title;
document.title = txt.notifyCharacter + document.title;
$( document.body ).one( 'mousemove', function() {
document.title = oldTitle;
} );
};
/*
* Update the displayed information to match the current link
* or lack thereof
*/
const updateContext = function() {
updateEditCounter();
if ( !currentLink ) {
toggleFinishedMessage( true );
} else {
ui.pageTitleLine.html( txt.pageTitleLine.replace( '$1',
mw.util.getUrl( currentPageTitle, {redirect: 'no'} ) ).replace( '$2', mw.html.escape( currentPageTitle ) ) );
var context = extractContext( currentPageParameters.content, currentLink );
ui.context.empty()
.append( $( '<span>' ).text( context[0] ) )
.append( $( '<span>' ).text( context[1] ).addClass( 'disamassist-inclink' ) )
.append( $( '<span>' ).text( context[2] ) );
toggleFinishedMessage( false );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.removeLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.intentionalLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.disamNeededButton?.prop( 'disabled', currentPageParameters.redirect || currentLink.hasDisamTemplate );
choosing = true;
}
};
/*
* Update the count of pending changes
*/
const updateEditCounter = function() {
if ( ui.pendingEditCounter ) {
ui.pendingEditCounter.text( txt.pendingEditCounter.replace( '$1', editCount )
.replace( '$2', countActuallyChangedFullyCheckedPages() ) );
}
if ( pendingEditBox ) {
if ( editCount === 0 && !running ) {
togglePendingEditBox( false );
notifyCompletion();
}
var textContent = editCount;
if ( editLimit ) {
textContent = txt.pendingEditBoxTimeEstimation.replace( '$1', editCount )
.replace( '$2', secondsToHHMMSS( cfg.editCooldown * editCount ) );
}
pendingEditBoxText.text( txt.pendingEditBox.replace( '$1', textContent ) );
}
};
/*
* Apply the changes made to an "origin" page
* pageChange: Change that will be saved
*/
const applyChange = function( pageChange ) {
if ( pageChange.page.content !== pageChange.contentBefore[0] ) {
editCount++;
// Filter duplicates and join
var changeSummaries = pageChange.summary
.filter( ( val, i, arr ) => val && arr.indexOf( val ) === i )
.join( txt.summarySeparator );
var summary = txt.summary.replace( '$1', getTargetPage() ).replace( '$2', changeSummaries );
var save = editLimit ? saveWithCooldown : savePage;
save( pageChange.title, pageChange.page, summary, true, true ).always( function() {
if ( editCount > 0 ) {
editCount--;
}
updateEditCounter();
} ).fail( error );
updateEditCounter();
}
};
/*
* Save all the pending changes
*/
const applyAllChanges = function() {
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
applyChange( pageChanges[ii] );
}
pageChanges = [];
};
/*
* Record a new pending change
* pageTitle: Title of the page
* page: Content of the page
* oldContent: Content of the page before the change
* link: Link that has been changed
* summary: Change summary
*/
const addChange = function( pageTitle, page, oldContent, link, summary ) {
if ( ( pageChanges.length === 0 ) || ( pageChanges[pageChanges.length - 1].title !== pageTitle ) ) {
pageChanges.push( {
title: pageTitle,
page: page,
contentBefore: [],
links: [],
summary: []
} );
}
var lastPageChange = pageChanges[pageChanges.length - 1];
lastPageChange.contentBefore.push( oldContent );
lastPageChange.links.push( link );
lastPageChange.summary.push( summary );
};
/*
* Check whether actual changes are stored in the history array
*/
const checkActualChanges = function() {
return countActualChanges() !== 0;
};
/*
* Return the number of entries in the history array that represent actual changes
*/
const countActualChanges = function() {
var changeCount = 0;
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
if ( pageChanges[ii].page.content !== pageChanges[ii].contentBefore[0] ) {
changeCount++;
}
}
return changeCount;
};
/*
* Return the number of changed pages in the history array, ignoring the last entry
* if we aren't done with that page yet
*/
const countActuallyChangedFullyCheckedPages = function() {
var changeCount = countActualChanges();
if ( pageChanges.length !== 0 ) {
var lastChange = pageChanges[pageChanges.length - 1];
if ( lastChange.title === currentPageTitle && currentLink !== null
&& lastChange.page.content !== lastChange.contentBefore[0] ) {
changeCount--;
}
}
return changeCount;
};
/*
* Find the links to disambiguation options in a disambiguation page
*/
const getDisamOptions = function() {
return $( '#mw-content-text a' ).filter( function() {
return !!extractPageName( $( this ) );
} );
};
/*
* Save all the pending changes and close the tool
*/
const saveAndEnd = function() {
applyAllChanges();
end();
};
/*
* Close the tool
*/
const end = function() {
var currentToolUI = ui.display;
choosing = false;
running = false;
$( '.disamassist-optionmarker' ).remove();
currentToolUI.fadeOut( { complete: function() {
currentToolUI.remove();
if ( editCount !== 0 ) {
togglePendingEditBox( true );
}
} } );
};
/*
* Display an error message
*/
const error = function( errorDescription ) {
var errorBox = $( '<div>' ).addClass( 'disamassist-box disamassist-errorbox' );
errorBox.text( txt.error.replace( '$1', errorDescription ) );
errorBox.append( createButton( txt.dismissError, function() {
errorBox.fadeOut();
} ).addClass( 'disamassist-errorbutton' ) );
var uiIsInPlace = ui && $.contains( document.documentElement, ui.display[0] );
var nextElement = uiIsInPlace ? ui.display : $( '#mw-content-text' );
nextElement.before( errorBox );
errorBox.hide().fadeIn();
}
/*
* Change a link so that it points to the title
* text: The wikitext of the whole page
* title: The new destination of the link
* link: The link that will be modified
* extra: Text that will be added after the link (optional)
*/
const replaceLink = function( text, title, link, extra ) {
var anchor = link.anchor || '';
var newContent;
if ( link.type === 'sortnaam' ) {
newContent = '{{sortnaam|' + link.first + '|' + link.last + '|' + title + anchor + '}}';
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + ( extra || '' ) + linkEnd;
}
if ( link.type === 'ill' ) {
newContent = '{{ill|' + title + anchor + '|' + link.lang + '|' + link.other + ( link.rest || '' ) + '}}';
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + ( extra || '' ) + linkEnd;
}
if ( isSamePage( title, link.description ) ) {
// [[B|A]] should be replaced with [[A]] rather than [[A|A]]
newContent = link.description + anchor;
} else {
newContent = title + anchor + '|' + link.description;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + '[[' + newContent + ']]' + link.afterDescription + ( extra || '' ) + linkEnd;
};
/*
* Remove a link from the text
* text: The wikitext of the whole page
* link: The link that will be removed
*/
const removeLink = function( text, link ) {
var anchor = link.anchor || '';
if ( link.type === 'sortnaam' ) {
var newContent = '{{sortnaam|' + link.first + '|' + link.last + '}}' + anchor;
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + linkEnd;
}
if ( link.type === 'ill' ) {
var newContent = link.title + anchor;
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + linkEnd;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + link.description + anchor + link.afterDescription + linkEnd;
};
/*
* Extract a link from a string in wiki format,
* starting from a given index. Return a link if one can be found,
* otherwise return null. The "link" includes "disambiguation needed"
* templates inmediately following the link proper
* text: Text from which the link will be extracted
* lastIndex: Index from which the search will start
*/
const extractLink = function( text, lastIndex ) {
// Zoek eerst naar gewone wikitlinks
titleRegex.lastIndex = lastIndex;
var wikiMatch = titleRegex.exec( text );
// Zoek naar sortnaam-templates (case-insensitief)
var sortnaamRegex = new RegExp('{{\\s*sortnaam\\s*\\|\\s*([^|]+?)\\s*\\|\\s*([^|]+?)\\s*(?:\\|\\s*([^|#}]*?)\\s*)?(#[^|}]*)?}}', 'gi');
sortnaamRegex.lastIndex = lastIndex;
var sortnaamMatch = sortnaamRegex.exec( text );
// Zoek naar ill-templates
var illRegex = new RegExp('{{\\s*ill\\s*\\|([\\s\\S]*?)}}', 'gi');
illRegex.lastIndex = lastIndex;
var illMatch = illRegex.exec( text );
var matchType = null;
var matchIndex = Infinity;
if ( wikiMatch !== null && wikiMatch.index >= lastIndex ) {
matchIndex = wikiMatch.index;
matchType = 'wiki';
}
if ( sortnaamMatch !== null && sortnaamMatch.index >= lastIndex && sortnaamMatch.index < matchIndex ) {
matchIndex = sortnaamMatch.index;
matchType = 'sortnaam';
}
if ( illMatch !== null && illMatch.index >= lastIndex && illMatch.index < matchIndex ) {
matchIndex = illMatch.index;
matchType = 'ill';
}
if ( matchType === 'wiki' ) {
var possiblyAmbiguous = true;
var hasDisamTemplate = false;
var fullMatchStr = wikiMatch[0];
var anchor = wikiMatch[2] ? wikiMatch[2] : '';
var end = wikiMatch.index + fullMatchStr.length;
var afterDescription = '';
var rest = text.substring( end );
var templateRegex = /^(\w*[.,:;?!)}\s]*){{\s*([^|{}]+?)\s*(?:\|[^{]*?)?}}/g;
var templateMatch = templateRegex.exec( rest );
if ( templateMatch !== null ) {
var templateTitle = getCanonicalTitle( templateMatch[2] );
if ( cfg.disamLinkTemplates?.includes( templateTitle ) ) {
end += templateMatch[0].length;
afterDescription = templateMatch[1].replace(/\s$/, '');
hasDisamTemplate = true;
} else if ( cfg.disamLinkIgnoreTemplates?.includes( templateTitle ) ) {
possiblyAmbiguous = false;
}
}
return {
type: 'wiki',
start: wikiMatch.index,
end: end,
possiblyAmbiguous: possiblyAmbiguous,
hasDisamTemplate: hasDisamTemplate,
title: wikiMatch[1],
description: wikiMatch[3] ? wikiMatch[3] : wikiMatch[1],
anchor: anchor,
afterDescription: afterDescription
};
} else if ( matchType === 'sortnaam' ) {
var first = sortnaamMatch[1].trim();
var last = sortnaamMatch[2].trim();
var explicitTarget = sortnaamMatch[3] ? sortnaamMatch[3].trim() : '';
var anchor = sortnaamMatch[4] ? sortnaamMatch[4].trim() : '';
var target = explicitTarget !== '' ? explicitTarget : ( first + ' ' + last );
return {
type: 'sortnaam',
start: sortnaamMatch.index,
end: sortnaamMatch.index + sortnaamMatch[0].length,
possiblyAmbiguous: true,
hasDisamTemplate: false,
title: target,
description: sortnaamMatch[0],
first: first,
last: last,
explicitTarget: explicitTarget,
anchor: anchor,
afterDescription: ''
};
} else if ( matchType === 'ill' ) {
var inner = illMatch[1];
var parts = inner.split('|');
var firstParam = parts[0].trim();
var anchorMatch = firstParam.match(/^(.*?)(#.*)$/);
var title = anchorMatch ? anchorMatch[1].trim() : firstParam;
var anchor = anchorMatch ? anchorMatch[2] : '';
var lang = parts[1] ? parts[1].trim() : '';
var other = parts[2] ? parts[2].trim() : '';
var restParts = parts.slice(3);
var rest = restParts.length > 0 ? '|' + restParts.join('|') : '';
return {
type: 'ill',
start: illMatch.index,
end: illMatch.index + illMatch[0].length,
possiblyAmbiguous: true,
hasDisamTemplate: false,
title: title,
description: title,
anchor: anchor,
lang: lang,
other: other,
rest: rest,
afterDescription: ''
};
}
return null;
};
/*
* Extract a link to one of a number of destination pages from a string
* ("text") in wiki format, starting from a given index ("lastIndex").
* "Disambiguation needed" templates are included as part of the links.
* text: Page in wiki format
* destinations: Array of page titles to look for
* lastIndex: Index from which the search will start
*/
const extractLinkToPage = function( text, destinations, lastIndex ) {
var link, title;
do {
link = extractLink( text, lastIndex );
if ( link !== null ) {
lastIndex = link.end;
title = getCanonicalTitle( link.title );
}
} while (
link !== null
&& ( !link.possiblyAmbiguous || !destinations?.includes( title ) )
);
return link;
};
/*
* Find the "target" page: either the one we are in or the "main" one found by extracting
* the title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const getTargetPage = function() {
var title = getTitle();
return forceSamePage ? title : removeDisam( title );
};
/*
* Get the page title, with the namespace prefix if any.
*/
const getTitle = function() {
return mw.config.get( 'wgPageName' ).replace(/_/g, ' ')
}
/*
* Extract a "main" title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const removeDisam = function( title ) {
var match = new RegExp( cfg.disamRegExp ).exec( title );
return match ? match[1] : title;
};
/*
* Check whether two page titles are the same
*/
const isSamePage = function( title1, title2 ) {
return getCanonicalTitle( title1 ) === getCanonicalTitle( title2 );
};
/*
* Return the 'canonical title' of a page
*/
const getCanonicalTitle = function( title ) {
try {
title = new mw.Title( title );
// Check that page name matches the current namespace
if ( title.getNamespaceId() !== mw.config.get( 'wgNamespaceNumber' ) ) {
return;
}
const fragment = title.getFragment();
return title.getPrefixedText() + ( fragment ? `#${ fragment }` : '' );
} catch ( ex ) {
// mw.Title seems to be buggy, and some valid titles are rejected
// FIXME: This may cause false negatives
}
};
/*
* Extract the context around a given link in a text string
*/
const extractContext = function( text, link ) {
var contextStart = link.start - cfg.radius;
var contextEnd = link.end + cfg.radius;
var contextPrev = text.substring( contextStart, link.start );
const ellipsis = '…';
if ( contextStart > 0 ) {
contextPrev = ellipsis + contextPrev;
}
var contextNext = text.substring( link.end, contextEnd );
if ( contextEnd < text.length ) {
contextNext = contextNext + ellipsis;
}
return [contextPrev, text.substring( link.start, link.end ), contextNext];
};
/*
* Extract the prefixed page name from a link
*/
const extractPageName = function( link ) {
var pageName = extractPageNameRaw( link );
if ( pageName ) {
return getCanonicalTitle( pageName );
}
return null;
};
/*
* Extract the page name from a link, as is
*/
const extractPageNameRaw = function( link ) {
// Ignore images and interwiki links
if ( link.hasClass( 'image' ) || link.hasClass( 'extiw' ) ) {
return null;
}
var href = link.attr( 'href' );
// Red links in old parser format
if ( link.hasClass( 'new' ) ) {
const oldRedLink = mw.util.getParamValue( 'title', href );
if ( oldRedLink !== null ) {
return oldRedLink;
}
}
// Check that external links are coming from this wiki
if ( href.indexOf( '//' ) === 0 || href.indexOf( 'https://' ) === 0 ) {
try {
const fullUrl = href.indexOf( '//' ) === 0 ? location.protocol + href : href;
let urlData = new URL( fullUrl );
if ( urlData.hostname !== location.hostname ) {
return null;
}
} catch( e ) {
return null;
}
}
var articlePathRegexp = new RegExp(
mw.config.get( 'wgArticlePath' ).replace( '$1', '([^?]*)' )
);
var result = articlePathRegexp.exec( href );
if ( Array.isArray( result ) && result.length > 1 ) {
return decodeURIComponent( result[ 1 ] );
}
return null;
};
/*
* Check whether this is a disambiguation page
*/
const isDisam = function() {
const categories = mw.config.get( 'wgCategories', [] );
return cfg.disamCategories.some( c => categories.includes( c ) );
};
const secondsToHHMMSS = function( totalSeconds ) {
var hhmmss = '';
var hours = Math.floor( totalSeconds / 3600 );
var minutes = Math.floor( totalSeconds % 3600 / 60 );
var seconds = Math.floor( totalSeconds % 3600 % 60 );
if ( hours >= 1 ) {
hhmmss = pad( hours, '0', 2 ) + ':';
}
hhmmss += pad( minutes, '0', 2 ) + ':' + pad( seconds, '0', 2 );
return hhmmss;
};
const pad = function( str, z, width ) {
str = str.toString();
if ( str.length >= width ) {
return str;
} else {
return new Array( width - str.length + 1 ).join( z ) + str;
}
}
/*
* Create a new button
* text: Text that will be displayed on the button
* onClick: Function that will be called when the button is clicked
*/
const createButton = function( text, onClick ) {
var button = $( '<button>', { 'type': 'button' } );
button.text( text );
button.addClass( 'disamassist-button' ).click( onClick );
return button;
};
/*
* Given a page title and an array of possible redirects {from, to} ("canonical titles"), find the page
* at the end of the redirect chain, if there is one. Otherwise, return the page title that was passed
*/
const resolveRedirect = function( pageTitle, possibleRedirects ) {
var appliedRedirect = true;
var visitedPages = {};
var currentPage = getCanonicalTitle( pageTitle );
while ( appliedRedirect ) {
appliedRedirect = false;
for ( var ii = 0; ii < possibleRedirects.length; ii++ ) {
if ( possibleRedirects[ii].from === currentPage ) {
if ( visitedPages[possibleRedirects[ii].to] ) {
// Redirect chain detected
return pageTitle;
}
visitedPages[currentPage] = true;
appliedRedirect = true;
currentPage = possibleRedirects[ii].to;
}
}
}
// No redirect rules applied for an iteration of the outer loop:
// no more redirects. We are done
return currentPage;
};
/*
* Fetch the incoming links to a page. Returns a jQuery promise
* (success - array of titles of pages that contain links to the target page and
* array of "canonical titles" of possible destinations of the backlinks (either
* the target page or redirects to the target page), failure - error description)
* page: Target page
*/
const getBacklinks = function( page ) {
var dfd = new $.Deferred();
api.get( {
'action': 'query',
'list': 'backlinks',
'bltitle': page,
'blredirect': true,
'bllimit': cfg.backlinkLimit,
'blnamespace': cfg.targetNamespaces,
} ).done( function( data ) {
// There might be duplicate entries in some corner cases. We don't care,
// since we are going to check later, anyway
var backlinks = [];
var linkTitles = [getCanonicalTitle( page )];
$.each( data.query.backlinks, function() {
backlinks.push( this.title );
if ( this.redirlinks ) {
linkTitles.push( this.title );
$.each( this.redirlinks, function() {
backlinks.push( this.title );
} );
}
} );
dfd.resolve( backlinks, linkTitles );
} ).fail( function( code, data ) {
dfd.reject( txt.getBacklinksError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download a list of redirects for some pages. Returns a jQuery callback (success -
* array of redirects ({from, to}), failure - error description )
* pageTitles: Array of page titles
*/
const fetchRedirects = function( pageTitles ) {
var dfd = new $.Deferred();
var currentTitles = pageTitles.slice( 0, cfg.queryTitleLimit );
var restTitles = pageTitles.slice( cfg.queryTitleLimit );
api.get( {
action: 'query',
titles: currentTitles,
redirects: true,
} ).done( function( data ) {
var theseRedirects = data.query.redirects ? data.query.redirects : [];
if ( restTitles.length !== 0 ) {
fetchRedirects( restTitles ).done( function( redirects ) {
dfd.resolve( theseRedirects.concat( redirects ) );
} ).fail( function( description ) {
dfd.reject( description );
} );
} else {
dfd.resolve( theseRedirects );
}
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRedirectsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download the list of user rights for the current user. Returns a
* jQuery promise (success - array of right names, error - error description)
*/
const fetchRights = function() {
var dfd = $.Deferred();
api.get( {
action: 'query',
meta: 'userinfo',
uiprop: 'rights',
} ).done( function( data ) {
dfd.resolve( data.query.userinfo.rights );
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRightsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Load the raw page text for a given title. Returns a jQuery promise (success - page
* content, failure - error description)
* pageTitle: Title of the page
*/
const loadPage = function( pageTitle ) {
var dfd = new $.Deferred();
api.get( {
action: 'query',
titles: pageTitle,
prop: 'revisions',
rvprop: 'timestamp|content',
meta: 'tokens',
type: 'csrf',
} ).done( function( data ) {
var pages = data.query.pages;
for ( var key in pages ) {
if ( pages.hasOwnProperty( key ) ) {
break;
}
}
var rawPage = data.query.pages[key];
var page = {};
page.missing = rawPage.missing !== undefined;
if ( rawPage.revisions ) {
page.content = rawPage.revisions[0]['*'];
// Assume anything with # at the start is a redirect
if ( page.content.match( /^#[^\s]*?\s*\[\[/ ) ) {
page.redirect = true;
}
page.baseTimeStamp = rawPage.revisions[0].timestamp;
} else {
page.content = '';
page.baseTimeStamp = null;
}
page.startTimeStamp = rawPage.starttimestamp;
page.editToken = data.query.tokens.csrftoken;
dfd.resolve( page );
} ).fail( function( code, data ) {
dfd.reject( txt.loadPageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
/*
* Register changes to a page, to be saved later. Returns a jQuery promise
* (success - no params, failure - error description). Takes the same parameters
* as savePage
*/
const saveWithCooldown = function() {
var deferred = new $.Deferred();
pendingSaves.push( {args: arguments, dfd: deferred} );
if ( !runningSaves ) {
checkAndSave();
}
return deferred.promise();
};
/*
* Save the first set of changes in the list of pending changes, providing that
* enough time has passed since the last edit
*/
const checkAndSave = function() {
if ( pendingSaves.length === 0 ) {
runningSaves = false;
return;
}
runningSaves = true;
var millisSinceLast = new Date().getTime() - lastEditMillis;
if ( millisSinceLast < cfg.editCooldown * 1000 ) {
setTimeout( checkAndSave, cfg.editCooldown * 1000 - millisSinceLast );
} else {
// The last edit started at least cfg.editCooldown seconds ago
var save = pendingSaves.shift();
savePage.apply( this, save.args ).done( function() {
checkAndSave();
save.dfd.resolve();
} ).fail( function( description ) {
checkAndSave();
save.dfd.reject( description );
} );
// We'll use the time since the last edit started
lastEditMillis = new Date().getTime();
}
};
/*
* Save the changes made to a page. Returns a jQuery promise (success - no params,
* failure - error description)
* pageTitle: Title of the page
* page: Page data
* summary: Summary of the changes made to the page
* minorEdit: Whether to mark the edit as 'minor'
* botEdit: Whether to mark the edit as 'bot'
*/
const savePage = function( pageTitle, page, summary, minorEdit, botEdit ) {
var dfd = new $.Deferred();
api.post( {
action: 'edit',
title: pageTitle,
token: page.editToken,
text: page.content,
basetimestamp: page.baseTimeStamp,
starttimestamp: page.startTimeStamp,
summary: summary,
watchlist: cfg.watch ? cfg.watch : 'nochange',
assertuser: mw.config.get( 'wgUserName' ),
minor: minorEdit,
bot: botEdit,
} ).done( function() {
dfd.resolve();
} ).fail( function( code, data ) {
dfd.reject( txt.savePageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
install();
} )();
// </nowiki>
ikunl9w7yr879wveihb85mnn8jysr7b
781235
781229
2026-10-27T17:11:40Z
Rots61
44293
781235
javascript
text/javascript
/*
* DisamAssist: a tool for repairing links from articles to disambiguation pages.
*/
// <nowiki>
// @ts-nocheck
( () => {
var cfg = {};
var txt = {};
var ui;
var links, pageChanges;
var currentPageTitle, currentPageParameters, currentLink;
var possibleBacklinkDestinations;
var titleRegex;
var forceSamePage = false;
var running = false;
var choosing = false;
var canMarkIntentionalLinks = false;
var displayedPages = {};
var editCount = 0;
var editLimit;
var pendingSaves = [];
var pendingEditBox = null;
var pendingEditBoxText;
var lastEditMillis = 0;
var runningSaves = false;
var api;
const dependencies = [
'mediawiki.api',
'mediawiki.confirmCloseWindow',
'mediawiki.Title',
'mediawiki.util',
];
/**
* Add an item in Tools menu
* @param {string} text
* @param {string} id
* @param {(e: MouseEvent) => void} callback
*/
const disamPortletLink = ( text, id, callback ) => {
const portlet = mw.util.addPortletLink(
'p-tb',
cfg.helpPage ? mw.util.getUrl( cfg.helpPage ) : '#',
text,
id
);
const link = portlet?.querySelector( 'a' );
link?.addEventListener( 'click', callback );
addButtonEvents( link );
}
/*
* Entry point. Check whether we are in a disambiguation page. If so, add a link to start the tool
*/
const install = function() {
cfg = window.DisamAssist.cfg;
txt = window.DisamAssist.txt;
if ( mw.config.get( 'wgAction' ) === 'view' && isDisam() ) {
mw.loader.using( dependencies, () => {
api = new mw.Api();
mw.loader.load( 'https://ru.wikipedia.org/w/index.php?title=Участник:Stjn/DisamAssist.css&action=raw&ctype=text/css', 'text/css' );
// This is a " (disambiguation)" page
if ( new RegExp( cfg.disamRegExp ).exec( getTitle() ) ) {
disamPortletLink( txt.startMain, 't-disamAssist-main', startMain );
disamPortletLink( txt.startSame, 't-disamAssist-same', startSame );
} else {
disamPortletLink( txt.start, 't-disamAssist-page', start );
}
} );
}
};
/*
* Start the tool. Display the UI and begin looking for links to fix
*/
const start = function( e ) {
e?.preventDefault();
if ( !running ) {
console.log(Date.now())
running = true;
links = [];
pageChanges = [];
displayedPages = {};
ensureDABExists().then( function( canMark ) {
canMarkIntentionalLinks = canMark;
createUI();
addUnloadConfirm();
markDisamOptions();
checkEditLimit().then( function() {
togglePendingEditBox( false );
doPage();
} );
} );
}
};
/*
* Start DisamAssist. Disambiguate incoming links to the current page, regardless
* of the title.
*/
const startSame = function( e ) {
e.preventDefault();
forceSamePage = true;
start( e );
};
/*
* Start DisamAssist. If the page title ends with " (disambiguation)", disambiguate
* links to the primary topic article. Otherwise, disambiguate links to the current
* page.
*/
const startMain = function( e ) {
e.preventDefault();
forceSamePage = false;
start( e );
};
/*
* Create and show the user interface.
*/
const createUI = function() {
ui = {
display: $( '<div>' ).addClass( 'disamassist-box disamassist-mainbox' ),
finishedMessage: $( '<div>' ).text( txt.noMoreLinks ).hide(),
pageTitleLine: $( '<span>' ).addClass( 'disamassist-pagetitleline' ),
pendingEditCounter: $( '<div>').addClass( 'disamassist-editcounter' ),
context: $( '<pre>' ).addClass( 'disamassist-context' ),
undoButton: createButton( txt.undo, undo ),
omitButton: createButton( txt.omit, omit ),
endButton: createButton( txt.close, saveAndEnd ),
refreshButton: createButton( txt.refresh, refresh ),
titleAsTextButton: createButton( txt.titleAsText, chooseTitleFromPrompt ),
intentionalLinkButton: canMarkIntentionalLinks ? createButton( txt.intentionalLink, chooseIntentionalLink ) : null,
disamNeededButton: cfg.disamNeededText ? createButton( txt.disamNeeded, chooseDisamNeeded ) : null,
removeLinkButton: createButton( txt.removeLink, chooseLinkRemoval )
};
var top = $( '<div>' ).addClass( 'disamassist-top' )
.append( [ui.pageTitleLine, ui.pendingEditCounter] );
var leftButtons = $( '<div>' ).addClass( 'disamassist-leftbuttons' )
.append( [ui.titleAsTextButton, ui.removeLinkButton, ui.intentionalLinkButton, ui.disamNeededButton, ui.omitButton] );
var rightButtons = $( '<div>' ).addClass( 'disamassist-rightbuttons' )
.append( [ui.undoButton, ui.refreshButton, ui.endButton] );
var allButtons = $( '<div>' ).addClass( 'disamassist-allbuttons' )
.append( [leftButtons, rightButtons] );
ui.display.append( [top, ui.finishedMessage, ui.context, allButtons] );
updateEditCounter();
toggleActionButtons( false );
// Insert the UI in the page
$( '#mw-content-text' ).before( ui.display );
ui.display.hide().fadeIn();
};
/**
* Add button role and related keyboard event
* @param {Element} el
*/
const addButtonEvents = ( el ) => {
el?.setAttribute( 'role', 'button' );
el?.addEventListener( 'keydown', function( e ) {
if ( [ 'Enter', 'Space' ].includes( e.code ) ) {
e.preventDefault();
this.click();
}
} );
}
/*
* If there are pending changes, show a confirm dialog before closing
*/
const addUnloadConfirm = function() {
mw.confirmCloseWindow( {
test: () => {
if ( running && checkActualChanges() ) {
return txt.pending;
} else if ( editCount !== 0 ) {
return txt.editInProgress;
}
}
} );
};
/*
* Mark the disambiguation options as such
*/
const markDisamOptions = function() {
var optionPageTitles = [];
var optionMarkers = [];
getDisamOptions().each( function() {
var link = $( this );
var title = extractPageName( link );
const optionMarker = document.createElement( 'a' );
optionMarker.classList.add( 'disamassist-optionmarker' );
optionMarker.href = link.attr( 'href' );
optionMarker.setAttribute( 'data-title', title );
optionMarker.textContent = txt.optionMarker;
optionMarker.addEventListener( 'click', ( e ) => {
e.preventDefault();
chooseReplacement( title );
} );
addButtonEvents( optionMarker );
link.after( optionMarker );
optionMarkers.push( optionMarker );
optionPageTitles.push( title );
} );
// Now check the disambiguation options and display a different message for those that are
// actually the same as the target page where the links go, as choosing those options doesn't really
// accomplish anything (except bypassing redirects, which might be useful in some cases)
var targetPage = getTargetPage();
fetchRedirects( optionPageTitles.concat( targetPage ) ).done( function( redirects ) {
var endTargetPage = resolveRedirect( targetPage, redirects );
for ( var ii = 0; ii < optionPageTitles.length; ii++ ) {
var endOptionTitle = resolveRedirect( optionPageTitles[ii], redirects );
if ( isSamePage( optionPageTitles[ii], targetPage ) ) {
$( optionMarkers[ii] ).text( txt.targetOptionMarker ).addClass( 'disamassist-curroptionmarker');
} else if ( isSamePage( endOptionTitle, endTargetPage ) ) {
$( optionMarkers[ii] ).text( txt.redirectOptionMarker ).addClass( 'disamassist-curroptionmarker');
}
}
} ).fail( error );
};
/*
* Check whether intentional links to disambiguation pages can be explicitly marked
* as such in this wiki. If so, ensure that a "Foo (disambiguation)" page exists.
* Returns a jQuery promise
*/
const ensureDABExists = function() {
var dfd = new $.Deferred();
var title = getTitle();
// That concept doesn't exist in this wiki.
if ( !cfg.intentionalLinkOption ) {
dfd.resolve( false );
// "Foo (disambiguation)" exists: it's the current page.
} else if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
dfd.resolve( true );
} else {
var disamTitle = cfg.disamFormat.replace( '$1', title );
loadPage( disamTitle ).done( function( page ) {
// "Foo (disambiguation)" doesn't exist.
if ( page.missing ) {
// We try to create it
page.content = cfg.redirectToDisam.replace( '$1', title );
var summary = txt.redirectSummary.replace( '$1', title );
savePage( disamTitle, page, summary, false, true ).done( function() {
dfd.resolve( true );
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
// It does exist
} else {
dfd.resolve( true );
}
} ).fail( function( description ) {
error( description );
dfd.resolve( false );
} );
}
return dfd.promise();
};
/*
* Check whether the edit cooldown applies and sets editLimit accordingly.
* Returns a jQuery promise
*/
const checkEditLimit = function() {
var dfd = new $.Deferred();
if ( cfg.editCooldown <= 0 ) {
editLimit = false;
dfd.resolve();
} else {
fetchRights().done( function( rights ) {
editLimit = !rights?.includes( 'bot' );
} ).fail( function( description ) {
error( description );
editLimit = true;
} ).always( function() {
dfd.resolve();
} );
}
return dfd.promise();
};
/*
* Find and ask the user to fix all the incoming links to the disambiguation ("target")
* page from a single "origin" page
*/
const doPage = function() {
if ( pageChanges.length > cfg.historySize ) {
applyChange( pageChanges.shift() );
}
if ( links.length === 0 ) {
var targetPage = getTargetPage();
getBacklinks( targetPage ).done( function( backlinks, pageTitles ) {
var pending = {};
$.each( pendingSaves, function() {
pending[this[0]] = true;
} );
possibleBacklinkDestinations = $.grep( pageTitles, function( t, ii) {
if ( t == targetPage ) {
return true;
}
return removeDisam(t) != targetPage;
} );
titleRegex = new RegExp('\\[\\[([ _]*?(?:' + possibleBacklinkDestinations.join('|').replaceAll('(', '\\(').replaceAll(')','\\)').replaceAll(' ', '[ _]') + ')[ _]*?)(#.*?)?(?:\\|(.*?))?\\]\\]', 'gi');
// Only incoming links from pages we haven't seen yet and we aren't currently
// saving (displayedPages is reset when the tool is closed and opened again,
// while the list of pending changes isn't; if the edit cooldown is disabled,
// it will be empty)
links = $.grep( backlinks, function( el, ii ) {
return !displayedPages[el] && !pending[el];
} );
if ( links.length === 0 ) {
updateContext();
} else {
doPage();
}
} ).fail( error );
} else {
currentPageTitle = links.shift();
displayedPages[currentPageTitle] = true;
toggleActionButtons( false );
loadPage( currentPageTitle ).done( function( data ) {
currentPageParameters = data;
currentLink = null;
doLink();
} ).fail( error );
}
};
/*
* Find and ask the user to fix a single incoming link to the disambiguation ("target")
* page
*/
const doLink = function() {
if (!currentPageParameters.redirect) {
var startIndex = currentLink ? currentLink.end : 0;
currentLink = extractLinkToPage( currentPageParameters.content,
possibleBacklinkDestinations, startIndex );
if ( currentLink ) {
updateContext();
} else {
doPage();
}
} else {
doPage();
}
};
/*
* Replace the target of a link with a different one
* pageTitle: New link target
* extra: Additional text after the link (optional)
* summary: Change summary (optional)
*/
const chooseReplacement = function( pageTitle, extra, summary ) {
if ( choosing ) {
choosing = false;
if ( !summary ) {
if ( pageTitle ) {
summary = txt.summaryChanged.replace( '$1', pageTitle );
} else {
summary = txt.summaryOmitted;
}
}
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
if ( pageTitle && ( pageTitle !== getTargetPage() || extra ) ) {
if ( currentPageParameters.redirect ) {
currentLink.description = pageTitle;
}
currentPageParameters.content =
replaceLink( currentPageParameters.content, pageTitle, currentLink, extra || '' );
}
// Als er een daadwerkelijke vervanging is gedaan (pageTitle is niet null),
// resetten we currentLink naar null zodat de zoektocht vanaf 0 herbegimt.
// De reeds aangepaste link wordt genegeerd, waardoor de volgende dubbele link wél wordt gevonden.
if ( pageTitle !== null ) {
currentLink = null;
}
doLink();
}
};
/*
* Replace the link with an explicit link to the disambiguation page
*/
const chooseIntentionalLink = function() {
const targetPage = getTargetPage();
let title = cfg.disamFormat.replace( '$1', targetPage );
if ( new RegExp( cfg.disamRegExp ).exec( title ) ) {
title = targetPage;
}
chooseReplacement( title, '', txt.summaryIntentional );
};
/*
* Prompt for an alternative link target and use it as a replacement
*/
const chooseTitleFromPrompt = function() {
var title = prompt( txt.titleAsTextPrompt );
if ( title !== null ) {
chooseReplacement( title );
}
};
/*
* Remove the current link, leaving the text unchanged
*/
const chooseLinkRemoval = function() {
if ( choosing ) {
var summary = txt.summaryRemoved;
addChange( currentPageTitle, currentPageParameters, currentPageParameters.content, currentLink, summary );
currentPageParameters.content = removeLink( currentPageParameters.content, currentLink );
currentLink = null;
doLink();
}
};
/*
* Add a "disambiguation needed" template after the link
*/
const chooseDisamNeeded = function() {
chooseReplacement( currentLink.title, cfg.disamNeededText, txt.summaryHelpNeeded );
};
/*
* Undo the last change
*/
const undo = function() {
if ( pageChanges.length !== 0 ) {
var lastPage = pageChanges[pageChanges.length - 1];
if ( currentPageTitle !== lastPage.title ) {
links.unshift( currentPageTitle );
currentPageTitle = lastPage.title;
}
currentPageParameters = lastPage.page;
currentPageParameters.content = lastPage.contentBefore.pop();
currentLink = lastPage.links.pop();
lastPage.summary.pop();
if ( lastPage.contentBefore.length === 0 ) {
pageChanges.pop();
}
updateContext();
}
};
/*
* Omit the current link without making a change
*/
const omit = function() {
chooseReplacement( null );
};
/*
* Save all the pending changes and restart the tool.
*/
const refresh = function() {
saveAndEnd();
start();
};
/*
* Enable or disable the buttons that can perform actions on a page or change the current link.
* enabled: Whether to enable or disable the buttons
*/
const toggleActionButtons = function( enabled ) {
var affectedButtons = [ui.omitButton, ui.titleAsTextButton, ui.removeLinkButton,
ui.intentionalLinkButton, ui.disamNeededButton, ui.undoButton];
$.each( affectedButtons, function( ii, button ) {
button?.prop( 'disabled', !enabled );
} );
};
/*
* Show or hide the 'no more links' message
* show: Whether to show or hide the message
*/
const toggleFinishedMessage = function( show ) {
toggleActionButtons( !show );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.finishedMessage.toggle( show );
ui.pageTitleLine.toggle( !show );
ui.context.toggle( !show );
if ( show ) console.log(Date.now())
};
const togglePendingEditBox = function( show ) {
if ( pendingEditBox === null ) {
pendingEditBox = $( '<div>' ).addClass( 'disamassist-box disamassist-pendingeditbox' );
pendingEditBoxText = $( '<div>' );
pendingEditBox.append( pendingEditBoxText ).hide();
if ( editLimit ) {
pendingEditBox.append( $( '<div>' ).text( txt.pendingEditBoxLimited )
.addClass( 'disamassist-subtitle' ) );
}
$( '#mw-content-text' ).before( pendingEditBox );
updateEditCounter();
}
if ( show ) {
pendingEditBox.fadeIn();
} else {
pendingEditBox.fadeOut();
}
};
const notifyCompletion = function() {
var oldTitle = document.title;
document.title = txt.notifyCharacter + document.title;
$( document.body ).one( 'mousemove', function() {
document.title = oldTitle;
} );
};
/*
* Update the displayed information to match the current link
* or lack thereof
*/
const updateContext = function() {
updateEditCounter();
if ( !currentLink ) {
toggleFinishedMessage( true );
} else {
ui.pageTitleLine.html( txt.pageTitleLine.replace( '$1',
mw.util.getUrl( currentPageTitle, {redirect: 'no'} ) ).replace( '$2', mw.html.escape( currentPageTitle ) ) );
var context = extractContext( currentPageParameters.content, currentLink );
ui.context.empty()
.append( $( '<span>' ).text( context[0] ) )
.append( $( '<span>' ).text( context[1] ).addClass( 'disamassist-inclink' ) )
.append( $( '<span>' ).text( context[2] ) );
toggleFinishedMessage( false );
ui.undoButton?.prop( 'disabled', pageChanges.length === 0 );
ui.removeLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.intentionalLinkButton?.prop( 'disabled', currentPageParameters.redirect );
ui.disamNeededButton?.prop( 'disabled', currentPageParameters.redirect || currentLink.hasDisamTemplate );
choosing = true;
}
};
/*
* Update the count of pending changes
*/
const updateEditCounter = function() {
if ( ui.pendingEditCounter ) {
ui.pendingEditCounter.text( txt.pendingEditCounter.replace( '$1', editCount )
.replace( '$2', countActuallyChangedFullyCheckedPages() ) );
}
if ( pendingEditBox ) {
if ( editCount === 0 && !running ) {
togglePendingEditBox( false );
notifyCompletion();
}
var textContent = editCount;
if ( editLimit ) {
textContent = txt.pendingEditBoxTimeEstimation.replace( '$1', editCount )
.replace( '$2', secondsToHHMMSS( cfg.editCooldown * editCount ) );
}
pendingEditBoxText.text( txt.pendingEditBox.replace( '$1', textContent ) );
}
};
/*
* Apply the changes made to an "origin" page
* pageChange: Change that will be saved
*/
const applyChange = function( pageChange ) {
if ( pageChange.page.content !== pageChange.contentBefore[0] ) {
editCount++;
// Filter duplicates and join
var changeSummaries = pageChange.summary
.filter( ( val, i, arr ) => val && arr.indexOf( val ) === i )
.join( txt.summarySeparator );
var summary = txt.summary.replace( '$1', getTargetPage() ).replace( '$2', changeSummaries );
var save = editLimit ? saveWithCooldown : savePage;
save( pageChange.title, pageChange.page, summary, true, true ).always( function() {
if ( editCount > 0 ) {
editCount--;
}
updateEditCounter();
} ).fail( error );
updateEditCounter();
}
};
/*
* Save all the pending changes
*/
const applyAllChanges = function() {
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
applyChange( pageChanges[ii] );
}
pageChanges = [];
};
/*
* Record a new pending change
* pageTitle: Title of the page
* page: Content of the page
* oldContent: Content of the page before the change
* link: Link that has been changed
* summary: Change summary
*/
const addChange = function( pageTitle, page, oldContent, link, summary ) {
if ( ( pageChanges.length === 0 ) || ( pageChanges[pageChanges.length - 1].title !== pageTitle ) ) {
pageChanges.push( {
title: pageTitle,
page: page,
contentBefore: [],
links: [],
summary: []
} );
}
var lastPageChange = pageChanges[pageChanges.length - 1];
lastPageChange.contentBefore.push( oldContent );
lastPageChange.links.push( link );
lastPageChange.summary.push( summary );
};
/*
* Check whether actual changes are stored in the history array
*/
const checkActualChanges = function() {
return countActualChanges() !== 0;
};
/*
* Return the number of entries in the history array that represent actual changes
*/
const countActualChanges = function() {
var changeCount = 0;
for ( var ii = 0; ii < pageChanges.length; ii++ ) {
if ( pageChanges[ii].page.content !== pageChanges[ii].contentBefore[0] ) {
changeCount++;
}
}
return changeCount;
};
/*
* Return the number of changed pages in the history array, ignoring the last entry
* if we aren't done with that page yet
*/
const countActuallyChangedFullyCheckedPages = function() {
var changeCount = countActualChanges();
if ( pageChanges.length !== 0 ) {
var lastChange = pageChanges[pageChanges.length - 1];
if ( lastChange.title === currentPageTitle && currentLink !== null
&& lastChange.page.content !== lastChange.contentBefore[0] ) {
changeCount--;
}
}
return changeCount;
};
/*
* Find the links to disambiguation options in a disambiguation page
*/
const getDisamOptions = function() {
return $( '#mw-content-text a' ).filter( function() {
return !!extractPageName( $( this ) );
} );
};
/*
* Save all the pending changes and close the tool
*/
const saveAndEnd = function() {
applyAllChanges();
end();
};
/*
* Close the tool
*/
const end = function() {
var currentToolUI = ui.display;
choosing = false;
running = false;
$( '.disamassist-optionmarker' ).remove();
currentToolUI.fadeOut( { complete: function() {
currentToolUI.remove();
if ( editCount !== 0 ) {
togglePendingEditBox( true );
}
} } );
};
/*
* Display an error message
*/
const error = function( errorDescription ) {
var errorBox = $( '<div>' ).addClass( 'disamassist-box disamassist-errorbox' );
errorBox.text( txt.error.replace( '$1', errorDescription ) );
errorBox.append( createButton( txt.dismissError, function() {
errorBox.fadeOut();
} ).addClass( 'disamassist-errorbutton' ) );
var uiIsInPlace = ui && $.contains( document.documentElement, ui.display[0] );
var nextElement = uiIsInPlace ? ui.display : $( '#mw-content-text' );
nextElement.before( errorBox );
errorBox.hide().fadeIn();
}
/*
* Change a link so that it points to the title
* text: The wikitext of the whole page
* title: The new destination of the link
* link: The link that will be modified
* extra: Text that will be added after the link (optional)
*/
const replaceLink = function( text, title, link, extra ) {
var anchor = link.anchor || '';
var newContent;
if ( link.type === 'sortnaam' ) {
newContent = '{{sortnaam|' + link.first + '|' + link.last + '|' + title + anchor + '}}';
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + ( extra || '' ) + linkEnd;
}
if ( link.type === 'ill' ) {
newContent = '{{ill|' + title + anchor + '|' + link.lang + '|' + link.other + ( link.rest || '' ) + '}}';
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + ( extra || '' ) + linkEnd;
}
if ( isSamePage( title, link.description ) ) {
// [[B|A]] should be replaced with [[A]] rather than [[A|A]]
newContent = link.description + anchor;
} else {
newContent = title + anchor + '|' + link.description;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + '[[' + newContent + ']]' + link.afterDescription + ( extra || '' ) + linkEnd;
};
/*
* Remove a link from the text
* text: The wikitext of the whole page
* link: The link that will be removed
*/
const removeLink = function( text, link ) {
var anchor = link.anchor || '';
if ( link.type === 'sortnaam' ) {
var newContent = '{{sortnaam|' + link.first + '|' + link.last + '}}' + anchor;
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + linkEnd;
}
if ( link.type === 'ill' ) {
var newContent = link.title + anchor;
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + newContent + link.afterDescription + linkEnd;
}
var linkStart = text.substring( 0, link.start );
var linkEnd = text.substring( link.end );
return linkStart + link.description + anchor + link.afterDescription + linkEnd;
};
/*
* Extract a link from a string in wiki format,
* starting from a given index. Return a link if one can be found,
* otherwise return null. The "link" includes "disambiguation needed"
* templates inmediately following the link proper
* text: Text from which the link will be extracted
* lastIndex: Index from which the search will start
*/
const extractLink = function( text, lastIndex ) {
// Zoek eerst naar gewone wikitlinks
titleRegex.lastIndex = lastIndex;
var wikiMatch = titleRegex.exec( text );
// Zoek naar sortnaam-templates (case-insensitief)
var sortnaamRegex = new RegExp('{{\\s*sortnaam\\s*\\|\\s*([^|]+?)\\s*\\|\\s*([^|]+?)\\s*(?:\\|\\s*([^|#}]*?)\\s*)?(#[^|}]*)?}}', 'gi');
sortnaamRegex.lastIndex = lastIndex;
var sortnaamMatch = sortnaamRegex.exec( text );
// Zoek naar ill-templates
var illRegex = new RegExp('{{\\s*ill\\s*\\|([\\s\\S]*?)}}', 'gi');
illRegex.lastIndex = lastIndex;
var illMatch = illRegex.exec( text );
var matchType = null;
var matchIndex = Infinity;
if ( wikiMatch !== null && wikiMatch.index >= lastIndex ) {
matchIndex = wikiMatch.index;
matchType = 'wiki';
}
if ( sortnaamMatch !== null && sortnaamMatch.index >= lastIndex && sortnaamMatch.index < matchIndex ) {
matchIndex = sortnaamMatch.index;
matchType = 'sortnaam';
}
if ( illMatch !== null && illMatch.index >= lastIndex && illMatch.index < matchIndex ) {
matchIndex = illMatch.index;
matchType = 'ill';
}
if ( matchType === 'wiki' ) {
var possiblyAmbiguous = true;
var hasDisamTemplate = false;
var fullMatchStr = wikiMatch[0];
var anchor = wikiMatch[2] ? wikiMatch[2] : '';
var end = wikiMatch.index + fullMatchStr.length;
var afterDescription = '';
var rest = text.substring( end );
var templateRegex = /^(\w*[.,:;?!)}\s]*){{\s*([^|{}]+?)\s*(?:\|[^{]*?)?}}/g;
var templateMatch = templateRegex.exec( rest );
if ( templateMatch !== null ) {
var templateTitle = getCanonicalTitle( templateMatch[2] );
if ( cfg.disamLinkTemplates?.includes( templateTitle ) ) {
end += templateMatch[0].length;
afterDescription = templateMatch[1].replace(/\s$/, '');
hasDisamTemplate = true;
} else if ( cfg.disamLinkIgnoreTemplates?.includes( templateTitle ) ) {
possiblyAmbiguous = false;
}
}
return {
type: 'wiki',
start: wikiMatch.index,
end: end,
possiblyAmbiguous: possiblyAmbiguous,
hasDisamTemplate: hasDisamTemplate,
title: wikiMatch[1],
description: wikiMatch[3] ? wikiMatch[3] : wikiMatch[1],
anchor: anchor,
afterDescription: afterDescription
};
} else if ( matchType === 'sortnaam' ) {
var first = sortnaamMatch[1].trim();
var last = sortnaamMatch[2].trim();
var explicitTarget = sortnaamMatch[3] ? sortnaamMatch[3].trim() : '';
var anchor = sortnaamMatch[4] ? sortnaamMatch[4].trim() : '';
var target = explicitTarget !== '' ? explicitTarget : ( first + ' ' + last );
return {
type: 'sortnaam',
start: sortnaamMatch.index,
end: sortnaamMatch.index + sortnaamMatch[0].length,
possiblyAmbiguous: true,
hasDisamTemplate: false,
title: target,
description: sortnaamMatch[0],
first: first,
last: last,
explicitTarget: explicitTarget,
anchor: anchor,
afterDescription: ''
};
} else if ( matchType === 'ill' ) {
var inner = illMatch[1];
var parts = inner.split('|');
var firstParam = parts[0].trim();
var anchorMatch = firstParam.match(/^(.*?)(#.*)$/);
var title = anchorMatch ? anchorMatch[1].trim() : firstParam;
var anchor = anchorMatch ? anchorMatch[2] : '';
var lang = parts[1] ? parts[1].trim() : '';
var other = parts[2] ? parts[2].trim() : '';
var restParts = parts.slice(3);
var rest = restParts.length > 0 ? '|' + restParts.join('|') : '';
return {
type: 'ill',
start: illMatch.index,
end: illMatch.index + illMatch[0].length,
possiblyAmbiguous: true,
hasDisamTemplate: false,
title: title,
description: title,
anchor: anchor,
lang: lang,
other: other,
rest: rest,
afterDescription: ''
};
}
return null;
};
/*
* Extract a link to one of a number of destination pages from a string
* ("text") in wiki format, starting from a given index ("lastIndex").
* "Disambiguation needed" templates are included as part of the links.
* text: Page in wiki format
* destinations: Array of page titles to look for
* lastIndex: Index from which the search will start
*/
const extractLinkToPage = function( text, destinations, lastIndex ) {
var link, title;
do {
link = extractLink( text, lastIndex );
if ( link !== null ) {
lastIndex = link.end;
title = getCanonicalTitle( link.title );
}
} while (
link !== null
&& ( !link.possiblyAmbiguous || !destinations?.includes( title ) )
);
return link;
};
/*
* Find the "target" page: either the one we are in or the "main" one found by extracting
* the title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const getTargetPage = function() {
var title = getTitle();
return forceSamePage ? title : removeDisam( title );
};
/*
* Get the page title, with the namespace prefix if any.
*/
const getTitle = function() {
return mw.config.get( 'wgPageName' ).replace(/_/g, ' ')
}
/*
* Extract a "main" title from ".* (disambiguation)" or whatever the appropiate local format is
*/
const removeDisam = function( title ) {
var match = new RegExp( cfg.disamRegExp ).exec( title );
return match ? match[1] : title;
};
/*
* Check whether two page titles are the same
*/
const isSamePage = function( title1, title2 ) {
return getCanonicalTitle( title1 ) === getCanonicalTitle( title2 );
};
/*
* Return the 'canonical title' of a page
*/
const getCanonicalTitle = function( title ) {
try {
title = new mw.Title( title );
// Check that page name matches the current namespace
if ( title.getNamespaceId() !== mw.config.get( 'wgNamespaceNumber' ) ) {
return;
}
const fragment = title.getFragment();
return title.getPrefixedText() + ( fragment ? `#${ fragment }` : '' );
} catch ( ex ) {
// mw.Title seems to be buggy, and some valid titles are rejected
// FIXME: This may cause false negatives
}
};
/*
* Extract the context around a given link in a text string
*/
const extractContext = function( text, link ) {
var contextStart = link.start - cfg.radius;
var contextEnd = link.end + cfg.radius;
var contextPrev = text.substring( contextStart, link.start );
const ellipsis = '…';
if ( contextStart > 0 ) {
contextPrev = ellipsis + contextPrev;
}
var contextNext = text.substring( link.end, contextEnd );
if ( contextEnd < text.length ) {
contextNext = contextNext + ellipsis;
}
return [contextPrev, text.substring( link.start, link.end ), contextNext];
};
/*
* Extract the prefixed page name from a link
*/
const extractPageName = function( link ) {
var pageName = extractPageNameRaw( link );
if ( pageName ) {
return getCanonicalTitle( pageName );
}
return null;
};
/*
* Extract the page name from a link, as is
*/
const extractPageNameRaw = function( link ) {
// Ignore images and interwiki links
if ( link.hasClass( 'image' ) || link.hasClass( 'extiw' ) ) {
return null;
}
var href = link.attr( 'href' );
// Red links in old parser format
if ( link.hasClass( 'new' ) ) {
const oldRedLink = mw.util.getParamValue( 'title', href );
if ( oldRedLink !== null ) {
return oldRedLink;
}
}
// Check that external links are coming from this wiki
if ( href.indexOf( '//' ) === 0 || href.indexOf( 'https://' ) === 0 ) {
try {
const fullUrl = href.indexOf( '//' ) === 0 ? location.protocol + href : href;
let urlData = new URL( fullUrl );
if ( urlData.hostname !== location.hostname ) {
return null;
}
} catch( e ) {
return null;
}
}
var articlePathRegexp = new RegExp(
mw.config.get( 'wgArticlePath' ).replace( '$1', '([^?]*)' )
);
var result = articlePathRegexp.exec( href );
if ( Array.isArray( result ) && result.length > 1 ) {
return decodeURIComponent( result[ 1 ] );
}
return null;
};
/*
* Check whether this is a disambiguation page
*/
const isDisam = function() {
const categories = mw.config.get( 'wgCategories', [] );
return cfg.disamCategories.some( c => categories.includes( c ) );
};
const secondsToHHMMSS = function( totalSeconds ) {
var hhmmss = '';
var hours = Math.floor( totalSeconds / 3600 );
var minutes = Math.floor( totalSeconds % 3600 / 60 );
var seconds = Math.floor( totalSeconds % 3600 % 60 );
if ( hours >= 1 ) {
hhmmss = pad( hours, '0', 2 ) + ':';
}
hhmmss += pad( minutes, '0', 2 ) + ':' + pad( seconds, '0', 2 );
return hhmmss;
};
const pad = function( str, z, width ) {
str = str.toString();
if ( str.length >= width ) {
return str;
} else {
return new Array( width - str.length + 1 ).join( z ) + str;
}
}
/*
* Create a new button
* text: Text that will be displayed on the button
* onClick: Function that will be called when the button is clicked
*/
const createButton = function( text, onClick ) {
var button = $( '<button>', { 'type': 'button' } );
button.text( text );
button.addClass( 'disamassist-button' ).click( onClick );
return button;
};
/*
* Given a page title and an array of possible redirects {from, to} ("canonical titles"), find the page
* at the end of the redirect chain, if there is one. Otherwise, return the page title that was passed
*/
const resolveRedirect = function( pageTitle, possibleRedirects ) {
var appliedRedirect = true;
var visitedPages = {};
var currentPage = getCanonicalTitle( pageTitle );
while ( appliedRedirect ) {
appliedRedirect = false;
for ( var ii = 0; ii < possibleRedirects.length; ii++ ) {
if ( possibleRedirects[ii].from === currentPage ) {
if ( visitedPages[possibleRedirects[ii].to] ) {
// Redirect chain detected
return pageTitle;
}
visitedPages[currentPage] = true;
appliedRedirect = true;
currentPage = possibleRedirects[ii].to;
}
}
}
// No redirect rules applied for an iteration of the outer loop:
// no more redirects. We are done
return currentPage;
};
/*
* Fetch the incoming links to a page. Returns a jQuery promise
* (success - array of titles of pages that contain links to the target page and
* array of "canonical titles" of possible destinations of the backlinks (either
* the target page or redirects to the target page), failure - error description)
* page: Target page
*/
const getBacklinks = function( page ) {
var dfd = new $.Deferred();
api.get( {
'action': 'query',
'list': 'backlinks',
'bltitle': page,
'blredirect': true,
'bllimit': cfg.backlinkLimit,
'blnamespace': cfg.targetNamespaces,
} ).done( function( data ) {
// There might be duplicate entries in some corner cases. We don't care,
// since we are going to check later, anyway
var backlinks = [];
var linkTitles = [getCanonicalTitle( page )];
$.each( data.query.backlinks, function() {
backlinks.push( this.title );
if ( this.redirlinks ) {
linkTitles.push( this.title );
$.each( this.redirlinks, function() {
backlinks.push( this.title );
} );
}
} );
dfd.resolve( backlinks, linkTitles );
} ).fail( function( code, data ) {
dfd.reject( txt.getBacklinksError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download a list of redirects for some pages. Returns a jQuery callback (success -
* array of redirects ({from, to}), failure - error description )
* pageTitles: Array of page titles
*/
const fetchRedirects = function( pageTitles ) {
var dfd = new $.Deferred();
var currentTitles = pageTitles.slice( 0, cfg.queryTitleLimit );
var restTitles = pageTitles.slice( cfg.queryTitleLimit );
api.get( {
action: 'query',
titles: currentTitles,
redirects: true,
} ).done( function( data ) {
var theseRedirects = data.query.redirects ? data.query.redirects : [];
if ( restTitles.length !== 0 ) {
fetchRedirects( restTitles ).done( function( redirects ) {
dfd.resolve( theseRedirects.concat( redirects ) );
} ).fail( function( description ) {
dfd.reject( description );
} );
} else {
dfd.resolve( theseRedirects );
}
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRedirectsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Download the list of user rights for the current user. Returns a
* jQuery promise (success - array of right names, error - error description)
*/
const fetchRights = function() {
var dfd = $.Deferred();
api.get( {
action: 'query',
meta: 'userinfo',
uiprop: 'rights',
} ).done( function( data ) {
dfd.resolve( data.query.userinfo.rights );
} ).fail( function( code, data ) {
dfd.reject( txt.fetchRightsError.replace( '$1', code ) );
} );
return dfd.promise();
};
/*
* Load the raw page text for a given title. Returns a jQuery promise (success - page
* content, failure - error description)
* pageTitle: Title of the page
*/
const loadPage = function( pageTitle ) {
var dfd = new $.Deferred();
api.get( {
action: 'query',
titles: pageTitle,
prop: 'revisions',
rvprop: 'timestamp|content',
meta: 'tokens',
type: 'csrf',
} ).done( function( data ) {
var pages = data.query.pages;
for ( var key in pages ) {
if ( pages.hasOwnProperty( key ) ) {
break;
}
}
var rawPage = data.query.pages[key];
var page = {};
page.missing = rawPage.missing !== undefined;
if ( rawPage.revisions ) {
page.content = rawPage.revisions[0]['*'];
// Assume anything with # at the start is a redirect
if ( page.content.match( /^#[^\s]*?\s*\[\[/ ) ) {
page.redirect = true;
}
page.baseTimeStamp = rawPage.revisions[0].timestamp;
} else {
page.content = '';
page.baseTimeStamp = null;
}
page.startTimeStamp = rawPage.starttimestamp;
page.editToken = data.query.tokens.csrftoken;
dfd.resolve( page );
} ).fail( function( code, data ) {
dfd.reject( txt.loadPageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
/*
* Register changes to a page, to be saved later. Returns a jQuery promise
* (success - no params, failure - error description). Takes the same parameters
* as savePage
*/
const saveWithCooldown = function() {
var deferred = new $.Deferred();
pendingSaves.push( {args: arguments, dfd: deferred} );
if ( !runningSaves ) {
checkAndSave();
}
return deferred.promise();
};
/*
* Save the first set of changes in the list of pending changes, providing that
* enough time has passed since the last edit
*/
const checkAndSave = function() {
if ( pendingSaves.length === 0 ) {
runningSaves = false;
return;
}
runningSaves = true;
var millisSinceLast = new Date().getTime() - lastEditMillis;
if ( millisSinceLast < cfg.editCooldown * 1000 ) {
setTimeout( checkAndSave, cfg.editCooldown * 1000 - millisSinceLast );
} else {
// The last edit started at least cfg.editCooldown seconds ago
var save = pendingSaves.shift();
savePage.apply( this, save.args ).done( function() {
checkAndSave();
save.dfd.resolve();
} ).fail( function( description ) {
checkAndSave();
save.dfd.reject( description );
} );
// We'll use the time since the last edit started
lastEditMillis = new Date().getTime();
}
};
/*
* Save the changes made to a page. Returns a jQuery promise (success - no params,
* failure - error description)
* pageTitle: Title of the page
* page: Page data
* summary: Summary of the changes made to the page
* minorEdit: Whether to mark the edit as 'minor'
* botEdit: Whether to mark the edit as 'bot'
*/
const savePage = function( pageTitle, page, summary, minorEdit, botEdit ) {
var dfd = new $.Deferred();
api.post( {
action: 'edit',
title: pageTitle,
token: page.editToken,
text: page.content,
basetimestamp: page.baseTimeStamp,
starttimestamp: page.startTimeStamp,
summary: summary,
watchlist: cfg.watch ? cfg.watch : 'nochange',
assertuser: mw.config.get( 'wgUserName' ),
minor: minorEdit,
bot: botEdit,
} ).done( function() {
dfd.resolve();
} ).fail( function( code, data ) {
dfd.reject( txt.savePageError.replace( '$1', pageTitle ).replace( '$2', code ) );
} );
return dfd.promise();
};
install();
} )();
// </nowiki>
pteq4ylrg7tigsjbhkhhmpnoufcs7vg
User:Rots61/Disamassist.js
2
189948
781170
2026-10-27T15:35:50Z
Rots61
44293
Created page with "//<syntaxhighlight lang="javascript"> // Originele code van Qwertyytrewqqwerty ([[:en:User:Qwertyytrewqqwerty]]) // Aanpassingen door Midas02 - augustus 2015 // Aanpassingen door Mbch331 (Interface moderator) - januari 2026 window.DisamAssist = jQuery.extend( true, { cfg: { /* * Categories where disambiguation pages are added (usually by a template like {{Disambiguation}} */ disamCategories: ['Wikipedia:Doorverwijspagina'], /* * "Canonical names" of t..."
781170
javascript
text/javascript
//<syntaxhighlight lang="javascript">
// Originele code van Qwertyytrewqqwerty ([[:en:User:Qwertyytrewqqwerty]])
// Aanpassingen door Midas02 - augustus 2015
// Aanpassingen door Mbch331 (Interface moderator) - januari 2026
window.DisamAssist = jQuery.extend( true, {
cfg: {
/*
* Categories where disambiguation pages are added (usually by a template like {{Disambiguation}}
*/
disamCategories: ['Wikipedia:Doorverwijspagina'],
/*
* "Canonical names" of the templates that may appear after ambiguous links
* and which should be removed when fixing those links
*/
disamLinkTemplates: [],
/*
* "Canonical names" of the templates that designate intentional links to
* disambiguation pages
*/
disamLinkIgnoreTemplates: [],
/*
* Format string for "Foo (disambiguation)"-style pages
*/
disamFormat: '$1 (doorverwijspagina)',
/*
* Regular expression matching the titles of disambiguation pages (when they are different from
* the titles of the primary topics)
*/
disamRegExp: '^(.*) \\(doorverwijspagina\\)$',
/*
* Text that will be inserted after the link if the user requests help. If the value is null,
* the option to request help won't be offered
*/
disamNeededText: null,
/*
* Content of the "Foo (disambiguation)" pages that will be created automatically when using
* DisamAssist from a "Foo" page
*/
redirectToDisam: null,
/*
* Whether intentional links to disambiguation pages can be explicitly marked by adding " (disambiguation)"
*/
intentionalLinkOption: false,
/*
* Namespaces that will be searched for incoming links to the disambiguation page (pages in other
* namespaces will be ignored)
*/
targetNamespaces: [0, 2, 6, 10, 14, 100, 828],
/*
* Number of backlinks that will be downloaded at once
* When using blredirect, the maximum limit is supposedly halved
* (see http://www.mediawiki.org/wiki/API:Backlinks)
*/
backlinkLimit: 2000,
/*
* Number of titles we can query for at once
*/
queryTitleLimit: 50,
/*
* Number of characters before and after the incoming link that will be displayed
*/
radius: 300,
/*
* Height of the context box, in lines
*/
numContextLines: 4,
/*
* Number of pages that will be stored before saving, so that changes to them can be
* undone if need be
*/
historySize: 2,
/*
* Minimum time in seconds since the last change was saved before a new edit can be made. A
* negative value or 0 disables the cooldown. Users with the "bot" right won't be affected by
* the cooldown
*/
editCooldown: 12,
/*
* Specify how the watchlist is affected by DisamAssist edits. Possible values: "watch", "unwatch",
* "preferences", "nochange"
*/
watch: 'nochange',
extrafiles: ["bestand", "file", "afbeelding"]
},
txt: {
start: 'Links naar doorverwijspagina repareren',
startMain: 'Repareer links naar hoofdartikel',
startSame: 'Repareer links naar doorverwijspagina',
close: 'Sluiten',
undo: 'Ongedaan maken',
omit: 'Overslaan',
refresh: 'Verversen',
titleAsText: 'Andere link',
disamNeeded: 'Markeren met {{dn}}',
intentionalLink: 'Opzettelijke link naar doorverwijspagina',
titleAsTextPrompt: 'Geef de nieuwe link in:',
removeLink: 'Verwijder link',
optionMarker: ' [Link hier]',
targetOptionMarker: ' [Huidig doelartikel]',
redirectOptionMarker: ' [Huidig doelartikel (via doorverwijzing)]',
pageTitleLine: 'In <a href="$1">$2</a>:',
noMoreLinks: 'Geen links meer ter reparatie.',
pendingEditCounter: 'Opslaan: $1; in geschiedenis: $2',
pendingEditBox: 'DisamAssist is bezig de wijzigingen op te slaan ($1).',
pendingEditBoxTimeEstimation: '$1; geschatte resterende tijd: $2',
pendingEditBoxLimited: 'Gelieve dit tabblad niet te sluiten zolang de wijzingen niet opgeslagen werden. U kan '
+ 'Wikipedia verder bewerken in een ander tabblad, maar het is niet aangeraden tezelfdertijd meerdere instanties '
+ 'van DisamAssist te gebruiken, aangezien een groot aantal wijzingen op korte tijd verstorend kan werken.',
error: 'Fout: $1',
fetchRedirectsError: 'Doorverwijzingen ophalen mislukt: "$1".',
getBacklinksError: 'Backlinks ophalen mislukt: "$1".',
fetchRightsError: 'Ophalen gebruikersrechten mislukt: "$1",',
loadPageError: 'Fout bij het laden van $1: "$2".',
savePageError: 'Fout bij het opslaan van wijzigingen aan $1: "$2".',
dismissError: 'Sluiten',
pending: 'Er zijn nog onopgeslagen wijzingen in DisamAssist. Om deze op te slaan, gelieve op sluiten te drukken',
editInProgress: 'DisamAssist is wijzigingen aan het doorvoeren. Indien u het tabblad nu sluit, kunnen die verloren gaan.',
ellipsis: '...',
notifyCharacter: '✔',
summary: 'Link naar doorverwijspagina [[$1]] $2 met [[Gebruiker:Midas02/DisamAssist|DisamAssist]].',
summaryChanged: 'gewijzigd in [[$1]]',
summaryOmitted: 'link overgeslagen',
summaryRemoved: 'link verwijderd',
summaryIntentional: 'opzettelijke link naar doorverwijspagina',
summaryHelpNeeded: 'hulp nodig',
summarySeparator: '; ',
redirectSummary: 'Creatie doorverwijzing naar [[$1]] met [[Gebruiker:Midas02/DisamAssist|DisamAssist]].'
}
}, window.DisamAssist || {} );
mw.loader.load( 'https://nl.wikipedia.org/w/index.php?title=User:Rots61/DisamAssist-a.js&action=raw&ctype=text/javascript' );
//</syntaxhighlight>
0nz3bavhkbyu1hgpfvfkm5axiuga26v
781193
781170
2026-10-27T16:04:42Z
Rots61
44293
781193
javascript
text/javascript
//<syntaxhighlight lang="javascript">
// Originele code van Qwertyytrewqqwerty ([[:en:User:Qwertyytrewqqwerty]])
// Aanpassingen door Midas02 - augustus 2015
// Aanpassingen door Mbch331 (Interface moderator) - januari 2026
window.DisamAssist = jQuery.extend( true, {
cfg: {
/*
* Categories where disambiguation pages are added (usually by a template like {{Disambiguation}}
*/
disamCategories: ['Wikipedia:Doorverwijspagina'],
/*
* "Canonical names" of the templates that may appear after ambiguous links
* and which should be removed when fixing those links
*/
disamLinkTemplates: [],
/*
* "Canonical names" of the templates that designate intentional links to
* disambiguation pages
*/
disamLinkIgnoreTemplates: [],
/*
* Format string for "Foo (disambiguation)"-style pages
*/
disamFormat: '$1 (doorverwijspagina)',
/*
* Regular expression matching the titles of disambiguation pages (when they are different from
* the titles of the primary topics)
*/
disamRegExp: '^(.*) \\(doorverwijspagina\\)$',
/*
* Text that will be inserted after the link if the user requests help. If the value is null,
* the option to request help won't be offered
*/
disamNeededText: null,
/*
* Content of the "Foo (disambiguation)" pages that will be created automatically when using
* DisamAssist from a "Foo" page
*/
redirectToDisam: null,
/*
* Whether intentional links to disambiguation pages can be explicitly marked by adding " (disambiguation)"
*/
intentionalLinkOption: false,
/*
* Namespaces that will be searched for incoming links to the disambiguation page (pages in other
* namespaces will be ignored)
*/
targetNamespaces: [0, 2, 6, 10, 14, 100, 828],
/*
* Number of backlinks that will be downloaded at once
* When using blredirect, the maximum limit is supposedly halved
* (see http://www.mediawiki.org/wiki/API:Backlinks)
*/
backlinkLimit: 2000,
/*
* Number of titles we can query for at once
*/
queryTitleLimit: 50,
/*
* Number of characters before and after the incoming link that will be displayed
*/
radius: 300,
/*
* Height of the context box, in lines
*/
numContextLines: 4,
/*
* Number of pages that will be stored before saving, so that changes to them can be
* undone if need be
*/
historySize: 2,
/*
* Minimum time in seconds since the last change was saved before a new edit can be made. A
* negative value or 0 disables the cooldown. Users with the "bot" right won't be affected by
* the cooldown
*/
editCooldown: 12,
/*
* Specify how the watchlist is affected by DisamAssist edits. Possible values: "watch", "unwatch",
* "preferences", "nochange"
*/
watch: 'nochange',
extrafiles: ["bestand", "file", "afbeelding"]
},
txt: {
start: 'Links naar doorverwijspagina repareren',
startMain: 'Repareer links naar hoofdartikel',
startSame: 'Repareer links naar doorverwijspagina',
close: 'Sluiten',
undo: 'Ongedaan maken',
omit: 'Overslaan',
refresh: 'Verversen',
titleAsText: 'Andere link',
disamNeeded: 'Markeren met {{dn}}',
intentionalLink: 'Opzettelijke link naar doorverwijspagina',
titleAsTextPrompt: 'Geef de nieuwe link in:',
removeLink: 'Verwijder link',
optionMarker: ' [Link hier]',
targetOptionMarker: ' [Huidig doelartikel]',
redirectOptionMarker: ' [Huidig doelartikel (via doorverwijzing)]',
pageTitleLine: 'In <a href="$1">$2</a>:',
noMoreLinks: 'Geen links meer ter reparatie.',
pendingEditCounter: 'Opslaan: $1; in geschiedenis: $2',
pendingEditBox: 'DisamAssist is bezig de wijzigingen op te slaan ($1).',
pendingEditBoxTimeEstimation: '$1; geschatte resterende tijd: $2',
pendingEditBoxLimited: 'Gelieve dit tabblad niet te sluiten zolang de wijzingen niet opgeslagen werden. U kan '
+ 'Wikipedia verder bewerken in een ander tabblad, maar het is niet aangeraden tezelfdertijd meerdere instanties '
+ 'van DisamAssist te gebruiken, aangezien een groot aantal wijzingen op korte tijd verstorend kan werken.',
error: 'Fout: $1',
fetchRedirectsError: 'Doorverwijzingen ophalen mislukt: "$1".',
getBacklinksError: 'Backlinks ophalen mislukt: "$1".',
fetchRightsError: 'Ophalen gebruikersrechten mislukt: "$1",',
loadPageError: 'Fout bij het laden van $1: "$2".',
savePageError: 'Fout bij het opslaan van wijzigingen aan $1: "$2".',
dismissError: 'Sluiten',
pending: 'Er zijn nog onopgeslagen wijzingen in DisamAssist. Om deze op te slaan, gelieve op sluiten te drukken',
editInProgress: 'DisamAssist is wijzigingen aan het doorvoeren. Indien u het tabblad nu sluit, kunnen die verloren gaan.',
ellipsis: '...',
notifyCharacter: '✔',
summary: 'Link naar doorverwijspagina [[$1]] $2 met [[Gebruiker:Midas02/DisamAssist|DisamAssist]].',
summaryChanged: 'gewijzigd in [[$1]]',
summaryOmitted: 'link overgeslagen',
summaryRemoved: 'link verwijderd',
summaryIntentional: 'opzettelijke link naar doorverwijspagina',
summaryHelpNeeded: 'hulp nodig',
summarySeparator: '; ',
redirectSummary: 'Creatie doorverwijzing naar [[$1]] met [[Gebruiker:Midas02/DisamAssist|DisamAssist]].'
}
}, window.DisamAssist || {} );
mw.loader.load( 'https://test.wikipedia.org/w/index.php?title=User:Rots61/DisamAssist-a.js&action=raw&ctype=text/javascript' );
//</syntaxhighlight>
hevpi5ngu35nsmzsuw1zlk41xpksy0a
781210
781193
2026-10-27T16:34:37Z
Rots61
44293
781210
javascript
text/javascript
//<syntaxhighlight lang="javascript">
// Originele code van Qwertyytrewqqwerty ([[:en:User:Qwertyytrewqqwerty]])
// Aanpassingen door Midas02 - augustus 2015
// Aanpassingen door Mbch331 (Interface moderator) - januari 2026
window.DisamAssist = jQuery.extend( true, {
cfg: {
/*
* Categories where disambiguation pages are added (usually by a template like {{Disambiguation}}
*/
disamCategories: ['Wikipedia:Doorverwijspagina'],
/*
* "Canonical names" of the templates that may appear after ambiguous links
* and which should be removed when fixing those links
*/
disamLinkTemplates: [],
/*
* "Canonical names" of the templates that designate intentional links to
* disambiguation pages
*/
disamLinkIgnoreTemplates: [],
/*
* Format string for "Foo (disambiguation)"-style pages
*/
disamFormat: '$1 (doorverwijspagina)',
/*
* Regular expression matching the titles of disambiguation pages (when they are different from
* the titles of the primary topics)
*/
disamRegExp: '^(.*) \\(doorverwijspagina\\)$',
/*
* Text that will be inserted after the link if the user requests help. If the value is null,
* the option to request help won't be offered
*/
disamNeededText: null,
/*
* Content of the "Foo (disambiguation)" pages that will be created automatically when using
* DisamAssist from a "Foo" page
*/
redirectToDisam: null,
/*
* Whether intentional links to disambiguation pages can be explicitly marked by adding " (disambiguation)"
*/
intentionalLinkOption: false,
/*
* Namespaces that will be searched for incoming links to the disambiguation page (pages in other
* namespaces will be ignored)
*/
targetNamespaces: [0, 6, 10, 14, 100, 828],
/*
* Number of backlinks that will be downloaded at once
* When using blredirect, the maximum limit is supposedly halved
* (see http://www.mediawiki.org/wiki/API:Backlinks)
*/
backlinkLimit: 2000,
/*
* Number of titles we can query for at once
*/
queryTitleLimit: 50,
/*
* Number of characters before and after the incoming link that will be displayed
*/
radius: 300,
/*
* Height of the context box, in lines
*/
numContextLines: 4,
/*
* Number of pages that will be stored before saving, so that changes to them can be
* undone if need be
*/
historySize: 2,
/*
* Minimum time in seconds since the last change was saved before a new edit can be made. A
* negative value or 0 disables the cooldown. Users with the "bot" right won't be affected by
* the cooldown
*/
editCooldown: 0,
/*
* Specify how the watchlist is affected by DisamAssist edits. Possible values: "watch", "unwatch",
* "preferences", "nochange"
*/
watch: 'nochange',
extrafiles: ["bestand", "file", "afbeelding"]
},
txt: {
start: 'Links naar doorverwijspagina repareren',
startMain: 'Repareer links naar hoofdartikel',
startSame: 'Repareer links naar doorverwijspagina',
close: 'Sluiten',
undo: 'Ongedaan maken',
omit: 'Overslaan',
refresh: 'Verversen',
titleAsText: 'Andere link',
disamNeeded: 'Markeren met {{dn}}',
intentionalLink: 'Opzettelijke link naar doorverwijspagina',
titleAsTextPrompt: 'Geef de nieuwe link in:',
removeLink: 'Verwijder link',
optionMarker: ' [Link hier]',
targetOptionMarker: ' [Huidig doelartikel]',
redirectOptionMarker: ' [Huidig doelartikel (via doorverwijzing)]',
pageTitleLine: 'In <a href="$1">$2</a>:',
noMoreLinks: 'Geen links meer ter reparatie.',
pendingEditCounter: 'Opslaan: $1; in geschiedenis: $2',
pendingEditBox: 'DisamAssist is bezig de wijzigingen op te slaan ($1).',
pendingEditBoxTimeEstimation: '$1; geschatte resterende tijd: $2',
pendingEditBoxLimited: 'Gelieve dit tabblad niet te sluiten zolang de wijzingen niet opgeslagen werden. U kan '
+ 'Wikipedia verder bewerken in een ander tabblad, maar het is niet aangeraden tezelfdertijd meerdere instanties '
+ 'van DisamAssist te gebruiken, aangezien een groot aantal wijzingen op korte tijd verstorend kan werken.',
error: 'Fout: $1',
fetchRedirectsError: 'Doorverwijzingen ophalen mislukt: "$1".',
getBacklinksError: 'Backlinks ophalen mislukt: "$1".',
fetchRightsError: 'Ophalen gebruikersrechten mislukt: "$1",',
loadPageError: 'Fout bij het laden van $1: "$2".',
savePageError: 'Fout bij het opslaan van wijzigingen aan $1: "$2".',
dismissError: 'Sluiten',
pending: 'Er zijn nog onopgeslagen wijzingen in DisamAssist. Om deze op te slaan, gelieve op sluiten te drukken',
editInProgress: 'DisamAssist is wijzigingen aan het doorvoeren. Indien u het tabblad nu sluit, kunnen die verloren gaan.',
ellipsis: '...',
notifyCharacter: '✔',
summary: 'Link naar doorverwijspagina [[$1]] $2 met [[Gebruiker:Midas02/DisamAssist|DisamAssist]].',
summaryChanged: 'gewijzigd in [[$1]]',
summaryOmitted: 'link overgeslagen',
summaryRemoved: 'link verwijderd',
summaryIntentional: 'opzettelijke link naar doorverwijspagina',
summaryHelpNeeded: 'hulp nodig',
summarySeparator: '; ',
redirectSummary: 'Creatie doorverwijzing naar [[$1]] met [[Gebruiker:Midas02/DisamAssist|DisamAssist]].'
}
}, window.DisamAssist || {} );
mw.loader.load( 'https://test.wikipedia.org/w/index.php?title=User:Rots61/DisamAssist-a.js&action=raw&ctype=text/javascript' );
//</syntaxhighlight>
pebezv605yvhal7h8lcm4b7x3yrbtzq
781212
781210
2026-10-27T16:36:19Z
Rots61
44293
781212
javascript
text/javascript
//<syntaxhighlight lang="javascript">
// Originele code van Qwertyytrewqqwerty ([[:en:User:Qwertyytrewqqwerty]])
// Aanpassingen door Midas02 - augustus 2015
// Aanpassingen door Mbch331 (Interface moderator) - januari 2026
window.DisamAssist = jQuery.extend( true, {
cfg: {
/*
* Categories where disambiguation pages are added (usually by a template like {{Disambiguation}}
*/
disamCategories: ['Wikipedia:Doorverwijspagina'],
/*
* "Canonical names" of the templates that may appear after ambiguous links
* and which should be removed when fixing those links
*/
disamLinkTemplates: [],
/*
* "Canonical names" of the templates that designate intentional links to
* disambiguation pages
*/
disamLinkIgnoreTemplates: [],
/*
* Format string for "Foo (disambiguation)"-style pages
*/
disamFormat: '$1 (doorverwijspagina)',
/*
* Regular expression matching the titles of disambiguation pages (when they are different from
* the titles of the primary topics)
*/
disamRegExp: '^(.*) \\(doorverwijspagina\\)$',
/*
* Text that will be inserted after the link if the user requests help. If the value is null,
* the option to request help won't be offered
*/
disamNeededText: null,
/*
* Content of the "Foo (disambiguation)" pages that will be created automatically when using
* DisamAssist from a "Foo" page
*/
redirectToDisam: null,
/*
* Whether intentional links to disambiguation pages can be explicitly marked by adding " (disambiguation)"
*/
intentionalLinkOption: false,
/*
* Namespaces that will be searched for incoming links to the disambiguation page (pages in other
* namespaces will be ignored)
*/
targetNamespaces: [0, 6, 10, 14, 100, 828],
/*
* Number of backlinks that will be downloaded at once
* When using blredirect, the maximum limit is supposedly halved
* (see http://www.mediawiki.org/wiki/API:Backlinks)
*/
backlinkLimit: 2000,
/*
* Number of titles we can query for at once
*/
queryTitleLimit: 50,
/*
* Number of characters before and after the incoming link that will be displayed
*/
radius: 300,
/*
* Height of the context box, in lines
*/
numContextLines: 4,
/*
* Number of pages that will be stored before saving, so that changes to them can be
* undone if need be
*/
historySize: 2,
/*
* Minimum time in seconds since the last change was saved before a new edit can be made. A
* negative value or 0 disables the cooldown. Users with the "bot" right won't be affected by
* the cooldown
*/
editCooldown: 0,
/*
* Specify how the watchlist is affected by DisamAssist edits. Possible values: "watch", "unwatch",
* "preferences", "nochange"
*/
watch: 'nochange',
extrafiles: ["bestand", "file", "afbeelding"]
},
txt: {
start: 'Links naar doorverwijspagina repareren',
startMain: 'Repareer links naar hoofdartikel',
startSame: 'Repareer links naar doorverwijspagina',
close: 'Sluiten',
undo: 'Ongedaan maken',
omit: 'Overslaan',
refresh: 'Verversen',
titleAsText: 'Andere link',
disamNeeded: 'Markeren met {{dn}}',
intentionalLink: 'Opzettelijke link naar doorverwijspagina',
titleAsTextPrompt: 'Geef de nieuwe link in:',
removeLink: 'Verwijder link',
optionMarker: ' [Link hier]',
targetOptionMarker: ' [Huidig doelartikel]',
redirectOptionMarker: ' [Huidig doelartikel (via doorverwijzing)]',
pageTitleLine: 'In <a href="$1">$2</a>:',
noMoreLinks: 'Geen links meer ter reparatie.',
pendingEditCounter: 'Opslaan: $1; in geschiedenis: $2',
pendingEditBox: 'DisamAssist is bezig de wijzigingen op te slaan ($1).',
pendingEditBoxTimeEstimation: '$1; geschatte resterende tijd: $2',
pendingEditBoxLimited: 'Gelieve dit tabblad niet te sluiten zolang de wijzingen niet opgeslagen werden. U kan '
+ 'Wikipedia verder bewerken in een ander tabblad, maar het is niet aangeraden tezelfdertijd meerdere instanties '
+ 'van DisamAssist te gebruiken, aangezien een groot aantal wijzingen op korte tijd verstorend kan werken.',
error: 'Fout: $1',
fetchRedirectsError: 'Doorverwijzingen ophalen mislukt: "$1".',
getBacklinksError: 'Backlinks ophalen mislukt: "$1".',
fetchRightsError: 'Ophalen gebruikersrechten mislukt: "$1",',
loadPageError: 'Fout bij het laden van $1: "$2".',
savePageError: 'Fout bij het opslaan van wijzigingen aan $1: "$2".',
dismissError: 'Sluiten',
pending: 'Er zijn nog onopgeslagen wijzingen in DisamAssist. Om deze op te slaan, gelieve op sluiten te drukken',
editInProgress: 'DisamAssist is wijzigingen aan het doorvoeren. Indien u het tabblad nu sluit, kunnen die verloren gaan.',
ellipsis: '...',
notifyCharacter: '✔',
summary: 'Link naar doorverwijspagina [[$1]] $2 met [[Gebruiker:Midas02/DisamAssist|DisamAssist]].',
summaryChanged: 'gewijzigd in [[$1]]',
summaryOmitted: 'link overgeslagen',
summaryRemoved: 'link verwijderd',
summaryIntentional: 'opzettelijke link naar doorverwijspagina',
summaryHelpNeeded: 'hulp nodig',
summarySeparator: '; ',
redirectSummary: 'Creatie doorverwijzing naar [[$1]] met [[Gebruiker:Midas02/DisamAssist|DisamAssist]].'
}
}, window.DisamAssist || {} );
mw.loader.load( 'https://ru.wikipedia.org/w/index.php?title=Участник:Stjn/DisamAssist-core.js&action=raw&ctype=text/javascript' );
//</syntaxhighlight>
f9qp1u4so064qzdaurzubl8czeh93f9
781217
781212
2026-10-27T16:42:02Z
Rots61
44293
781217
javascript
text/javascript
//<syntaxhighlight lang="javascript">
// Originele code van Qwertyytrewqqwerty ([[:en:User:Qwertyytrewqqwerty]])
// Aanpassingen door Midas02 - augustus 2015
// Aanpassingen door Mbch331 (Interface moderator) - januari 2026
window.DisamAssist = jQuery.extend( true, {
cfg: {
/*
* Categories where disambiguation pages are added (usually by a template like {{Disambiguation}}
*/
disamCategories: ['Wikipedia:Doorverwijspagina'],
/*
* "Canonical names" of the templates that may appear after ambiguous links
* and which should be removed when fixing those links
*/
disamLinkTemplates: [],
/*
* "Canonical names" of the templates that designate intentional links to
* disambiguation pages
*/
disamLinkIgnoreTemplates: [],
/*
* Format string for "Foo (disambiguation)"-style pages
*/
disamFormat: '$1 (doorverwijspagina)',
/*
* Regular expression matching the titles of disambiguation pages (when they are different from
* the titles of the primary topics)
*/
disamRegExp: '^(.*) \\(doorverwijspagina\\)$',
/*
* Text that will be inserted after the link if the user requests help. If the value is null,
* the option to request help won't be offered
*/
disamNeededText: null,
/*
* Content of the "Foo (disambiguation)" pages that will be created automatically when using
* DisamAssist from a "Foo" page
*/
redirectToDisam: null,
/*
* Whether intentional links to disambiguation pages can be explicitly marked by adding " (disambiguation)"
*/
intentionalLinkOption: false,
/*
* Namespaces that will be searched for incoming links to the disambiguation page (pages in other
* namespaces will be ignored)
*/
targetNamespaces: [0, 6, 10, 14, 100, 828],
/*
* Number of backlinks that will be downloaded at once
* When using blredirect, the maximum limit is supposedly halved
* (see http://www.mediawiki.org/wiki/API:Backlinks)
*/
backlinkLimit: 2000,
/*
* Number of titles we can query for at once
*/
queryTitleLimit: 50,
/*
* Number of characters before and after the incoming link that will be displayed
*/
radius: 300,
/*
* Height of the context box, in lines
*/
numContextLines: 4,
/*
* Number of pages that will be stored before saving, so that changes to them can be
* undone if need be
*/
historySize: 2,
/*
* Minimum time in seconds since the last change was saved before a new edit can be made. A
* negative value or 0 disables the cooldown. Users with the "bot" right won't be affected by
* the cooldown
*/
editCooldown: 0,
/*
* Specify how the watchlist is affected by DisamAssist edits. Possible values: "watch", "unwatch",
* "preferences", "nochange"
*/
watch: 'nochange',
extrafiles: ["bestand", "file", "afbeelding"]
},
txt: {
start: 'Links naar doorverwijspagina repareren',
startMain: 'Repareer links naar hoofdartikel',
startSame: 'Repareer links naar doorverwijspagina',
close: 'Sluiten',
undo: 'Ongedaan maken',
omit: 'Overslaan',
refresh: 'Verversen',
titleAsText: 'Andere link',
disamNeeded: 'Markeren met {{dn}}',
intentionalLink: 'Opzettelijke link naar doorverwijspagina',
titleAsTextPrompt: 'Geef de nieuwe link in:',
removeLink: 'Verwijder link',
optionMarker: ' [Link hier]',
targetOptionMarker: ' [Huidig doelartikel]',
redirectOptionMarker: ' [Huidig doelartikel (via doorverwijzing)]',
pageTitleLine: 'In <a href="$1">$2</a>:',
noMoreLinks: 'Geen links meer ter reparatie.',
pendingEditCounter: 'Opslaan: $1; in geschiedenis: $2',
pendingEditBox: 'DisamAssist is bezig de wijzigingen op te slaan ($1).',
pendingEditBoxTimeEstimation: '$1; geschatte resterende tijd: $2',
pendingEditBoxLimited: 'Gelieve dit tabblad niet te sluiten zolang de wijzingen niet opgeslagen werden. U kan '
+ 'Wikipedia verder bewerken in een ander tabblad, maar het is niet aangeraden tezelfdertijd meerdere instanties '
+ 'van DisamAssist te gebruiken, aangezien een groot aantal wijzingen op korte tijd verstorend kan werken.',
error: 'Fout: $1',
fetchRedirectsError: 'Doorverwijzingen ophalen mislukt: "$1".',
getBacklinksError: 'Backlinks ophalen mislukt: "$1".',
fetchRightsError: 'Ophalen gebruikersrechten mislukt: "$1",',
loadPageError: 'Fout bij het laden van $1: "$2".',
savePageError: 'Fout bij het opslaan van wijzigingen aan $1: "$2".',
dismissError: 'Sluiten',
pending: 'Er zijn nog onopgeslagen wijzingen in DisamAssist. Om deze op te slaan, gelieve op sluiten te drukken',
editInProgress: 'DisamAssist is wijzigingen aan het doorvoeren. Indien u het tabblad nu sluit, kunnen die verloren gaan.',
ellipsis: '...',
notifyCharacter: '✔',
summary: 'Link naar doorverwijspagina [[$1]] $2 met [[Gebruiker:Midas02/DisamAssist|DisamAssist]].',
summaryChanged: 'gewijzigd in [[$1]]',
summaryOmitted: 'link overgeslagen',
summaryRemoved: 'link verwijderd',
summaryIntentional: 'opzettelijke link naar doorverwijspagina',
summaryHelpNeeded: 'hulp nodig',
summarySeparator: '; ',
redirectSummary: 'Creatie doorverwijzing naar [[$1]] met [[Gebruiker:Midas02/DisamAssist|DisamAssist]].'
}
}, window.DisamAssist || {} );
mw.loader.load( 'https://test.wikipedia.org/w/index.php?title=User:Rots61/DisamAssist-a.js&action=raw&ctype=text/javascript' );
//</syntaxhighlight>
pebezv605yvhal7h8lcm4b7x3yrbtzq
User:Rots61/common.js
2
189949
781171
2026-10-27T15:36:31Z
Rots61
44293
Created page with "importScript('User:Rots61/Disamassist.js');"
781171
javascript
text/javascript
importScript('User:Rots61/Disamassist.js');
ieb0tqfnpr8gqmq4xpodtzqr9fuxh9i
User:Rots61/Disamassist-a.js
2
189950
781173
2026-10-27T15:36:55Z
Rots61
44293
Rots61 moved page [[User:Rots61/Disamassist-a.js]] to [[User:Rots61/DisamAssist-a.js]]
781173
javascript
text/javascript
/* #REDIRECT */mw.loader.load("//test.wikipedia.org/w/index.php?title=User:Rots61/DisamAssist-a.js&action=raw&ctype=text/javascript");
5wtao03fghzo3ria1v7dd8bnhd33c8g
Dp space
0
189951
781174
2026-10-27T15:38:47Z
Rots61
44293
Created page with "{{dpintro}} *[[Test]] *[[Appeltje]] *[[Genot]] *[[Triviaal]] {{dpoutro}}"
781174
wikitext
text/x-wiki
{{dpintro}}
*[[Test]]
*[[Appeltje]]
*[[Genot]]
*[[Triviaal]]
{{dpoutro}}
sr1uiyuh1fvl31pprhpt9kc4xovzvfv
781176
781174
2026-10-27T15:40:19Z
Rots61
44293
781176
wikitext
text/x-wiki
Kan verwijzen naar:
*[[Test]]
*[[Appeltje]]
*[[Genot]]
*[[Triviaal]]
[[Categorie:Wikipedia:Doorverwijspagina]]
ao2hs7fwxkwurjdfhcf0hr0cj6dkgnf
781178
781176
2026-10-27T15:41:01Z
Rots61
44293
781178
wikitext
text/x-wiki
Kan verwijzen naar:
*[[Test]]
*[[Appeltje]]
*[[Genot]]
*[[Triviaal]]
[[Category:Wikipedia:Doorverwijspagina]]
mb0c61jxem55ud4jqilphvh6wopwj20
781182
781178
2026-10-27T15:44:02Z
Rots61
44293
Rots61 moved page [[Dp]] to [[Dp space]]
781178
wikitext
text/x-wiki
Kan verwijzen naar:
*[[Test]]
*[[Appeltje]]
*[[Genot]]
*[[Triviaal]]
[[Category:Wikipedia:Doorverwijspagina]]
mb0c61jxem55ud4jqilphvh6wopwj20
Template:Dpoutro
10
189952
781175
2026-10-27T15:39:28Z
Rots61
44293
Created page with "<br style="clear:both;" /> <div style="display:flex; margin-bottom:0.25em;"> <div style="min-width:48px;">[[Bestand:Vista-xmag.png|left|30px|class=noviewer]]</div> <div>Bekijk alle artikelen waarvan de titel [[Speciaal:Prefixindex/{{{1|{{#invoke:Doorverwijspagina|getlabel}}}}}|begint met '''{{{1|{{#invoke:Doorverwijspagina|getlabel}}}}}''']] of Speciaal:Zoeken/intitle:"{{{1|{{#invoke:Doorverwijspagina|getlabel}}}}}"|met '''{{{1|{{#invoke:Doorverwijspagina|get..."
781175
wikitext
text/x-wiki
<br style="clear:both;" />
<div style="display:flex; margin-bottom:0.25em;">
<div style="min-width:48px;">[[Bestand:Vista-xmag.png|left|30px|class=noviewer]]</div>
<div>Bekijk alle artikelen waarvan de titel [[Speciaal:Prefixindex/{{{1|{{#invoke:Doorverwijspagina|getlabel}}}}}|begint met '''{{{1|{{#invoke:Doorverwijspagina|getlabel}}}}}''']] of [[Speciaal:Zoeken/intitle:"{{{1|{{#invoke:Doorverwijspagina|getlabel}}}}}"|met '''{{{1|{{#invoke:Doorverwijspagina|getlabel}}}}}''' in de titel]].</div>
</div>
<div class="toccolours disambigbox" role="note" style="display:flex; padding:0.7em; border-width:1px 0;">
<div style="min-width:44px;">[[Bestand:Disambig-dark.svg|left|30px|class=noviewer]]</div>
<div>Dit is een '''[[Wikipedia:Doorverwijspagina|doorverwijspagina]]''', bedoeld om de verschillen in betekenis of gebruik van ''{{{1|{{#invoke:Doorverwijspagina|getlabel}}}}}'' inzichtelijk te maken.
<p><small>Op deze pagina staat een uitleg van de verschillende betekenissen van ''{{{1|{{#invoke:Doorverwijspagina|getlabel}}}}}'' en [[Speciaal:VerwijzingenNaarHier/{{{1|{{#invoke:Doorverwijspagina|getlabel}}}}}|verwijzingen]] daarnaartoe. Bent u hier via een pagina in Wikipedia terechtgekomen? Pas dan de verwijzing naar deze doorverwijspagina aan, zodat toekomstige bezoekers direct op de juiste pagina terechtkomen.</small></p></div>
</div><includeonly>{{ #ifeq: {{FULLPAGENAME}} | Wikipedia:Sjablonen voor artikelboodschappen | |
{{ #ifeq: {{FULLPAGENAME}} | Help:Doorverwijspagina | |
{{ #ifeq: {{FULLPAGENAME}} | Help:Doorverwijzen | | [[Categorie:Wikipedia:Doorverwijspagina]] __DISAMBIG__
}} }} }}</includeonly><noinclude>
{{Documentatie}}
[[Categorie:Wikipedia:Sjablonen doorverwijzing| ]]
</noinclude>
hj0bhdr42or0rdzueidudeeh3dknapm
781177
781175
2026-10-27T15:40:45Z
Rots61
44293
Replaced content with "{{delete}}"
781177
wikitext
text/x-wiki
{{delete}}
35r2j9t4ectnt1cmb7mlgcqvwz6h5k6
Dp-test
0
189953
781179
2026-10-27T15:41:34Z
Rots61
44293
Created page with "*{{ill|Gebruiker:Rots61/DisamAssist|en|User:Rots61/DisamAssist}} *[[Gebruiker:Rots61/DisamAssist]] *[[Gebruiker:Rots61/DisamAssist|DisamAssist]] *[[Gebruiker:Rots61/DisamAssist#DisamAssist]] *[[Gebruiker:Rots61/DisamAssist#DisamAssist|DisamAssist]] *{{sortnaam|Gebruiker:Rots61/DisamAssist|}}"
781179
wikitext
text/x-wiki
*{{ill|Gebruiker:Rots61/DisamAssist|en|User:Rots61/DisamAssist}}
*[[Gebruiker:Rots61/DisamAssist]]
*[[Gebruiker:Rots61/DisamAssist|DisamAssist]]
*[[Gebruiker:Rots61/DisamAssist#DisamAssist]]
*[[Gebruiker:Rots61/DisamAssist#DisamAssist|DisamAssist]]
*{{sortnaam|Gebruiker:Rots61/DisamAssist|}}
4tckc8cor25htj9hpkybeh8xjh1ngri
781181
781179
2026-10-27T15:43:17Z
Rots61
44293
781181
wikitext
text/x-wiki
*{{ill|dp|en|User:Rots61/DisamAssist}}
*[[dp]]
*[[dp|DisamAssist]]
*[[dp#DisamAssist]]
*[[dp#DisamAssist|DisamAssist]]
*{{sortnaam|dp|}}
*{{sortnaam|dp|with space}}
elv69a2jufe9f1p946h2xfxv6fh1o55
781184
781181
2026-10-27T15:44:16Z
Rots61
44293
781184
wikitext
text/x-wiki
*{{ill|dp|en|User:Rots61/DisamAssist}}
*[[dp]]
*[[dp|DisamAssist]]
*[[dp#DisamAssist]]
*[[dp#DisamAssist|DisamAssist]]
*{{sortnaam|dp|space}}
*{{sortnaam|dp|with space}}
g168ubel82uk3k8yd15ijj68qv4i9ti
781187
781184
2026-10-27T15:52:14Z
Rots61
44293
Link naar doorverwijspagina [[Dp space]] gewijzigd in [[Test]]; gewijzigd in [[Appeltje]] met [[Gebruiker:Midas02/DisamAssist|DisamAssist]].
781187
wikitext
text/x-wiki
*{{ill|dp|en|User:Rots61/DisamAssist}}
*[[Test|dp]]
*[[Test|DisamAssist]]
*[[Test|dp]]isamAssist]]
*[[Test|DisamAssist]]isamAssist]]
*{{sortnaam|dp|space|Appeltje}}
*{{sortnaam|dp|with space}}
lf1yfawr5osb792xdneqrf3fr6g4ola
781188
781187
2026-10-27T15:53:54Z
Rots61
44293
Undid revision [[Special:Diff/781187|781187]] by [[Special:Contributions/Rots61|Rots61]] ([[User talk:Rots61|talk]])
781188
wikitext
text/x-wiki
*{{ill|dp|en|User:Rots61/DisamAssist}}
*[[dp]]
*[[dp|DisamAssist]]
*[[dp#DisamAssist]]
*[[dp#DisamAssist|DisamAssist]]
*{{sortnaam|dp|space}}
*{{sortnaam|dp|with space}}
*[[dp space#DisamAssist]]
*[[dp with space]]
ickdcu3zs70n7pknfdoe2c8qk8no5pw
781190
781188
2026-10-27T15:57:29Z
Rots61
44293
781190
wikitext
text/x-wiki
*{{ill|dp|en|User:Rots61/DisamAssist}}
*[[dp]]
*[[dp|DisamAssist]]
*[[dp#DisamAssist]]
*[[dp#DisamAssist|DisamAssist]]
*{{sortnaam|dp|space}}
*{{sortnaam|dp|with space}}
*{{sortnaam|dp|with space|dp space#appel}}
*[[dp space#DisamAssist]]
*[[dp with space]]
*{{sortnaam|dp|with space|dp#appel}}
*[[dp]]
jl9tpknmdj97i2405vfbnytnxtyv4wp
781196
781190
2026-10-27T16:16:44Z
Rots61
44293
781196
wikitext
text/x-wiki
*{{ill|dp|en|User:Rots61/DisamAssist}}
*{{ill|dp|en|User:Rots61/DisamAssist|be|tralala|fr|lol}}
*[[dp]]
*[[dp|DisamAssist]]
*[[dp#DisamAssist]]
*[[dp#DisamAssist|DisamAssist]]
*{{sortnaam|dp|space}}
*{{sortnaam|dp|with space}}
*{{sortnaam|dp|with space|dp space#appel}}
*[[dp space#DisamAssist]]
*[[dp with space]]
*{{sortnaam|dp|with space|dp#appel}}
*[[dp]]
ahue7lbzdvhqnxw8znfsiwf1au0s634
781197
781196
2026-10-27T16:20:15Z
Rots61
44293
Link naar doorverwijspagina [[Dp space]] gewijzigd in [[Test]]; gewijzigd in [[Appeltje]]; gewijzigd in [[Genot]]; gewijzigd in [[Triviaal]] met [[Gebruiker:Midas02/DisamAssist|DisamAssist]].
781197
wikitext
text/x-wiki
*{{ill|Test|en|User:Rots61/DisamAssist}}
*{{ill|Appeltje|en|User:Rots61/DisamAssist|be|tralala|fr|lol}}
*[[Genot|dp]]
*[[Triviaal|DisamAssist]]
*[[Test#DisamAssist|dp]]
*[[Appeltje#DisamAssist|DisamAssist]]
*{{sortnaam|dp|space|Genot}}
*{{sortnaam|dp|with space|Triviaal}}
*{{sortnaam|dp|with space|Triviaal#appel}}
*[[Genot#DisamAssist|dp space]]
*[[Appeltje|dp with space]]
*{{sortnaam|dp|with space|Test#appel}}
*[[Appeltje|dp]]
3qpg78jd4k812bc96on94p7dqjrgtlw
781199
781197
2026-10-27T16:23:07Z
Rots61
44293
Undid revision [[Special:Diff/781197|781197]] by [[Special:Contributions/Rots61|Rots61]] ([[User talk:Rots61|talk]])
781199
wikitext
text/x-wiki
*{{ill|dp|en|User:Rots61/DisamAssist}}
*{{ill|dp|en|User:Rots61/DisamAssist|be|tralala|fr|lol}}
*{{ill|dp with space}}
*[[dp]]
*[[dp|DisamAssist]]
*[[dp#DisamAssist]]
*[[dp#DisamAssist|DisamAssist]]
*{{sortnaam|dp|space}}
*{{sortnaam|dp|with space}}
*{{sortnaam|dp|with space|dp space#appel}}
*[[dp space#DisamAssist]]
*[[dp with space]]
*{{sortnaam|dp|with space|dp#appel}}
*[[dp]]
ig66ny1pxp298b004a3s03hy1hfeu4z
781200
781199
2026-10-27T16:24:05Z
Rots61
44293
Link naar doorverwijspagina [[Dp space]] gewijzigd in [[Test]]; gewijzigd in [[Appeltje]]; gewijzigd in [[Genot]]; gewijzigd in [[Triviaal]] met [[Gebruiker:Midas02/DisamAssist|DisamAssist]].
781200
wikitext
text/x-wiki
*{{ill|Test|en|User:Rots61/DisamAssist}}
*{{ill|Appeltje|en|User:Rots61/DisamAssist|be|tralala|fr|lol}}
*{{ill|Genot||}}
*[[dp]]
*[[Triviaal|DisamAssist]]
*[[Triviaal#DisamAssist|dp]]
*[[Genot#DisamAssist|DisamAssist]]
*{{sortnaam|dp|space|Appeltje}}
*{{sortnaam|dp|with space|Test}}
*{{sortnaam|dp|with space|Test#appel}}
*[[dp space#DisamAssist]]
*[[Genot|dp with space]]
*{{sortnaam|dp|with space|Appeltje#appel}}
*[[Triviaal|dp]]
pdt9tl51q3k6tpy4644qx57hpjvrwqd
781201
781200
2026-10-27T16:26:18Z
Rots61
44293
781201
wikitext
text/x-wiki
*{{ill|dp|en|User:Rots61/DisamAssist}}
*{{ill|dp|en|User:Rots61/DisamAssist|be|tralala|fr|lol}}
*{{ill|dp with space}}
*[[dp]]
*[[dp|DisamAssist]]
*[[dp#DisamAssist]]
*[[dp#DisamAssist|DisamAssist]]
*{{sortnaam|dp|space}}
*{{sortnaam|dp|with space}}
*{{sortnaam|dp|with space|dp space#appel}}
*[[dp space#DisamAssist]]
*[[dp space#DisamAssist|OEIOEI]]
*[[dp with space#DisamAssist]]
*[[dp with space#DisamAssist|JAJA]]
*[[dp with space]]
*{{sortnaam|dp|with space|dp#appel}}
*[[dp]]
40ystj2sdj4fmj48i8krfoa1nj01y9c
781203
781201
2026-10-27T16:29:16Z
Rots61
44293
Link naar doorverwijspagina [[Dp space]] gewijzigd in [[Test]]; gewijzigd in [[Appeltje]]; gewijzigd in [[Genot]]; gewijzigd in [[Triviaal]] met [[Gebruiker:Midas02/DisamAssist|DisamAssist]].
781203
wikitext
text/x-wiki
*{{ill|Test|en|User:Rots61/DisamAssist}}
*{{ill|Appeltje|en|User:Rots61/DisamAssist|be|tralala|fr|lol}}
*{{ill|Test||}}
*[[dp]]
*[[Appeltje|DisamAssist]]
*[[Appeltje#DisamAssist|dp]]
*[[Genot#DisamAssist|DisamAssist]]
*{{sortnaam|dp|space|Test}}
*{{sortnaam|dp|with space|Appeltje}}
*{{sortnaam|dp|with space|Genot#appel}}
*[[dp space#DisamAssist]]
*[[Triviaal#DisamAssist|OEIOEI]]
*[[Genot#DisamAssist|dp with space]]
*[[Appeltje#DisamAssist|JAJA]]
*[[dp with space]]
*{{sortnaam|dp|with space|Test#appel}}
*[[Appeltje|dp]]
afwx9hpp6aayp2yn03p4fmkswx97vvc
781205
781203
2026-10-27T16:29:37Z
Rots61
44293
Undid revision [[Special:Diff/781203|781203]] by [[Special:Contributions/Rots61|Rots61]] ([[User talk:Rots61|talk]])
781205
wikitext
text/x-wiki
*{{ill|dp|en|User:Rots61/DisamAssist}}
*{{ill|dp|en|User:Rots61/DisamAssist|be|tralala|fr|lol}}
*{{ill|dp with space}}
*[[dp]]
*[[dp|DisamAssist]]
*[[dp#DisamAssist]]
*[[dp#DisamAssist|DisamAssist]]
*{{sortnaam|dp|space}}
*{{sortnaam|dp|with space}}
*{{sortnaam|dp|with space|dp space#appel}}
*[[dp space#DisamAssist]]
*[[dp space#DisamAssist|OEIOEI]]
*[[dp with space#DisamAssist]]
*[[dp with space#DisamAssist|JAJA]]
*[[dp with space]]
*{{sortnaam|dp|with space|dp#appel}}
*[[dp]]
40ystj2sdj4fmj48i8krfoa1nj01y9c
781208
781205
2026-10-27T16:32:07Z
Rots61
44293
Link naar doorverwijspagina [[Dp space]] gewijzigd in [[Test]]; gewijzigd in [[Appeltje]]; gewijzigd in [[Genot]] met [[Gebruiker:Midas02/DisamAssist|DisamAssist]].
781208
wikitext
text/x-wiki
*{{ill|dp|en|User:Rots61/DisamAssist}}
*{{ill|dp|en|User:Rots61/DisamAssist|be|tralala|fr|lol}}
*{{ill|dp with space}}
*[[Test|dp]]
*[[Appeltje|DisamAssist]]
*[[Genot|dp]]isamAssist]]
*[[Genot|DisamAssist]]isamAssist]]
*{{sortnaam|dp|space}}
*{{sortnaam|dp|with space}}
*{{sortnaam|dp|with space|dp space#appel}}
*[[Genot|dp space]]isamAssist]]
*[[Genot|OEIOEI]]ist|OEIOEI]]
*[[Genot|dp with space]]isamAssist]]
*[[Appeltje|JAJA]]ssist|JAJA]]
*[[Appeltje|dp with space]]
*{{sortnaam|dp|with space|dp#appel}}
*[[Appeltje|dp]]
0ns63ojm2hakfogzk8c4qe58im597sj
781209
781208
2026-10-27T16:32:29Z
Rots61
44293
Undid revision [[Special:Diff/781208|781208]] by [[Special:Contributions/Rots61|Rots61]] ([[User talk:Rots61|talk]])
781209
wikitext
text/x-wiki
*{{ill|dp|en|User:Rots61/DisamAssist}}
*{{ill|dp|en|User:Rots61/DisamAssist|be|tralala|fr|lol}}
*{{ill|dp with space}}
*[[dp]]
*[[dp|DisamAssist]]
*[[dp#DisamAssist]]
*[[dp#DisamAssist|DisamAssist]]
*{{sortnaam|dp|space}}
*{{sortnaam|dp|with space}}
*{{sortnaam|dp|with space|dp space#appel}}
*[[dp space#DisamAssist]]
*[[dp space#DisamAssist|OEIOEI]]
*[[dp with space#DisamAssist]]
*[[dp with space#DisamAssist|JAJA]]
*[[dp with space]]
*{{sortnaam|dp|with space|dp#appel}}
*[[dp]]
40ystj2sdj4fmj48i8krfoa1nj01y9c
781211
781209
2026-10-27T16:35:08Z
Rots61
44293
Link naar doorverwijspagina [[Dp space]] gewijzigd in [[Test]]; gewijzigd in [[Appeltje]]; gewijzigd in [[Genot]]; gewijzigd in [[Triviaal]] met [[Gebruiker:Midas02/DisamAssist|DisamAssist]].
781211
wikitext
text/x-wiki
*{{ill|dp|en|User:Rots61/DisamAssist}}
*{{ill|dp|en|User:Rots61/DisamAssist|be|tralala|fr|lol}}
*{{ill|dp with space}}
*[[Test|dp]]
*[[Appeltje|DisamAssist]]
*[[Genot|dp]]isamAssist]]
*[[Triviaal|DisamAssist]]isamAssist]]
*{{sortnaam|dp|space}}
*{{sortnaam|dp|with space}}
*{{sortnaam|dp|with space|dp space#appel}}
*[[Genot|dp space]]isamAssist]]
*[[Appeltje|OEIOEI]]ist|OEIOEI]]
*[[Test|dp with space]]isamAssist]]
*[[Appeltje|JAJA]]ssist|JAJA]]
*[[Triviaal|dp with space]]
*{{sortnaam|dp|with space|dp#appel}}
*[[Genot|dp]]
subnkzswg0nn7uuefuwr3dou4sch215
781213
781211
2026-10-27T16:36:44Z
Rots61
44293
Undid revision [[Special:Diff/781211|781211]] by [[Special:Contributions/Rots61|Rots61]] ([[User talk:Rots61|talk]])
781213
wikitext
text/x-wiki
*{{ill|dp|en|User:Rots61/DisamAssist}}
*{{ill|dp|en|User:Rots61/DisamAssist|be|tralala|fr|lol}}
*{{ill|dp with space}}
*[[dp]]
*[[dp|DisamAssist]]
*[[dp#DisamAssist]]
*[[dp#DisamAssist|DisamAssist]]
*{{sortnaam|dp|space}}
*{{sortnaam|dp|with space}}
*{{sortnaam|dp|with space|dp space#appel}}
*[[dp space#DisamAssist]]
*[[dp space#DisamAssist|OEIOEI]]
*[[dp with space#DisamAssist]]
*[[dp with space#DisamAssist|JAJA]]
*[[dp with space]]
*{{sortnaam|dp|with space|dp#appel}}
*[[dp]]
40ystj2sdj4fmj48i8krfoa1nj01y9c
781214
781213
2026-10-27T16:37:02Z
Rots61
44293
Link naar doorverwijspagina [[Dp space]] gewijzigd in [[Test]]; gewijzigd in [[Appeltje]]; gewijzigd in [[Triviaal]]; gewijzigd in [[Genot]] met [[Gebruiker:Midas02/DisamAssist|DisamAssist]].
781214
wikitext
text/x-wiki
*{{ill|dp|en|User:Rots61/DisamAssist}}
*{{ill|dp|en|User:Rots61/DisamAssist|be|tralala|fr|lol}}
*{{ill|dp with space}}
*[[Test|dp]]
*[[Appeltje|DisamAssist]]
*[[dp#DisamAssist]]
*[[dp#DisamAssist|DisamAssist]]
*{{sortnaam|dp|space}}
*{{sortnaam|dp|with space}}
*{{sortnaam|dp|with space|dp space#appel}}
*[[dp space#DisamAssist]]
*[[dp space#DisamAssist|OEIOEI]]
*[[dp with space#DisamAssist]]
*[[dp with space#DisamAssist|JAJA]]
*[[Triviaal|dp with space]]
*{{sortnaam|dp|with space|dp#appel}}
*[[Genot|dp]]
1qofagjb5ekxj6f6cbh41qhzgwrtwp9
781215
781214
2026-10-27T16:37:28Z
Rots61
44293
Undid revision [[Special:Diff/781214|781214]] by [[Special:Contributions/Rots61|Rots61]] ([[User talk:Rots61|talk]])
781215
wikitext
text/x-wiki
*{{ill|dp|en|User:Rots61/DisamAssist}}
*{{ill|dp|en|User:Rots61/DisamAssist|be|tralala|fr|lol}}
*{{ill|dp with space}}
*[[dp]]
*[[dp|DisamAssist]]
*[[dp#DisamAssist]]
*[[dp#DisamAssist|DisamAssist]]
*{{sortnaam|dp|space}}
*{{sortnaam|dp|with space}}
*{{sortnaam|dp|with space|dp space#appel}}
*[[dp space#DisamAssist]]
*[[dp space#DisamAssist|OEIOEI]]
*[[dp with space#DisamAssist]]
*[[dp with space#DisamAssist|JAJA]]
*[[dp with space]]
*{{sortnaam|dp|with space|dp#appel}}
*[[dp]]
40ystj2sdj4fmj48i8krfoa1nj01y9c
781218
781215
2026-10-27T16:45:36Z
Rots61
44293
Link naar doorverwijspagina [[Dp space]] gewijzigd in [[Test]]; gewijzigd in [[Appeltje]]; gewijzigd in [[Genot]]; gewijzigd in [[Triviaal]] met [[Gebruiker:Midas02/DisamAssist|DisamAssist]].
781218
wikitext
text/x-wiki
*{{ill|Test|en|User:Rots61/DisamAssist}}
*{{ill|Appeltje|en|User:Rots61/DisamAssist|be|tralala|fr|lol}}
*{{ill|Genot||}}
*[[dp]]
*[[Test|DisamAssist]]
*[[Appeltje#DisamAssist|dp]]
*[[Genot#DisamAssist|DisamAssist]]
*{{sortnaam|dp|space|Triviaal}}
*{{sortnaam|dp|with space|Genot}}
*{{sortnaam|dp|with space|Appeltje#appel}}
*[[Test#DisamAssist|dp space]]
*[[Appeltje#DisamAssist|OEIOEI]]
*[[Genot#DisamAssist|dp with space]]
*[[Triviaal#DisamAssist|JAJA]]
*[[dp with space]]
*{{sortnaam|dp|with space|Genot#appel}}
*[[Appeltje|dp]]
l8v0mqad78hqjn6q4mkj0a3zejlmhvz
781219
781218
2026-10-27T16:47:49Z
Rots61
44293
Undid revision [[Special:Diff/781218|781218]] by [[Special:Contributions/Rots61|Rots61]] ([[User talk:Rots61|talk]])
781219
wikitext
text/x-wiki
*{{ill|dp|en|User:Rots61/DisamAssist}}
*{{ill|dp|en|User:Rots61/DisamAssist|be|tralala|fr|lol}}
*{{ill|dp with space}}
*[[dp]]
*[[dp|DisamAssist]]
*[[dp|DisamAssist]]
*[[dp|DisamAssist]]
*[[dp#DisamAssist]]
*[[dp#DisamAssist|DisamAssist]]
*{{sortnaam|dp|space}}
*{{sortnaam|dp|with space}}
*{{sortnaam|dp|with space|dp space#appel}}
*[[dp space#DisamAssist]]
*[[dp space#DisamAssist|OEIOEI]]
*[[dp with space#DisamAssist]]
*[[dp with space#DisamAssist|JAJA]]
*[[dp with space]]
*[[dp space]]
*[[dp with space]]
*{{sortnaam|dp|with space|dp#appel}}
*[[dp]]
gfbznedeho4qn3hykn823sdr3pp3l24
781220
781219
2026-10-27T16:48:22Z
Rots61
44293
Link naar doorverwijspagina [[Dp space]] gewijzigd in [[Test]] met [[Gebruiker:Midas02/DisamAssist|DisamAssist]].
781220
wikitext
text/x-wiki
*{{ill|Test|en|User:Rots61/DisamAssist}}
*{{ill|Test|en|User:Rots61/DisamAssist|be|tralala|fr|lol}}
*{{ill|Test||}}
*[[dp]]
*[[Test|DisamAssist]]
*[[Test|DisamAssist]]
*[[Test|DisamAssist]]
*[[Test#DisamAssist|dp]]
*[[Test#DisamAssist|DisamAssist]]
*{{sortnaam|dp|space|Test}}
*{{sortnaam|dp|with space|Test}}
*{{sortnaam|dp|with space|Test#appel}}
*[[dp space#DisamAssist]]
*[[Test#DisamAssist|OEIOEI]]
*[[dp with space#DisamAssist]]
*[[Test#DisamAssist|JAJA]]
*[[dp with space]]
*[[Test|dp space]]
*[[Test|dp with space]]
*{{sortnaam|dp|with space|Test#appel}}
*[[Test|dp]]
kpbsufvoaox1nv9h6lpkj427z1qjo3h
781222
781220
2026-10-27T16:50:19Z
Rots61
44293
Undid revision [[Special:Diff/781220|781220]] by [[Special:Contributions/Rots61|Rots61]] ([[User talk:Rots61|talk]])
781222
wikitext
text/x-wiki
*{{ill|dp|en|User:Rots61/DisamAssist}}
*{{ill|dp|en|User:Rots61/DisamAssist|be|tralala|fr|lol}}
*{{ill|dp with space}}
*[[dp]]
*[[dp|DisamAssist]]
*[[dp|DisamAssist]]
*[[dp|DisamAssist]]
*[[dp#DisamAssist]]
*[[dp#DisamAssist|DisamAssist]]
*{{sortnaam|dp|space}}
*{{sortnaam|dp|with space}}
*{{sortnaam|dp|with space|dp space#appel}}
*[[dp space#DisamAssist]]
*[[dp space#DisamAssist|OEIOEI]]
*[[dp with space#DisamAssist]]
*[[dp with space#DisamAssist|JAJA]]
*[[dp with space]]
*[[dp space]]
*[[dp with space]]
*{{sortnaam|dp|with space|dp#appel}}
*[[dp]]
gfbznedeho4qn3hykn823sdr3pp3l24
781223
781222
2026-10-27T16:50:44Z
Rots61
44293
Link naar doorverwijspagina [[Dp space]] gewijzigd in [[Appeltje]] met [[Gebruiker:Midas02/DisamAssist|DisamAssist]].
781223
wikitext
text/x-wiki
*{{ill|Appeltje|en|User:Rots61/DisamAssist}}
*{{ill|Appeltje|en|User:Rots61/DisamAssist|be|tralala|fr|lol}}
*{{ill|Appeltje||}}
*[[Appeltje|dp]]
*[[Appeltje|DisamAssist]]
*[[Appeltje|DisamAssist]]
*[[Appeltje|DisamAssist]]
*[[Appeltje#DisamAssist|dp]]
*[[Appeltje#DisamAssist|DisamAssist]]
*{{sortnaam|dp|space|Appeltje}}
*{{sortnaam|dp|with space|Appeltje}}
*{{sortnaam|dp|with space|Appeltje#appel}}
*[[Appeltje#DisamAssist|dp space]]
*[[Appeltje#DisamAssist|OEIOEI]]
*[[Appeltje#DisamAssist|dp with space]]
*[[Appeltje#DisamAssist|JAJA]]
*[[Appeltje|dp with space]]
*[[Appeltje|dp space]]
*[[Appeltje|dp with space]]
*{{sortnaam|dp|with space|Appeltje#appel}}
*[[Appeltje|dp]]
9ihozeep1uor2d9n4prq2rzlix9uhg6
781224
781223
2026-10-27T16:54:21Z
Rots61
44293
Undid revision [[Special:Diff/781223|781223]] by [[Special:Contributions/Rots61|Rots61]] ([[User talk:Rots61|talk]])
781224
wikitext
text/x-wiki
*{{ill|dp|en|User:Rots61/DisamAssist}}
*{{ill|dp|en|User:Rots61/DisamAssist|be|tralala|fr|lol}}
*{{ill|dp with space}}
*[[dp]]
*[[dp|DisamAssist]]
*[[dp|DisamAssist]]
*[[dp|DisamAssist]]
*[[dp#DisamAssist]]
*[[dp#DisamAssist|DisamAssist]]
*{{sortnaam|dp|space}}
*{{sortnaam|dp|with space}}
*{{sortnaam|dp|with space|dp space#appel}}
*[[dp space#DisamAssist]]
*[[dp space#DisamAssist|OEIOEI]]
*[[dp with space#DisamAssist]]
*[[dp with space#DisamAssist|JAJA]]
*[[dp with space]]
*[[dp space]]
*[[dp with space]]
*{{sortnaam|dp|with space|dp#appel}}
*[[dp]]
gfbznedeho4qn3hykn823sdr3pp3l24
781227
781224
2026-10-27T16:58:39Z
Rots61
44293
Link naar doorverwijspagina [[Dp space]] link overgeslagen; gewijzigd in [[Test]] met [[Gebruiker:Midas02/DisamAssist|DisamAssist]].
781227
wikitext
text/x-wiki
*{{ill|dp|en|User:Rots61/DisamAssist}}
*{{ill|dp|en|User:Rots61/DisamAssist|be|tralala|fr|lol}}
*{{ill|Test||}}
*[[dp]]
*[[dp|DisamAssist]]
*[[dp|DisamAssist]]
*[[dp|DisamAssist]]
*[[dp#DisamAssist]]
*[[dp#DisamAssist|DisamAssist]]
*{{sortnaam|dp|space}}
*{{sortnaam|dp|with space}}
*{{sortnaam|dp|with space|dp space#appel}}
*[[dp space#DisamAssist]]
*[[dp space#DisamAssist|OEIOEI]]
*[[dp with space#DisamAssist]]
*[[dp with space#DisamAssist|JAJA]]
*[[dp with space]]
*[[dp space]]
*[[dp with space]]
*{{sortnaam|dp|with space|dp#appel}}
*[[dp]]
8kptgcwqldpj7thi5p2ut80z28o5rp4
781230
781227
2026-10-27T17:05:36Z
Rots61
44293
Undid revision [[Special:Diff/781227|781227]] by [[Special:Contributions/Rots61|Rots61]] ([[User talk:Rots61|talk]])
781230
wikitext
text/x-wiki
*{{ill|dp|en|User:Rots61/DisamAssist}}
*{{ill|dp|en|User:Rots61/DisamAssist|be|tralala|fr|lol}}
*{{ill|dp with space}}
*[[dp]]
*[[dp|DisamAssist]]
*[[dp|DisamAssist]]
*[[dp|DisamAssist]]
*[[dp#DisamAssist]]
*[[dp#DisamAssist|DisamAssist]]
*{{sortnaam|dp|space}}
*{{sortnaam|dp|with space}}
*{{sortnaam|dp|with space|dp space#appel}}
*[[dp space#DisamAssist]]
*[[dp space#DisamAssist|OEIOEI]]
*[[dp with space#DisamAssist]]
*[[dp with space#DisamAssist|JAJA]]
*[[dp with space]]
*[[dp space]]
*[[dp with space]]
*{{sortnaam|dp|with space|dp#appel}}
*[[dp]]
gfbznedeho4qn3hykn823sdr3pp3l24
781231
781230
2026-10-27T17:06:18Z
Rots61
44293
Link naar doorverwijspagina [[Dp space]] gewijzigd in [[Test]] met [[Gebruiker:Midas02/DisamAssist|DisamAssist]].
781231
wikitext
text/x-wiki
*{{ill|Test|en|User:Rots61/DisamAssist}}
*{{ill|Test|en|User:Rots61/DisamAssist|be|tralala|fr|lol}}
*{{ill|Test||}}
*[[dp]]
*[[Test|DisamAssist]]
*[[Test|DisamAssist]]
*[[Test|DisamAssist]]
*[[Test#DisamAssist|dp]]
*[[Test#DisamAssist|DisamAssist]]
*{{sortnaam|dp|space|Test}}
*{{sortnaam|dp|with space|Test}}
*{{sortnaam|dp|with space|Test#appel}}
*[[dp space#DisamAssist]]
*[[Test#DisamAssist|OEIOEI]]
*[[dp with space#DisamAssist]]
*[[Test#DisamAssist|JAJA]]
*[[dp with space]]
*[[Test|dp space]]
*[[Test|dp with space]]
*{{sortnaam|dp|with space|Test#appel}}
*[[Test|dp]]
kpbsufvoaox1nv9h6lpkj427z1qjo3h
781232
781231
2026-10-27T17:06:33Z
Rots61
44293
Link naar doorverwijspagina [[Dp space]] gewijzigd in [[Test]] met [[Gebruiker:Midas02/DisamAssist|DisamAssist]].
781232
wikitext
text/x-wiki
*{{ill|Test|en|User:Rots61/DisamAssist}}
*{{ill|Test|en|User:Rots61/DisamAssist|be|tralala|fr|lol}}
*{{ill|Test||}}
*[[Test|dp]]
*[[Test|DisamAssist]]
*[[Test|DisamAssist]]
*[[Test|DisamAssist]]
*[[Test#DisamAssist|dp]]
*[[Test#DisamAssist|DisamAssist]]
*{{sortnaam|dp|space|Test}}
*{{sortnaam|dp|with space|Test}}
*{{sortnaam|dp|with space|Test#appel}}
*[[Test#DisamAssist|dp space]]
*[[Test#DisamAssist|OEIOEI]]
*[[Test#DisamAssist|dp with space]]
*[[Test#DisamAssist|JAJA]]
*[[Test|dp with space]]
*[[Test|dp space]]
*[[Test|dp with space]]
*{{sortnaam|dp|with space|Test#appel}}
*[[Test|dp]]
m8e3dj9y6ij8a2q1knejvivbsdterph
781233
781232
2026-10-27T17:08:13Z
Rots61
44293
781233
wikitext
text/x-wiki
*{{ill|dp|en|User:Rots61/DisamAssist}}
*{{ill|dp|en|User:Rots61/DisamAssist|be|tralala|fr|lol}}
*{{ill|dp with space}}
*[[dp]]
*[[dp|DisamAssist]]
*[[dp|DisamAssist]]
*[[dp|DisamAssist]]
*[[dp#DisamAssist]]
*[[dp#DisamAssist|DisamAssist]]
*{{sortnaam|dp|space}}
*{{sortnaam|dp|with space}}
*{{sortnaam|dp|with space|dp space#appel}}
*[[dp space#DisamAssist]]
*[[dp space#DisamAssist|OEIOEI]]
*[[dp with space#DisamAssist]]
*[[dp with space#DisamAssist|JAJA]]
*[[dp with space]]
*[[dp space]]
*[[dp with space]]
*{{sortnaam|dp|with space|dp#appel}}
*[[dp]]
[[File:test.png|[[dp space]]]]
7oxyrxaxfzlrurcqc9ipg0fq6n0qjc5
781234
781233
2026-10-27T17:08:39Z
Rots61
44293
781234
wikitext
text/x-wiki
*{{ill|dp|en|User:Rots61/DisamAssist}}
*{{ill|dp|en|User:Rots61/DisamAssist|be|tralala|fr|lol}}
*{{ill|dp with space}}
*[[dp]]
*[[dp|DisamAssist]]
*[[dp|DisamAssist]]
*[[dp|DisamAssist]]
*[[dp#DisamAssist]]
*[[dp#DisamAssist|DisamAssist]]
*{{sortnaam|dp|space}}
*{{sortnaam|dp|with space}}
*{{sortnaam|dp|with space|dp space#appel}}
*[[dp space#DisamAssist]]
*[[dp space#DisamAssist|OEIOEI]]
*[[dp with space#DisamAssist]]
*[[dp with space#DisamAssist|JAJA]]
*[[dp with space]]
*[[dp space]]
*[[dp with space]]
*{{sortnaam|dp|with space|dp#appel}}
*[[dp]]
[[File:test.png|thumb|[[dp space]]]]
4rm38xlih0bxj6vmkkb3sy3bv3x4rm0
Dp with space
0
189954
781180
2026-10-27T15:42:36Z
Rots61
44293
Redirected page to [[Dp]]
781180
wikitext
text/x-wiki
#redirect[[dp]]
hh7ggen5vrxyc235ci6hf3k5s56d2au
781185
781180
2026-10-27T15:47:42Z
Xqbot
25938
Fixing double redirect from [[Dp]] to [[Dp space]]
781185
wikitext
text/x-wiki
#REDIRECT [[Dp space]]
03rgtr6mrrf7w58wu3c5wp6tamo85a4
Dp
0
189955
781183
2026-10-27T15:44:02Z
Rots61
44293
Rots61 moved page [[Dp]] to [[Dp space]]
781183
wikitext
text/x-wiki
#REDIRECT [[Dp space]]
{{Redirect category shell|
{{R from move}}
}}
gnjo2tbgyxasv08gzjf1naou20p46vd
Template:User-ainb
10
189956
781244
2026-10-27T19:56:14Z
DVRTed
55993
copying https://en.wikipedia.org/w/index.php?title=Template:User-ainb&action=edit
781244
wikitext
text/x-wiki
<includeonly><span class="plainlinks userlinks ainb-userlinks">{{User-multi<noinclude>/template</noinclude>
| user = {{{1|{{{User|{{{user|}}}}}}}}}
| noping = {{#if:{{#invoke:IPAddress|isIpOrRange|{{{1|}}}}}|no|yes}}
| uic = yes
| 1 = t
| 2 = c
| 3 = bl
| 4 = efl
| 5 = pg <!-- experimental, page creations -->
| 6 = ca
| 7 = ct }}</span>{{#ifeq:{{NAMESPACE}}|Talk||{{#ifeq:{{NAMESPACE}}|Template||__NOINDEX__}}}}</includeonly><noinclude>
{{documentation}}
<!-- Please add categories to the /doc subpage; interwikis go to Wikidata, thank you! -->
</noinclude>
jl959yfojpmlnjjnh3j61rmoqqusj4n
Template:User-multi/template
10
189957
781246
2026-10-27T20:00:29Z
DVRTed
55993
copying https://en.wikipedia.org/w/index.php?title=Template:User-multi/template
781246
wikitext
text/x-wiki
{{#invoke:UserLinks|main|user=Example|User=Example}}{{#ifeq:{{{doc<noinclude>|yes</noinclude>}}}|yes
|{{documentation
|content=
{{#ifeq:{{#invoke:High-use|num|x|demo={{ROOTPAGENAME}}}}|many||{{High-use|demo={{ROOTPAGENAME}}}}}}
{{lua|Module:UserLinks}}
This is a user link template for use internally on Wikipedia.
=== Usage ===
The username can be defined via a named or an unnamed parameter, i.e.
* <code><nowiki>{{</nowiki>{{BASEPAGENAME}}<nowiki>|</nowiki>{{#ifeq:{{{User|¬}}}|¬||User}}{{#ifeq:{{{user|¬}}}|¬||user}}<nowiki>=USERNAME}}</nowiki></code> or
* <code><nowiki>{{</nowiki>{{BASEPAGENAME}}<nowiki>|USERNAME}}</nowiki></code>
=== Examples ===
* <code><nowiki>{{</nowiki>{{BASEPAGENAME}}<nowiki>|Jimbo Wales}}</nowiki></code> → {{ {{BASEPAGENAME}}|User=Jimbo Wales}}
In cases of usernames which contain = sign, the former syntax should be used, e.g.
* <code><nowiki>{{</nowiki>{{BASEPAGENAME}}<nowiki>|</nowiki>{{#ifeq:{{{User|¬}}}|¬||User}}{{#ifeq:{{{user|¬}}}|¬||user}}<nowiki>=Billbailey=legend}}</nowiki></code> → {{ {{BASEPAGENAME}}|User=Billbailey=legend}}
=== Parameters ===
{{#ifeq:{{{User|¬}}}|¬|
|* ''User'' is a required parameter, e.g. {{para|User|Jimbo Wales}}.
}}{{#ifeq:{{{user|¬}}}|¬|
|* ''user'' is a required parameter, e.g. {{para|user|Jimbo Wales}}.
}}{{#ifeq:{{{Project|¬}}}|¬|
|* ''Project'' defines a [[Wikipedia:Wikimedia sister projects|Wikimedia sister project]] to be used instead of the English Wikipedia, the correct code for the [[Help:Interwikimedia links|interwiki]] link should be used, e.g. {{para|Project|b}}.
}}{{#ifeq:{{{project|¬}}}|¬|
|* ''project'' defines a [[Wikipedia:Wikimedia sister projects|Wikimedia sister project]] to be used instead of the English Wikipedia, the correct code for the [[Help:Interwikimedia links|interwiki]] link should be used, e.g. {{para|project|b}}.
}}{{#ifeq:{{{Lang|¬}}}|¬|
|* ''Lang'' is the [[Help:Interlanguage links|interlanguage]] code for a project to be used instead of the English Wikipedia, e.g. {{para|Lang|es}}. This can be used in conjunction with the project parameter.
}}{{#ifeq:{{{lang|¬}}}|¬|
|* ''lang'' is the [[Help:Interlanguage links|interlanguage]] code for a project to be used instead of the English Wikipedia, e.g. {{para|lang|es}}. This can be used in conjunction with the project parameter.
}}{{#ifeq:{{{small|¬}}}|¬|
|* ''small'' causes a smaller text to be used, e.g. {{para|small|yes}}.
}}{{#ifeq:{{{separator|¬}}}|¬|
|* ''separator'' defines the symbol used to separate different links, e.g. {{para|separator|pipe}}. The default is a dot.
}}{{#ifeq:{{{sup|¬}}}|¬|
|* ''sup'' controls whether the output is in superscript, e.g. {{para|sup|yes}}.
}}
{{#ifexist:{{FULLPAGENAME}}/doc|{{{{FULLPAGENAME}}/doc}}}}
{{Userspace linking templates}}
|link box=This documentation is automatically generated by [[Template:User-multi/template]].
<br>Any [[WP:CAT|categories]] and documentation specific to this template should be added to the template's [{{fullurl:{{FULLPAGENAME}}/doc|action=edit&preload=Template:Category_interwiki/preload}} /doc] subpage.
}}
}}<includeonly>{{#ifeq:{{SUBPAGENAME}}|sandbox||[[Category:Username internal link templates]]}}</includeonly>
me1j4w8g7vgwk7qgzl17f6toydfr7vf
Truth in Soul
0
189958
781247
2026-10-27T20:01:43Z
Jaheim Ember
76112
Created page with "{{Use mdy dates|date=June 2023}} {{Infobox album | name = Truth in Soul | type = Studio | artist = [[Red Hot and Foo]] | cover = | alt = | released = September 13, 1998 | recorded = 1997–1998 | studio = [[The Sound Factory]] (Los Angeles) | venue = | genre = {{Flatlist| * [[Funk rock]] * [[funk metal]] * [[hard rock]]<ref name="allmusic"/> * [[ska]]<ref name="allmusic"/> }} | length = | label = [[Sony Records|Sony]] |..."
781247
wikitext
text/x-wiki
{{Use mdy dates|date=June 2023}}
{{Infobox album
| name = Truth in Soul
| type = Studio
| artist = [[Red Hot and Foo]]
| cover =
| alt =
| released = September 13, 1998
| recorded = 1997–1998
| studio = [[The Sound Factory]] (Los Angeles)
| venue =
| genre = {{Flatlist|
* [[Funk rock]]
* [[funk metal]]
* [[hard rock]]<ref name="allmusic"/>
* [[ska]]<ref name="allmusic"/>
}}
| length =
| label = [[Sony Records|Sony]]
| producer = [[Lenny Kravitz]
| prev_title = [[Show Me Your Soul (EP)|Show Me Your Soul]]
| prev_year = 1997
| next_title = [[Set the Body Up Right]]
| next_year = 2000
| misc = {{Singles
| name = Truth in Soul
| type = album
| single1 = [[True Men Don't Kill Coyotes]]
| single1date = 1998
| single2 = [[Istanbul (Not Constantinople) (song)|Istanbul (Not Constantinople)]]
| single2date = 1998
| single3 = Taste the Pain
| single3date = 1998
| single4 = The Statue Got Me High
| single4date = 1999
}}
}}
'''''Truth in Soul''''' is the second studio album by American [[Rock music|rock]] band [[Red Hot and Foo]]. It was released on September 13, 1998.
==Recording==
==Promotion==
==Track listing==
==Personnel==
==Charts==
==References==
{{reflist}}
{{Red Hot and Foo}}
{{Authority control}}
[[Category:Red Hot and Foo albums]]
[[Category:1998 albums]]
[[Category:Albums produced by Lenny Kravitz]]
[[Category:Sony Records albums]]
qh7iiqilbpamxhrwjhpdd3sd6ejqoka
781248
781247
2026-10-27T20:01:59Z
Jaheim Ember
76112
781248
wikitext
text/x-wiki
{{Use mdy dates|date=June 2023}}
{{Infobox album
| name = Truth in Soul
| type = Studio
| artist = [[Red Hot and Foo]]
| cover =
| alt =
| released = September 13, 1998
| recorded = 1997–1998
| studio = [[The Sound Factory]] (Los Angeles)
| venue =
| genre = {{Flatlist|
* [[Funk rock]]
* [[funk metal]]
* [[hard rock]]<ref name="allmusic"/>
* [[ska]]<ref name="allmusic"/>
}}
| length =
| label = [[Sony Records|Sony]]
| producer = [[Lenny Kravitz]]
| prev_title = [[Show Me Your Soul (EP)|Show Me Your Soul]]
| prev_year = 1997
| next_title = [[Set the Body Up Right]]
| next_year = 2000
| misc = {{Singles
| name = Truth in Soul
| type = album
| single1 = [[True Men Don't Kill Coyotes]]
| single1date = 1998
| single2 = [[Istanbul (Not Constantinople) (song)|Istanbul (Not Constantinople)]]
| single2date = 1998
| single3 = Taste the Pain
| single3date = 1998
| single4 = The Statue Got Me High
| single4date = 1999
}}
}}
'''''Truth in Soul''''' is the second studio album by American [[Rock music|rock]] band [[Red Hot and Foo]]. It was released on September 13, 1998.
==Recording==
==Promotion==
==Track listing==
==Personnel==
==Charts==
==References==
{{reflist}}
{{Red Hot and Foo}}
{{Authority control}}
[[Category:Red Hot and Foo albums]]
[[Category:1998 albums]]
[[Category:Albums produced by Lenny Kravitz]]
[[Category:Sony Records albums]]
qaf2n09ptbr93k8vspy5zaxjptynulj
Category:Albums produced by Dave Kelly (producer)
14
189959
781249
2026-10-27T20:03:57Z
Jaheim Ember
76112
Created page with "{{Portal|Record production}} {{DEFAULTSORT:Kelly, Dave}} [[Category:Albums by producer]]"
781249
wikitext
text/x-wiki
{{Portal|Record production}}
{{DEFAULTSORT:Kelly, Dave}}
[[Category:Albums by producer]]
7lvhu1b3s8xuzyfofh25qbcixlk7ppy
Category:Priarity Records soundtracks
14
189960
781250
2026-10-27T20:05:10Z
Jaheim Ember
76112
Created page with "[[Category:Soundtracks by record label]] [[Category:Priarity Records albums| Soundtrack]]"
781250
wikitext
text/x-wiki
[[Category:Soundtracks by record label]]
[[Category:Priarity Records albums| Soundtrack]]
r9qjb7uud31bnfhlvqdxtbm7yfp8nnl
Template:Consent
10
189961
781293
2026-10-28T00:36:50Z
UHHEFUH
76432
Created page with "Test"
781293
wikitext
text/x-wiki
Test
bop1vj5i98maix36pjrpgep1w6hnxfe
MediaWiki:Hiruwiki/modules/doppler.css
8
189962
781420
2026-10-28T10:38:48Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781420
css
text/css
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/* =========================================================
* Hiruwiki module: doppler — stylesheet
* Colours come from the --hw-* tokens in hiruwiki-shared.css,
* which the core loads first and which handle dark mode.
* The .hw-sound__* rules are repeated in every sound module's
* stylesheet (sound-wave, harmonics, sound-propagation, doppler) because
* the core loads one CSS file per module; keep them in sync.
* ========================================================= */
/* Card */
.hw-sound {
font-family: var(--hw-font);
font-size: 14px;
line-height: 1.4;
color: var(--hw-text-base);
background: var(--hw-bg-base);
border: 0.5px solid var(--hw-border);
border-radius: var(--hw-radius-lg);
padding: 12px 16px;
margin: 1em 0 0;
box-sizing: border-box;
}
.hw-sound__bar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.hw-sound__note,
.hw-sound__axis {
font-size: 12px;
color: var(--hw-text-muted);
}
/* Canvas */
.hw-sound__canvas {
display: block;
width: 100%;
border-radius: var(--hw-radius-sm);
background: var(--hw-bg-subtle);
touch-action: pan-y;
}
.hw-sound__axis {
display: flex;
justify-content: space-between;
margin: 4px 0 12px;
}
/* Control rows */
.hw-sound__row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 12px;
margin: 0 0 10px;
}
.hw-sound__row--disabled {
opacity: 0.4;
}
.hw-sound__label {
min-width: 110px;
color: var(--hw-text-muted);
}
.hw-sound__range {
flex: 1;
min-width: 120px;
accent-color: var(--hw-color-primary);
}
.hw-sound__out {
min-width: 72px;
text-align: right;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
/* Buttons and segmented choices */
.hw-sound__seg {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.hw-sound__btn {
font: inherit;
font-size: 13px;
padding: 4px 12px;
border: 0.5px solid var(--hw-border);
border-radius: var(--hw-radius-sm);
background: var(--hw-bg-base);
color: var(--hw-text-base);
cursor: pointer;
}
.hw-sound__btn:hover {
background: var(--hw-bg-subtle);
}
.hw-sound__btn[aria-pressed="true"] {
background: var(--hw-color-primary-subtle);
border-color: var(--hw-color-primary);
color: var(--hw-color-primary);
}
.hw-sound__btn:focus-visible,
.hw-sound__range:focus-visible {
outline: 2px solid var(--hw-color-primary);
outline-offset: 1px;
}
.hw-sound__play {
display: inline-flex;
align-items: center;
gap: 6px;
}
.hw-sound__icon {
font-size: 11px;
width: 12px;
text-align: center;
}
/* Stat cards */
.hw-sound__stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px;
margin: 8px 0 4px;
}
.hw-sound__stat {
background: var(--hw-bg-subtle);
border-radius: var(--hw-radius-sm);
padding: 8px 10px;
}
.hw-sound__stat span {
display: block;
font-size: 12px;
color: var(--hw-text-muted);
}
.hw-sound__stat b {
font-size: 17px;
font-variant-numeric: tabular-nums;
}
@media print {
.hw-sound__btn {
display: none;
}
}
/* Doppler: button pair, draggable canvas */
.hw-sound__btns {
display: flex;
gap: 8px;
}
.hw-doppler__canvas {
cursor: crosshair;
touch-action: none;
}
.hw-doppler__canvas:focus-visible {
outline: 2px solid var(--hw-color-primary);
outline-offset: 2px;
}
.hw-doppler__gap {
height: 12px;
}
9xsfpxocuyzt5i5vtv78rrny8ksxuw0
MediaWiki:Hiruwiki/modules/doppler.js
8
189963
781421
2026-10-28T10:38:51Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781421
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/* =========================================================
* Hiruwiki module: doppler
* Acoustic Doppler effect: a source moves along a line and
* emits circular wavefronts; the reader drags the listener
* anywhere and hears the shifted frequency.
*
* <div class="hiruwiki" data-module="doppler"
* data-speed="0.5" data-frequency="400"></div>
*
* data-speed source speed as a Mach number, 0–1.5,
* default 0.5
* data-frequency emitted frequency, 200–1000 Hz, default 400
*
* Physics: the received frequency is computed from where the
* source was when the sound now arriving left it (retarded
* time), f' = f / (1 − M·cos θ), with θ the angle between the
* source's velocity and the line to the listener. So the shift
* starts only when the wavefronts reach the listener, there is
* silence ahead of a supersonic source, and the Mach cone is
* drawn for M > 1. Volume falls off with distance.
*
* The motion on screen is slowed down; the pitch is not, since
* it depends only on M and θ. Only a moving source and a still
* listener are modelled.
*
* The listener can be dragged, or moved with the arrow keys
* when the canvas has focus.
* =========================================================
* i18n: add a new key to the `messages` object below and
* a matching entry for every language code you need.
* Banana falls back to 'en' when a key is missing.
* ========================================================= */
( function () {
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"fr": {
"_name": "Effet Doppler",
"hint": "En approchant, les ondes se resserrent (son plus aigu) ; en s'éloignant, elles s'espacent (plus grave)",
"play": "Écouter",
"stop": "Arrêter",
"restart": "Recommencer",
"noAudio": "Votre navigateur ne peut pas jouer de son",
"dragNote": "Faites glisser l'auditeur · animation ralentie",
"speed": "Vitesse de la source",
"emitted": "Émise par la source",
"received": "Reçue par l'auditeur",
"change": "Variation",
"silence": "Silence",
"notArrived": "Le son n'est pas encore arrivé",
"shock": "Onde de choc !",
"pileUp": "Fronts d'onde empilés",
"higher": "plus aigu",
"lower": "plus grave",
"listener": "auditeur",
"source": "source",
"cone": "Cône de Mach (onde de choc)",
"canvasLabel": "Une source sonore en mouvement et un auditeur. Utilisez les flèches pour déplacer l'auditeur."
},
"eu": {
"_name": "Doppler efektua",
"hint": "Hurbiltzean uhinak estutu egiten dira (goi-soinua); urruntzean, zabaldu (behe-soinua)",
"play": "Entzun",
"stop": "Gelditu",
"restart": "Berriro",
"noAudio": "Zure nabigatzaileak ezin du soinurik erreproduzitu",
"dragNote": "Arrastatu entzulea · animazioa moteldua",
"speed": "Iturriaren abiadura",
"emitted": "Iturriak igorria",
"received": "Entzuleak jasoa",
"change": "Aldaketa",
"silence": "Isiltasuna",
"notArrived": "Soinua ez da iritsi",
"shock": "Talka-uhina!",
"pileUp": "Uhinak pilatuta",
"higher": "altuago",
"lower": "baxuago",
"listener": "entzulea",
"source": "iturria",
"cone": "Mach-en konoa (talka-uhina)",
"canvasLabel": "Mugitzen ari den soinu-iturri bat eta entzule bat. Erabili geziak entzulea mugitzeko."
},
"en": {
"_name": "Doppler effect",
"hint": "Approaching, the waves bunch up (higher pitch); moving away, they spread out (lower pitch)",
"play": "Listen",
"stop": "Stop",
"restart": "Restart",
"noAudio": "Your browser can't play sound",
"dragNote": "Drag the listener · animation slowed down",
"speed": "Source speed",
"emitted": "Emitted by the source",
"received": "Received by the listener",
"change": "Change",
"silence": "Silence",
"notArrived": "The sound hasn't arrived yet",
"shock": "Shock wave!",
"pileUp": "Wavefronts piled up",
"higher": "higher",
"lower": "lower",
"listener": "listener",
"source": "source",
"cone": "Mach cone (shock wave)",
"canvasLabel": "A moving sound source and a listener. Use the arrow keys to move the listener."
},
"qqq": {
"_name": "Name of the Doppler module",
"hint": "Instruction text shown in the footer.",
"play": "Label of the button that starts the sound.",
"stop": "Label of the button that stops the sound.",
"restart": "Button that restarts the source's pass from the left.",
"noAudio": "Shown when the browser has no Web Audio support.",
"dragNote": "Note above the canvas: the listener can be dragged; the motion is slowed down.",
"speed": "Label of the source speed slider (shown in m/s and as a Mach number).",
"emitted": "Stat card: frequency emitted by the source.",
"received": "Stat card: frequency heard by the listener.",
"change": "Stat card: percentage change between emitted and received frequency.",
"silence": "Received-frequency value when no sound has reached the listener.",
"notArrived": "Change value when no sound has reached the listener.",
"shock": "Received-frequency value at the moment the shock wave arrives.",
"pileUp": "Change value at the moment the shock wave arrives.",
"higher": "Suffix after a positive percentage change (higher pitch).",
"lower": "Suffix after a negative percentage change (lower pitch).",
"listener": "Canvas label above the listener dot.",
"source": "Canvas label under the moving source.",
"cone": "Canvas label for the Mach cone, shown above Mach 1.",
"canvasLabel": "Accessible label of the canvas, including the keyboard hint."
},
"es": {
"_name": "Efecto Doppler",
"hint": "Al acercarse, las ondas se apiñan (sonido más agudo); al alejarse, se separan (más grave)",
"play": "Escuchar",
"stop": "Detener",
"restart": "Reiniciar",
"noAudio": "Tu navegador no puede reproducir sonido",
"dragNote": "Arrastra al oyente · animación ralentizada",
"speed": "Velocidad de la fuente",
"emitted": "Emitida por la fuente",
"received": "Recibida por el oyente",
"change": "Cambio",
"silence": "Silencio",
"notArrived": "El sonido aún no ha llegado",
"shock": "¡Onda de choque!",
"pileUp": "Frentes de onda apilados",
"higher": "más agudo",
"lower": "más grave",
"listener": "oyente",
"source": "fuente",
"cone": "Cono de Mach (onda de choque)",
"canvasLabel": "Una fuente de sonido en movimiento y un oyente. Usa las flechas para mover al oyente."
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
function t( key, params ) {
return banana.i18n.apply( banana, [ key ].concat( params || [] ) );
}
/* ── Constants ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var H_PX = 300; /* canvas height (px) */
var C_VIS = 90; /* on-screen speed of sound (px per real s) */
var EMIT = 0.3; /* interval between drawn wavefronts (s) */
var PATH_Y = 0.6; /* source path, as a fraction of the height */
var V_SOUND = 343; /* m/s, for the speed readout */
var MAX_GAIN = 0.13; /* never louder than this */
var MAX_F = 4000; /* cap on the played frequency (Hz) */
var KEY_STEP = 10; /* listener move per arrow-key press (px) */
var uid = 0;
/* ── Shared helpers ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
function el( tag, cls, text ) {
var e = document.createElement( tag );
if ( cls ) { e.className = cls; }
if ( text !== undefined ) { e.textContent = text; }
return e;
}
function num( v, min, max, def ) {
var n = parseFloat( v );
if ( isNaN( n ) ) { return def; }
return Math.max( min, Math.min( max, n ) );
}
function fmt( n, d ) {
try {
return n.toLocaleString( lang, { minimumFractionDigits: d, maximumFractionDigits: d } );
} catch ( e ) {
return n.toFixed( d );
}
}
function isDark() {
var root = document.documentElement;
return root.classList.contains( 'skin-theme-clientpref-night' ) ||
root.classList.contains( 'client-dark-mode' ) ||
document.body.classList.contains( 'mw-dark-mode' ) ||
( root.classList.contains( 'skin-theme-clientpref-os' ) &&
window.matchMedia && window.matchMedia( '(prefers-color-scheme: dark)' ).matches );
}
function palette() {
var d = isDark();
return {
wave: d ? '93,202,165' : '15,110,86', /* rgb, alpha added per wavefront */
source: d ? '#FAC775' : '#BA7517',
listener: d ? '#AFA9EC' : '#534AB7',
cone: d ? '#F0997B' : '#D85A30',
path: d ? '#4a4a47' : '#c9c7be',
text: d ? '#B4B2A9' : '#5F5E5A'
};
}
function audioHub() {
window.hiruwiki = window.hiruwiki || {};
if ( !window.hiruwiki.audio ) {
var hub = {
ctx: null,
current: null,
context: function () {
if ( !hub.ctx ) {
var AC = window.AudioContext || window.webkitAudioContext;
if ( !AC ) { return null; }
hub.ctx = new AC();
}
if ( hub.ctx.state === 'suspended' ) { hub.ctx.resume(); }
return hub.ctx;
},
claim: function ( owner ) {
if ( hub.current && hub.current !== owner ) { hub.current.stop(); }
hub.current = owner;
},
release: function ( owner ) {
if ( hub.current === owner ) { hub.current = null; }
}
};
document.addEventListener( 'visibilitychange', function () {
if ( document.hidden && hub.current ) { hub.current.stop(); }
} );
window.hiruwiki.audio = hub;
}
return window.hiruwiki.audio;
}
function makeFooter( container, text ) {
var footer = el( 'div', 'hw-footer' );
var logo = el( 'a', 'hw-footer-icon' );
logo.href = ( window.mw && mw.util && mw.util.getUrl( 'Wikipedia:Hiruwiki' ) ) || '#';
logo.title = 'Hiruwiki';
if ( window.hiruwiki && window.hiruwiki.getLogoSvg ) {
logo.insertAdjacentHTML( 'beforeend', window.hiruwiki.getLogoSvg( 22 ) );
}
footer.appendChild( logo );
footer.appendChild( el( 'span', 'hw-footer__text', text ) );
container.appendChild( footer );
}
function makeSlider( parent, label, min, max, step, value ) {
var id = 'hw-sound-' + ( ++uid );
var row = el( 'div', 'hw-sound__row' );
var lab = el( 'label', 'hw-sound__label', label );
lab.htmlFor = id;
var input = el( 'input', 'hw-sound__range' );
input.type = 'range'; input.id = id;
input.min = min; input.max = max; input.step = step; input.value = value;
var out = el( 'output', 'hw-sound__out' );
out.setAttribute( 'for', id );
row.appendChild( lab ); row.appendChild( input ); row.appendChild( out );
parent.appendChild( row );
return { row: row, input: input, out: out };
}
function makeButton( label, iconText ) {
var b = el( 'button', 'hw-sound__btn hw-sound__play' );
b.type = 'button';
var icon = el( 'span', 'hw-sound__icon', iconText );
icon.setAttribute( 'aria-hidden', 'true' );
var txt = el( 'span', null, label );
b.appendChild( icon ); b.appendChild( txt );
return { el: b, icon: icon, txt: txt };
}
function animator( target, frame ) {
var visible = true, raf = null, last = 0;
var reduce = window.matchMedia && window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches;
function tick( now ) {
raf = null;
var dt = last ? Math.min( ( now - last ) / 1000, 0.05 ) : 0;
last = now;
frame( reduce ? 0 : dt );
if ( visible && !reduce ) { raf = requestAnimationFrame( tick ); }
}
function start() {
if ( !raf ) { last = 0; raf = requestAnimationFrame( tick ); }
}
if ( 'IntersectionObserver' in window ) {
new IntersectionObserver( function ( entries ) {
visible = entries[ 0 ].isIntersecting;
if ( visible ) { start(); }
} ).observe( target );
}
start();
return { redraw: function () { frame( 0 ); }, reduced: reduce };
}
/* ── Build widget ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
function buildWidget( container ) {
var ds = container.dataset;
var F0 = num( ds.frequency, 200, 1000, 400 );
var state = {
mach: num( ds.speed, 0, 1.5, 0.5 ),
t: 0, /* simulated time (s) */
sx: 0, /* source x (px) */
nextEmit: 0, /* time of the next drawn wavefront */
fronts: [], /* drawn wavefronts: { t, x } */
hist: [], /* source track for retarded time: { t, x } */
obs: { fx: 0.72, fy: 0.25 } /* listener, as fractions of W, H */
};
var W = 600;
container.innerHTML = '';
var root = el( 'div', 'hw-sound' );
container.appendChild( root );
/* -- top bar -- */
var bar = el( 'div', 'hw-sound__bar' );
var btns = el( 'div', 'hw-sound__btns' );
var play = makeButton( t( 'play' ), '▶' );
var restart = makeButton( t( 'restart' ), '↺' );
play.el.setAttribute( 'aria-pressed', 'false' );
btns.appendChild( play.el );
btns.appendChild( restart.el );
bar.appendChild( btns );
bar.appendChild( el( 'span', 'hw-sound__note', t( 'dragNote' ) ) );
root.appendChild( bar );
/* -- canvas -- */
var canvas = el( 'canvas', 'hw-sound__canvas hw-doppler__canvas' );
canvas.style.height = H_PX + 'px';
canvas.tabIndex = 0;
canvas.setAttribute( 'role', 'img' );
canvas.setAttribute( 'aria-label', t( 'canvasLabel' ) );
root.appendChild( canvas );
var ctx = canvas.getContext( '2d' );
root.appendChild( el( 'div', 'hw-doppler__gap' ) );
/* -- speed slider -- */
var mSlider = makeSlider( root, t( 'speed' ), 0, 150, 1, Math.round( state.mach * 100 ) );
mSlider.input.addEventListener( 'input', function () {
state.mach = +mSlider.input.value / 100;
updateSpeed();
} );
/* -- stat cards -- */
var stats = el( 'div', 'hw-sound__stats' );
function stat( label ) {
var c = el( 'div', 'hw-sound__stat' );
c.appendChild( el( 'span', null, label ) );
var b = el( 'b' );
c.appendChild( b );
stats.appendChild( c );
return b;
}
var sEmitted = stat( t( 'emitted' ) );
var sReceived = stat( t( 'received' ) );
var sChange = stat( t( 'change' ) );
sEmitted.textContent = fmt( F0, 0 ) + ' Hz';
sReceived.setAttribute( 'aria-live', 'off' );
root.appendChild( stats );
makeFooter( container, t( 'hint' ) );
/* -- simulation -- */
function pathY() { return H_PX * PATH_Y; }
function obsPt() { return { x: state.obs.fx * W, y: state.obs.fy * H_PX }; }
function diag() { return Math.hypot( W, H_PX ); }
function reset() {
state.t = 0;
state.sx = -30;
state.nextEmit = 0;
state.fronts = [];
state.hist = [];
}
function step( dt ) {
state.t += dt;
state.sx += state.mach * C_VIS * dt;
state.hist.push( { t: state.t, x: state.sx } );
var maxAge = diag() / C_VIS + 1;
while ( state.hist.length && state.t - state.hist[ 0 ].t > maxAge ) { state.hist.shift(); }
while ( state.t >= state.nextEmit ) {
state.fronts.push( { t: state.nextEmit, x: state.sx } );
state.nextEmit += EMIT;
}
state.fronts = state.fronts.filter( function ( f ) {
return C_VIS * ( state.t - f.t ) < diag() + 40;
} );
/* restart once the source has left and its last waves have cleared */
if ( state.sx > W + 30 && !state.fronts.length ) { reset(); }
}
/* Retarded time: find the source position whose sound is reaching the
* listener now, i.e. where c·(t − tₑ) = |listener − source(tₑ)|.
* Scanning from the newest sample backwards returns the latest
* emission, which is unique below Mach 1. Returns null for silence. */
function received() {
var o = obsPt(), y = pathY(), h = state.hist;
for ( var i = h.length - 1; i > 0; i-- ) {
var a = h[ i ], b = h[ i - 1 ];
var ga = C_VIS * ( state.t - a.t ) - Math.hypot( o.x - a.x, o.y - y );
var gb = C_VIS * ( state.t - b.t ) - Math.hypot( o.x - b.x, o.y - y );
if ( ga <= 0 && gb > 0 ) {
var k = ga / ( ga - gb );
var ex = a.x + ( b.x - a.x ) * k;
var d = Math.hypot( o.x - ex, o.y - y ) || 1;
var den = 1 - state.mach * ( o.x - ex ) / d;
return { f: den > 0.05 ? F0 / den : null, d: d };
}
}
return null;
}
/* -- drawing -- */
function draw() {
var p = palette(), y = pathY(), o = obsPt(), sx = state.sx, M = state.mach;
ctx.clearRect( 0, 0, W, H_PX );
ctx.strokeStyle = p.path;
ctx.lineWidth = 1;
ctx.setLineDash( [ 4, 6 ] );
ctx.beginPath(); ctx.moveTo( 0, y ); ctx.lineTo( W, y ); ctx.stroke();
ctx.setLineDash( [] );
state.fronts.forEach( function ( f ) {
var r = C_VIS * ( state.t - f.t );
var a = Math.max( 0.12, 1 - r / ( W * 0.9 ) );
ctx.strokeStyle = 'rgba(' + p.wave + ',' + a.toFixed( 3 ) + ')';
ctx.lineWidth = 1.6;
ctx.beginPath(); ctx.arc( f.x, y, r, 0, 2 * Math.PI ); ctx.stroke();
} );
ctx.font = '12px sans-serif';
if ( M > 1 ) {
var ang = Math.asin( 1 / M ), L = diag();
ctx.strokeStyle = p.cone;
ctx.lineWidth = 2;
[ 1, -1 ].forEach( function ( s ) {
ctx.beginPath();
ctx.moveTo( sx, y );
ctx.lineTo( sx - L * Math.cos( ang ), y + s * L * Math.sin( ang ) );
ctx.stroke();
} );
ctx.fillStyle = p.cone;
ctx.textAlign = 'left';
ctx.fillText( t( 'cone' ), 10, H_PX - 10 );
}
/* source + direction arrow */
ctx.fillStyle = p.source;
ctx.beginPath(); ctx.arc( sx, y, 8, 0, 2 * Math.PI ); ctx.fill();
if ( M > 0 ) {
ctx.strokeStyle = p.source;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo( sx + 12, y ); ctx.lineTo( sx + 30, y );
ctx.lineTo( sx + 25, y - 4 ); ctx.moveTo( sx + 30, y ); ctx.lineTo( sx + 25, y + 4 );
ctx.stroke();
}
/* listener */
ctx.fillStyle = p.listener;
ctx.beginPath(); ctx.arc( o.x, o.y, 9, 0, 2 * Math.PI ); ctx.fill();
if ( document.activeElement === canvas ) {
ctx.strokeStyle = p.listener;
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc( o.x, o.y, 14, 0, 2 * Math.PI ); ctx.stroke();
}
ctx.fillStyle = p.text;
ctx.textAlign = 'center';
ctx.fillText( t( 'listener' ), o.x, o.y - 16 );
ctx.fillText( t( 'source' ), sx, y + 24 );
}
/* -- readouts and audio -- */
function setText( node, text ) {
if ( node.textContent !== text ) { node.textContent = text; }
}
function readouts() {
var r = received();
if ( !r ) {
setText( sReceived, t( 'silence' ) );
setText( sChange, t( 'notArrived' ) );
} else if ( r.f === null ) {
setText( sReceived, t( 'shock' ) );
setText( sChange, t( 'pileUp' ) );
} else {
setText( sReceived, fmt( r.f, 0 ) + ' Hz' );
var pct = ( r.f / F0 - 1 ) * 100;
var word = pct > 0.5 ? ' (' + t( 'higher' ) + ')' : ( pct < -0.5 ? ' (' + t( 'lower' ) + ')' : '' );
setText( sChange, ( pct >= 0 ? '+' : '−' ) + fmt( Math.abs( pct ), 0 ) + ' %' + word );
}
voice.follow( r );
}
var voice = {
playing: false, osc: null, gain: null, ac: null,
start: function () {
var hub = audioHub();
var ac = hub.context();
if ( !ac ) { play.el.title = t( 'noAudio' ); return; }
hub.claim( voice );
voice.ac = ac;
voice.osc = ac.createOscillator();
voice.osc.type = 'triangle';
voice.osc.frequency.value = F0;
voice.gain = ac.createGain();
voice.gain.gain.value = 0;
voice.osc.connect( voice.gain ).connect( ac.destination );
voice.osc.start();
voice.playing = true;
setPlayUI();
},
stop: function () {
if ( !voice.playing ) { return; }
var o = voice.osc;
voice.gain.gain.setTargetAtTime( 0, voice.ac.currentTime, 0.02 );
setTimeout( function () { o.stop(); o.disconnect(); }, 150 );
voice.playing = false;
audioHub().release( voice );
setPlayUI();
},
follow: function ( r ) {
if ( !voice.playing ) { return; }
var now = voice.ac.currentTime;
if ( r && r.f ) {
voice.osc.frequency.setTargetAtTime( Math.min( r.f, MAX_F ), now, 0.03 );
voice.gain.gain.setTargetAtTime( MAX_GAIN * Math.min( 1, 120 / r.d ), now, 0.05 );
} else {
voice.gain.gain.setTargetAtTime( 0, now, 0.03 );
}
}
};
function setPlayUI() {
play.icon.textContent = voice.playing ? '❚❚' : '▶';
play.txt.textContent = voice.playing ? t( 'stop' ) : t( 'play' );
play.el.setAttribute( 'aria-pressed', voice.playing ? 'true' : 'false' );
}
function updateSpeed() {
mSlider.out.textContent = fmt( state.mach * V_SOUND, 0 ) + ' m/s · Mach ' + fmt( state.mach, 2 );
if ( anim && anim.reduced ) { prerun(); }
if ( anim ) { anim.redraw(); }
}
/* With reduced motion, show one frozen moment of a pass instead. */
function prerun() {
reset();
var target = ( W * 0.45 + 30 ) / Math.max( state.mach * C_VIS, 1 );
target = Math.min( target, 4 );
for ( var s = 0; s < target; s += 0.02 ) { step( 0.02 ); }
}
function frame( dt ) {
if ( dt > 0 ) { step( dt ); }
draw();
readouts();
}
/* -- events -- */
play.el.addEventListener( 'click', function () {
if ( voice.playing ) { voice.stop(); } else { voice.start(); }
} );
restart.el.addEventListener( 'click', function () {
if ( anim.reduced ) { prerun(); } else { reset(); }
anim.redraw();
} );
var dragging = false;
function moveTo( e ) {
var rect = canvas.getBoundingClientRect();
var x = Math.max( 10, Math.min( W - 10, e.clientX - rect.left ) );
var y = Math.max( 10, Math.min( H_PX - 10, e.clientY - rect.top ) );
state.obs.fx = x / W;
state.obs.fy = y / H_PX;
anim.redraw();
}
canvas.addEventListener( 'pointerdown', function ( e ) {
dragging = true;
canvas.setPointerCapture( e.pointerId );
moveTo( e );
} );
canvas.addEventListener( 'pointermove', function ( e ) { if ( dragging ) { moveTo( e ); } } );
canvas.addEventListener( 'pointerup', function () { dragging = false; } );
canvas.addEventListener( 'pointercancel', function () { dragging = false; } );
canvas.addEventListener( 'keydown', function ( e ) {
var o = obsPt(), dx = 0, dy = 0;
if ( e.key === 'ArrowLeft' ) { dx = -KEY_STEP; }
else if ( e.key === 'ArrowRight' ) { dx = KEY_STEP; }
else if ( e.key === 'ArrowUp' ) { dy = -KEY_STEP; }
else if ( e.key === 'ArrowDown' ) { dy = KEY_STEP; }
else { return; }
e.preventDefault();
state.obs.fx = Math.max( 10, Math.min( W - 10, o.x + dx ) ) / W;
state.obs.fy = Math.max( 10, Math.min( H_PX - 10, o.y + dy ) ) / H_PX;
anim.redraw();
} );
canvas.addEventListener( 'focus', function () { anim.redraw(); } );
canvas.addEventListener( 'blur', function () { anim.redraw(); } );
function resize() {
var dpr = window.devicePixelRatio || 1;
W = canvas.clientWidth || W;
canvas.width = Math.round( W * dpr );
canvas.height = Math.round( H_PX * dpr );
ctx.setTransform( dpr, 0, 0, dpr, 0, 0 );
if ( anim.reduced ) { prerun(); }
anim.redraw();
}
reset();
var anim = animator( canvas, frame );
new ResizeObserver( resize ).observe( canvas );
/* dark-mode live update (MediaWiki) */
var mo = new MutationObserver( function () { anim.redraw(); } );
mo.observe( document.documentElement, { attributes: true, attributeFilter: [ 'class' ] } );
mo.observe( document.body, { attributes: true, attributeFilter: [ 'class' ] } );
setPlayUI();
updateSpeed();
}
/* ----------------------------------------------------------
* Initialise all matching containers on the page
* ---------------------------------------------------------- */
document.querySelectorAll( '.hiruwiki[data-module="doppler"]' ).forEach( buildWidget );
}() );
prdzn6g9a65oo99n52n7at23mju808n
MediaWiki:Hiruwiki/modules/echo.css
8
189964
781422
2026-10-28T10:38:54Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781422
css
text/css
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/* =========================================================
* Hiruwiki module: echo — stylesheet
* Colours come from the --hw-* tokens in hiruwiki-shared.css,
* which the core loads first and which handle dark mode.
* The .hw-sound__* rules are repeated in every sound module's
* stylesheet (sound-wave, harmonics, sound-propagation, doppler, echo) because
* the core loads one CSS file per module; keep them in sync.
* ========================================================= */
/* Card */
.hw-sound {
font-family: var(--hw-font);
font-size: 14px;
line-height: 1.4;
color: var(--hw-text-base);
background: var(--hw-bg-base);
border: 0.5px solid var(--hw-border);
border-radius: var(--hw-radius-lg);
padding: 12px 16px;
margin: 1em 0 0;
box-sizing: border-box;
}
.hw-sound__bar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.hw-sound__note,
.hw-sound__axis {
font-size: 12px;
color: var(--hw-text-muted);
}
/* Canvas */
.hw-sound__canvas {
display: block;
width: 100%;
border-radius: var(--hw-radius-sm);
background: var(--hw-bg-subtle);
touch-action: pan-y;
}
.hw-sound__axis {
display: flex;
justify-content: space-between;
margin: 4px 0 12px;
}
/* Control rows */
.hw-sound__row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 12px;
margin: 0 0 10px;
}
.hw-sound__row--disabled {
opacity: 0.4;
}
.hw-sound__label {
min-width: 110px;
color: var(--hw-text-muted);
}
.hw-sound__range {
flex: 1;
min-width: 120px;
accent-color: var(--hw-color-primary);
}
.hw-sound__out {
min-width: 72px;
text-align: right;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
/* Buttons and segmented choices */
.hw-sound__seg {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.hw-sound__btn {
font: inherit;
font-size: 13px;
padding: 4px 12px;
border: 0.5px solid var(--hw-border);
border-radius: var(--hw-radius-sm);
background: var(--hw-bg-base);
color: var(--hw-text-base);
cursor: pointer;
}
.hw-sound__btn:hover {
background: var(--hw-bg-subtle);
}
.hw-sound__btn[aria-pressed="true"] {
background: var(--hw-color-primary-subtle);
border-color: var(--hw-color-primary);
color: var(--hw-color-primary);
}
.hw-sound__btn:focus-visible,
.hw-sound__range:focus-visible {
outline: 2px solid var(--hw-color-primary);
outline-offset: 1px;
}
.hw-sound__play {
display: inline-flex;
align-items: center;
gap: 6px;
}
.hw-sound__icon {
font-size: 11px;
width: 12px;
text-align: center;
}
/* Stat cards */
.hw-sound__stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px;
margin: 8px 0 4px;
}
.hw-sound__stat {
background: var(--hw-bg-subtle);
border-radius: var(--hw-radius-sm);
padding: 8px 10px;
}
.hw-sound__stat span {
display: block;
font-size: 12px;
color: var(--hw-text-muted);
}
.hw-sound__stat b {
font-size: 17px;
font-variant-numeric: tabular-nums;
}
@media print {
.hw-sound__btn {
display: none;
}
}
/* Echo: space between canvas and slider */
.hw-echo__gap {
height: 12px;
}
monjnhdx7rxptaul74abltixchpz4yf
MediaWiki:Hiruwiki/modules/echo.js
8
189965
781423
2026-10-28T10:38:57Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781423
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/* =========================================================
* Hiruwiki module: echo
* Echo and reverberation. A person claps in front of a wall;
* the reader sets the wall distance and hears the clap and its
* reflection with the real delay, t = 2d / v.
*
* top — side view: the wavefront leaves the person, hits
* the wall and comes back (drawn with the image-source
* method: the reflection is a circle centred on the
* person's mirror image behind the wall)
* bottom — timeline with the direct clap at 0 and the echo at
* 2d/v; the first 0,1 s is shaded as the zone where
* the ear fuses both into one longer sound
*
* <div class="hiruwiki" data-module="echo" data-distance="25"></div>
*
* data-distance wall distance in metres, 1–500, default 25
*
* Real time: the wave moves at the speed of sound and is driven
* by the audio clock, so what the reader sees and hears happens
* together. At a few metres the round trip lasts a blink; at a
* few hundred metres (a valley, a cliff) it takes seconds. The
* scene zooms so the wall stays in view, and the slider is
* logarithmic so short and long distances are both easy to pick.
*
* Threshold: the ear separates two sounds about 0,1 s apart, so
* the wall must be more than 343 × 0,1 / 2 ≈ 17 m away (air at
* 20 °C). Closer than that the reflection merges with the clap
* (reverberation); further away it is heard as an echo.
*
* With reduced motion the module draws a static picture and
* still plays the sound.
* =========================================================
* i18n: add a new key to the `messages` object below and
* a matching entry for every language code you need.
* Banana falls back to 'en' when a key is missing.
* ========================================================= */
( function () {
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"fr": {
"_name": "Écho et réverbération",
"hint": "L'oreille a besoin d'environ 0,1 s entre deux sons pour les distinguer : le mur doit être à plus de 17 m pour entendre un écho",
"clap": "Taper dans les mains",
"noAudio": "Votre navigateur ne peut pas jouer de son",
"note": "Son et onde en temps réel",
"distance": "Distance au mur",
"delay": "Retard de l'écho",
"heard": "Ce que vous entendez",
"echo": "Écho : deux sons",
"reverb": "Réverbération : un son plus long",
"person": "vous",
"wall": "mur",
"timeAxis": "temps (ms)",
"fusion": "fusionnés par l'oreille",
"direct": "claquement",
"reflected": "écho",
"canvasLabel": "Une personne devant un mur, et une ligne de temps avec le claquement et son écho."
},
"eu": {
"_name": "Oihartzuna eta erreberberazioa",
"hint": "Belarriak 0,1 s inguru behar ditu bi soinu bereizteko: horma 17 m baino urrunago egon behar da oihartzuna entzuteko",
"clap": "Txalo egin",
"noAudio": "Zure nabigatzaileak ezin du soinurik erreproduzitu",
"note": "Soinua eta uhina denbora errealean",
"distance": "Hormarainoko distantzia",
"delay": "Oihartzunaren atzerapena",
"heard": "Entzuten duzuna",
"echo": "Oihartzuna: bi soinu",
"reverb": "Erreberberazioa: soinu luzeago bat",
"person": "zu",
"wall": "horma",
"timeAxis": "denbora (ms)",
"fusion": "belarriak batzen ditu",
"direct": "txaloa",
"reflected": "oihartzuna",
"canvasLabel": "Pertsona bat horma baten aurrean, eta denbora-lerro bat txaloarekin eta haren oihartzunarekin."
},
"en": {
"_name": "Echo and reverberation",
"hint": "The ear needs about 0.1 s between two sounds to tell them apart: the wall must be more than 17 m away to hear an echo",
"clap": "Clap",
"noAudio": "Your browser can't play sound",
"note": "Sound and wave in real time",
"distance": "Distance to the wall",
"delay": "Echo delay",
"heard": "What you hear",
"echo": "Echo: two sounds",
"reverb": "Reverberation: one longer sound",
"person": "you",
"wall": "wall",
"timeAxis": "time (ms)",
"fusion": "fused by the ear",
"direct": "clap",
"reflected": "echo",
"canvasLabel": "A person in front of a wall, and a timeline with the clap and its echo."
},
"qqq": {
"_name": "Name of the Echo module",
"hint": "Instruction text shown in the footer. Explains the 0.1 s / 17 m threshold.",
"clap": "Button that plays a hand clap and its echo, and starts the animation.",
"noAudio": "Shown when the browser has no Web Audio support.",
"note": "Note above the canvas: the sound and the drawn wave both run in real time.",
"distance": "Label of the wall distance slider.",
"delay": "Stat card: time between the clap and its echo.",
"heard": "Stat card: whether the reflection is heard as an echo or as reverberation.",
"echo": "Stat value when the delay is 0.1 s or more.",
"reverb": "Stat value when the delay is under 0.1 s.",
"person": "Canvas label under the person who claps (addressing the reader).",
"wall": "Canvas label under the wall.",
"timeAxis": "Label of the timeline's horizontal axis.",
"fusion": "Label of the shaded first 0.1 s on the timeline, where two sounds merge.",
"direct": "Timeline label of the original clap.",
"reflected": "Timeline label of the reflected sound.",
"canvasLabel": "Accessible label of the canvas."
},
"es": {
"_name": "Eco y reverberación",
"hint": "El oído necesita unos 0,1 s entre dos sonidos para distinguirlos: la pared debe estar a más de 17 m para oír eco",
"clap": "Dar una palmada",
"noAudio": "Tu navegador no puede reproducir sonido",
"note": "Sonido y onda en tiempo real",
"distance": "Distancia a la pared",
"delay": "Retraso del eco",
"heard": "Lo que oyes",
"echo": "Eco: dos sonidos",
"reverb": "Reverberación: un sonido más largo",
"person": "tú",
"wall": "pared",
"timeAxis": "tiempo (ms)",
"fusion": "el oído los une",
"direct": "palmada",
"reflected": "eco",
"canvasLabel": "Una persona frente a una pared y una línea de tiempo con la palmada y su eco."
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
function t( key, params ) {
return banana.i18n.apply( banana, [ key ].concat( params || [] ) );
}
/* ── Constants ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var H_PX = 300; /* canvas height (px) */
var V_SOUND = 343; /* m/s, air at 20 °C */
var FUSION = 0.1; /* s, minimum gap the ear can separate */
var D_MIN = 1; /* wall distance slider (m), logarithmic */
var D_MAX = 500;
var VIEW_MIN = 25; /* narrowest scene width (m) */
var WALL_AT = 0.8; /* wall position as a fraction of the scene */
var MAX_GAIN = 0.5; /* clap peak gain (noise burst) */
var LEAD = 0.06; /* s between the click and the clap */
var SCENE_Y = 128; /* ground line of the side view (px) */
var PERSON_X = 44; /* person position (px) */
var TL_TOP = 176; /* timeline band (px) */
var TL_BOT = 270;
var TL_X0 = 44;
var uid = 0;
/* ── Shared helpers ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
function el( tag, cls, text ) {
var e = document.createElement( tag );
if ( cls ) { e.className = cls; }
if ( text !== undefined ) { e.textContent = text; }
return e;
}
function num( v, min, max, def ) {
var n = parseFloat( v );
if ( isNaN( n ) ) { return def; }
return Math.max( min, Math.min( max, n ) );
}
function fmt( n, d ) {
try {
return n.toLocaleString( lang, { minimumFractionDigits: d, maximumFractionDigits: d } );
} catch ( e ) {
return n.toFixed( d );
}
}
function isDark() {
var root = document.documentElement;
return root.classList.contains( 'skin-theme-clientpref-night' ) ||
root.classList.contains( 'client-dark-mode' ) ||
document.body.classList.contains( 'mw-dark-mode' ) ||
( root.classList.contains( 'skin-theme-clientpref-os' ) &&
window.matchMedia && window.matchMedia( '(prefers-color-scheme: dark)' ).matches );
}
function palette() {
var d = isDark();
return {
wave: d ? '93,202,165' : '15,110,86', /* rgb, alpha added per front */
echo: d ? '#FAC775' : '#BA7517',
person: d ? '#AFA9EC' : '#534AB7',
wall: d ? '#888780' : '#5F5E5A',
ground: d ? '#4a4a47' : '#c9c7be',
fusion: d ? 'rgba(175,169,236,.14)' : 'rgba(83,74,183,.08)',
cursor: d ? '#E8E6DC' : '#1A1A18',
text: d ? '#B4B2A9' : '#5F5E5A'
};
}
function audioHub() {
window.hiruwiki = window.hiruwiki || {};
if ( !window.hiruwiki.audio ) {
var hub = {
ctx: null,
current: null,
context: function () {
if ( !hub.ctx ) {
var AC = window.AudioContext || window.webkitAudioContext;
if ( !AC ) { return null; }
hub.ctx = new AC();
}
if ( hub.ctx.state === 'suspended' ) { hub.ctx.resume(); }
return hub.ctx;
},
claim: function ( owner ) {
if ( hub.current && hub.current !== owner ) { hub.current.stop(); }
hub.current = owner;
},
release: function ( owner ) {
if ( hub.current === owner ) { hub.current = null; }
}
};
document.addEventListener( 'visibilitychange', function () {
if ( document.hidden && hub.current ) { hub.current.stop(); }
} );
window.hiruwiki.audio = hub;
}
return window.hiruwiki.audio;
}
function makeFooter( container, text ) {
var footer = el( 'div', 'hw-footer' );
var logo = el( 'a', 'hw-footer-icon' );
logo.href = ( window.mw && mw.util && mw.util.getUrl( 'Wikipedia:Hiruwiki' ) ) || '#';
logo.title = 'Hiruwiki';
if ( window.hiruwiki && window.hiruwiki.getLogoSvg ) {
logo.insertAdjacentHTML( 'beforeend', window.hiruwiki.getLogoSvg( 22 ) );
}
footer.appendChild( logo );
footer.appendChild( el( 'span', 'hw-footer__text', text ) );
container.appendChild( footer );
}
function makeSlider( parent, label, min, max, step, value ) {
var id = 'hw-sound-' + ( ++uid );
var row = el( 'div', 'hw-sound__row' );
var lab = el( 'label', 'hw-sound__label', label );
lab.htmlFor = id;
var input = el( 'input', 'hw-sound__range' );
input.type = 'range'; input.id = id;
input.min = min; input.max = max; input.step = step; input.value = value;
var out = el( 'output', 'hw-sound__out' );
out.setAttribute( 'for', id );
row.appendChild( lab ); row.appendChild( input ); row.appendChild( out );
parent.appendChild( row );
return { row: row, input: input, out: out };
}
/* ── Module-specific helpers ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
function echoDelay( d ) { return 2 * d / V_SOUND; }
/* Reflection loudness relative to the clap: falls with distance but
* stays audible. Pedagogical, not a room-acoustics model. */
function echoLevel( d ) { return Math.max( 0.18, 0.7 * 10 / ( 10 + d ) ); }
/* Logarithmic slider: 0–1000 ↔ D_MIN–D_MAX metres. */
function sliderToDist( v ) {
var d = D_MIN * Math.pow( D_MAX / D_MIN, v / 1000 );
return d < 10 ? Math.round( d * 10 ) / 10 : Math.round( d );
}
function distToSlider( d ) {
return Math.round( 1000 * Math.log( d / D_MIN ) / Math.log( D_MAX / D_MIN ) );
}
function fmtDist( d ) { return fmt( d, d < 10 ? 1 : 0 ) + ' m'; }
/* Timeline length: at least 400 ms, else the echo plus some room. */
function timelineMax( d ) { return Math.max( 0.4, echoDelay( d ) * 1.25 + 0.08 ); }
function tickStep( tmax ) { return tmax <= 0.5 ? 0.1 : ( tmax <= 1.5 ? 0.25 : 0.5 ); }
/* One second of white noise, cached on the shared AudioContext. */
function noiseBuffer( ac ) {
if ( !ac.hwNoise ) {
var buf = ac.createBuffer( 1, ac.sampleRate, ac.sampleRate );
var data = buf.getChannelData( 0 );
for ( var i = 0; i < data.length; i++ ) { data[ i ] = Math.random() * 2 - 1; }
ac.hwNoise = buf;
}
return ac.hwNoise;
}
/* A hand clap: a band-passed noise burst with a fast decay. The
* reflection gets a lower filter, as walls absorb high frequencies. */
function clap( ac, at, gain, freq, nodes ) {
var src = ac.createBufferSource();
src.buffer = noiseBuffer( ac );
var bp = ac.createBiquadFilter();
bp.type = 'bandpass';
bp.frequency.value = freq;
bp.Q.value = 0.9;
var g = ac.createGain();
g.gain.setValueAtTime( 0, at );
g.gain.linearRampToValueAtTime( gain, at + 0.002 );
g.gain.exponentialRampToValueAtTime( 0.0005, at + 0.14 );
src.connect( bp ).connect( g ).connect( ac.destination );
src.start( at, Math.random() * 0.5 );
src.stop( at + 0.16 );
nodes.push( src );
}
/* ── Build widget ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
function buildWidget( container ) {
var ds = container.dataset;
var state = {
d: num( ds.distance, D_MIN, D_MAX, 25 ),
running: false,
clock: null, /* returns seconds since the clap, from the audio clock */
now: 0 /* seconds since the clap at the current frame */
};
var W = 600;
var reduce = window.matchMedia && window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches;
container.innerHTML = '';
var root = el( 'div', 'hw-sound' );
container.appendChild( root );
/* -- top bar -- */
var bar = el( 'div', 'hw-sound__bar' );
var clapBtn = el( 'button', 'hw-sound__btn hw-sound__play' );
clapBtn.type = 'button';
var icon = el( 'span', 'hw-sound__icon', '▶' );
icon.setAttribute( 'aria-hidden', 'true' );
clapBtn.appendChild( icon );
clapBtn.appendChild( el( 'span', null, t( 'clap' ) ) );
bar.appendChild( clapBtn );
bar.appendChild( el( 'span', 'hw-sound__note', t( 'note' ) ) );
root.appendChild( bar );
/* -- canvas -- */
var canvas = el( 'canvas', 'hw-sound__canvas' );
canvas.style.height = H_PX + 'px';
canvas.setAttribute( 'role', 'img' );
canvas.setAttribute( 'aria-label', t( 'canvasLabel' ) );
root.appendChild( canvas );
var ctx = canvas.getContext( '2d' );
root.appendChild( el( 'div', 'hw-echo__gap' ) );
/* -- distance slider (logarithmic) -- */
var dSlider = makeSlider( root, t( 'distance' ), 0, 1000, 1, distToSlider( state.d ) );
dSlider.input.addEventListener( 'input', function () {
state.d = sliderToDist( +dSlider.input.value );
state.running = false;
update();
} );
/* -- stat cards -- */
var stats = el( 'div', 'hw-sound__stats' );
function stat( label ) {
var c = el( 'div', 'hw-sound__stat' );
c.appendChild( el( 'span', null, label ) );
var b = el( 'b' );
c.appendChild( b );
stats.appendChild( c );
return b;
}
var sDelay = stat( t( 'delay' ) );
var sHeard = stat( t( 'heard' ) );
root.appendChild( stats );
makeFooter( container, t( 'hint' ) );
/* -- geometry: the scene zooms so the wall sits near WALL_AT -- */
function viewMetres() { return Math.max( VIEW_MIN, state.d / WALL_AT ); }
function scale() { return ( W - PERSON_X - 24 ) / viewMetres(); } /* px per metre */
function wallX() { return PERSON_X + state.d * scale(); }
function tlMax() { return timelineMax( state.d ); }
function tlX( sec ) { return TL_X0 + ( W - TL_X0 - 16 ) * sec / tlMax(); }
/* -- drawing -- */
function drawScene( p ) {
var xw = wallX(), yc = SCENE_Y - 34, sc = scale();
/* ground */
ctx.strokeStyle = p.ground;
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo( 0, SCENE_Y ); ctx.lineTo( W, SCENE_Y ); ctx.stroke();
ctx.font = '11px sans-serif';
ctx.textAlign = 'center';
/* 17 m threshold tick, while it is a readable distance from the person */
var thr = V_SOUND * FUSION / 2, xt = PERSON_X + thr * sc;
if ( xt - PERSON_X > 24 && xt < W - 10 ) {
ctx.setLineDash( [ 3, 3 ] );
ctx.beginPath(); ctx.moveTo( xt, SCENE_Y - 6 ); ctx.lineTo( xt, SCENE_Y + 6 ); ctx.stroke();
ctx.setLineDash( [] );
ctx.fillStyle = p.text;
ctx.fillText( fmt( thr, 0 ) + ' m', xt, SCENE_Y + ( Math.abs( xw - xt ) < 34 ? 32 : 18 ) );
}
/* wavefronts, clipped to the air in front of the wall */
if ( state.running ) {
var r = V_SOUND * state.now * sc;
var gap = xw - PERSON_X;
ctx.save();
ctx.beginPath(); ctx.rect( 0, 0, xw, SCENE_Y ); ctx.clip();
ctx.lineWidth = 3;
if ( r > 0 && r < gap + W ) {
ctx.strokeStyle = 'rgba(' + p.wave + ',' + Math.max( 0.2, 1 - r / ( 1.4 * W ) ).toFixed( 3 ) + ')';
ctx.beginPath(); ctx.arc( PERSON_X, yc, r, 0, 2 * Math.PI ); ctx.stroke();
}
if ( r > gap ) {
ctx.strokeStyle = p.echo;
ctx.globalAlpha = Math.max( 0.45, echoLevel( state.d ) * 1.4 );
ctx.beginPath(); ctx.arc( 2 * xw - PERSON_X, yc, r, 0, 2 * Math.PI ); ctx.stroke();
ctx.globalAlpha = 1;
}
ctx.restore();
}
/* wall */
ctx.fillStyle = p.wall;
ctx.fillRect( xw, SCENE_Y - 104, 9, 104 );
ctx.strokeStyle = isDark() ? '#2C2C2A' : '#ffffff';
ctx.lineWidth = 1;
for ( var by = SCENE_Y - 104 + 10; by < SCENE_Y; by += 12 ) {
ctx.beginPath(); ctx.moveTo( xw, by ); ctx.lineTo( xw + 9, by ); ctx.stroke();
}
/* person: head and body */
ctx.fillStyle = p.person;
ctx.beginPath(); ctx.arc( PERSON_X, SCENE_Y - 46, 7, 0, 2 * Math.PI ); ctx.fill();
ctx.fillRect( PERSON_X - 5, SCENE_Y - 37, 10, 37 );
ctx.fillStyle = p.text;
ctx.textAlign = 'center';
ctx.fillText( t( 'person' ), PERSON_X, SCENE_Y + 18 );
ctx.fillText( t( 'wall' ), xw + 4, SCENE_Y + 18 );
if ( xw - PERSON_X > 40 ) {
ctx.fillText( fmtDist( state.d ), ( PERSON_X + xw ) / 2, SCENE_Y - 112 );
ctx.strokeStyle = p.text;
ctx.beginPath();
ctx.moveTo( PERSON_X + 10, SCENE_Y - 106 ); ctx.lineTo( xw - 2, SCENE_Y - 106 );
ctx.stroke();
}
}
function pulse( p, at, height, color, label, reveal ) {
var x0 = tlX( at ), base = TL_BOT, h = height * ( TL_BOT - TL_TOP - 18 );
var end = Math.min( at + 0.07, tlMax() );
if ( reveal !== undefined && reveal < at ) { return; }
var stopAt = reveal === undefined ? end : Math.min( end, reveal );
ctx.fillStyle = color;
ctx.globalAlpha = 0.85;
ctx.beginPath();
ctx.moveTo( x0, base );
for ( var s = at; s <= stopAt; s += 0.001 ) {
ctx.lineTo( tlX( s ), base - h * Math.exp( -( s - at ) / 0.012 ) );
}
ctx.lineTo( tlX( stopAt ), base );
ctx.closePath();
ctx.fill();
ctx.globalAlpha = 1;
ctx.fillStyle = color;
ctx.textAlign = 'left';
ctx.fillText( label, x0 + 4, base - h - 4 );
}
function drawTimeline( p ) {
var delay = echoDelay( state.d ), tmax = tlMax(), step = tickStep( tmax );
/* fusion zone */
ctx.fillStyle = p.fusion;
ctx.fillRect( tlX( 0 ), TL_TOP, tlX( FUSION ) - tlX( 0 ), TL_BOT - TL_TOP );
ctx.fillStyle = p.text;
ctx.font = '11px sans-serif';
ctx.textAlign = tlX( FUSION ) - tlX( 0 ) > 110 ? 'center' : 'left';
ctx.fillText( t( 'fusion' ),
ctx.textAlign === 'center' ? ( tlX( 0 ) + tlX( FUSION ) ) / 2 : tlX( 0 ), TL_TOP - 6 );
/* axis and ticks */
ctx.strokeStyle = p.ground;
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo( TL_X0, TL_BOT ); ctx.lineTo( W - 16, TL_BOT ); ctx.stroke();
ctx.fillStyle = p.text;
ctx.textAlign = 'center';
for ( var s = 0; s <= tmax + 1e-9; s += step ) {
var x = tlX( s );
ctx.beginPath(); ctx.moveTo( x, TL_BOT ); ctx.lineTo( x, TL_BOT + 4 ); ctx.stroke();
ctx.fillText( String( Math.round( s * 1000 ) ), x, TL_BOT + 16 );
}
ctx.textAlign = 'right';
ctx.fillText( t( 'timeAxis' ), W - 16, TL_BOT + 28 );
/* pulses: revealed in step with the sound while running */
var reveal = state.running ? state.now : undefined;
pulse( p, 0, 1, 'rgb(' + p.wave + ')', t( 'direct' ), reveal );
pulse( p, delay, echoLevel( state.d ) * 1.2, p.echo, t( 'reflected' ), reveal );
if ( state.running && reveal >= 0 && reveal <= tmax ) {
var cx = tlX( reveal );
ctx.strokeStyle = p.cursor;
ctx.beginPath(); ctx.moveTo( cx, TL_TOP ); ctx.lineTo( cx, TL_BOT ); ctx.stroke();
}
}
function draw() {
var p = palette();
ctx.clearRect( 0, 0, W, H_PX );
drawScene( p );
drawTimeline( p );
}
/* -- animation: driven by the audio clock, so sight and sound agree -- */
var raf = null, visible = true;
function tick() {
raf = null;
state.now = state.clock();
var r = V_SOUND * state.now * scale();
var echoGone = r > 2 * ( wallX() - PERSON_X ) + W;
var timelineDone = state.now > Math.max( echoDelay( state.d ) + 0.07, 0.12 );
if ( echoGone && timelineDone ) { state.running = false; }
draw();
if ( state.running && visible ) { raf = requestAnimationFrame( tick ); }
}
function animate( clock ) {
if ( reduce ) { draw(); return; }
state.clock = clock;
state.running = true;
if ( !raf ) { raf = requestAnimationFrame( tick ); }
}
if ( 'IntersectionObserver' in window ) {
new IntersectionObserver( function ( entries ) {
visible = entries[ 0 ].isIntersecting;
if ( visible && state.running && !raf ) { raf = requestAnimationFrame( tick ); }
} ).observe( canvas );
}
/* -- audio -- */
var voice = {
nodes: [],
stop: function () {
voice.nodes.forEach( function ( n ) { try { n.stop(); } catch ( e ) {} } );
voice.nodes = [];
audioHub().release( voice );
}
};
/* Schedules the clap and its echo; returns a clock that reads
* seconds since the clap. Falls back to the page clock without audio. */
function playClap() {
var hub = audioHub();
var ac = hub.context();
if ( !ac ) {
clapBtn.title = t( 'noAudio' );
var t0 = performance.now() / 1000 + LEAD;
return function () { return performance.now() / 1000 - t0; };
}
hub.claim( voice );
voice.nodes = [];
var at = ac.currentTime + LEAD;
clap( ac, at, MAX_GAIN, 1800, voice.nodes );
clap( ac, at + echoDelay( state.d ), MAX_GAIN * echoLevel( state.d ), 1200, voice.nodes );
/* outputLatency (where supported) aligns the picture with what
* reaches the speakers, not with what leaves the audio graph */
var lat = ac.outputLatency || ac.baseLatency || 0;
return function () { return ac.currentTime - at - lat; };
}
clapBtn.addEventListener( 'click', function () {
animate( playClap() );
} );
/* -- readouts -- */
function update() {
var delay = echoDelay( state.d );
dSlider.out.textContent = fmtDist( state.d );
sDelay.textContent = String( Math.round( delay * 1000 ) ) + ' ms';
sHeard.textContent = delay >= FUSION ? t( 'echo' ) : t( 'reverb' );
draw();
}
function resize() {
var dpr = window.devicePixelRatio || 1;
W = canvas.clientWidth || W;
canvas.width = Math.round( W * dpr );
canvas.height = Math.round( H_PX * dpr );
ctx.setTransform( dpr, 0, 0, dpr, 0, 0 );
draw();
}
new ResizeObserver( resize ).observe( canvas );
/* dark-mode live update (MediaWiki) */
var mo = new MutationObserver( draw );
mo.observe( document.documentElement, { attributes: true, attributeFilter: [ 'class' ] } );
mo.observe( document.body, { attributes: true, attributeFilter: [ 'class' ] } );
update();
}
/* ----------------------------------------------------------
* Initialise all matching containers on the page
* ---------------------------------------------------------- */
document.querySelectorAll( '.hiruwiki[data-module="echo"]' ).forEach( buildWidget );
}() );
9vreyam34kj0f2x7dv5p81m3vaqo4kz
MediaWiki:Hiruwiki/modules/harmonics.css
8
189966
781428
2026-10-28T10:39:15Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781428
css
text/css
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/* =========================================================
* Hiruwiki module: harmonics — stylesheet
* Colours come from the --hw-* tokens in hiruwiki-shared.css,
* which the core loads first and which handle dark mode.
* The .hw-sound__* rules are repeated in every sound module's
* stylesheet (sound-wave, harmonics, sound-propagation) because
* the core loads one CSS file per module; keep them in sync.
* ========================================================= */
/* Card */
.hw-sound {
font-family: var(--hw-font);
font-size: 14px;
line-height: 1.4;
color: var(--hw-text-base);
background: var(--hw-bg-base);
border: 0.5px solid var(--hw-border);
border-radius: var(--hw-radius-lg);
padding: 12px 16px;
margin: 1em 0 0;
box-sizing: border-box;
}
.hw-sound__bar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.hw-sound__note,
.hw-sound__axis {
font-size: 12px;
color: var(--hw-text-muted);
}
/* Canvas */
.hw-sound__canvas {
display: block;
width: 100%;
border-radius: var(--hw-radius-sm);
background: var(--hw-bg-subtle);
touch-action: pan-y;
}
.hw-sound__axis {
display: flex;
justify-content: space-between;
margin: 4px 0 12px;
}
/* Control rows */
.hw-sound__row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 12px;
margin: 0 0 10px;
}
.hw-sound__row--disabled {
opacity: 0.4;
}
.hw-sound__label {
min-width: 110px;
color: var(--hw-text-muted);
}
.hw-sound__range {
flex: 1;
min-width: 120px;
accent-color: var(--hw-color-primary);
}
.hw-sound__out {
min-width: 72px;
text-align: right;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
/* Buttons and segmented choices */
.hw-sound__seg {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.hw-sound__btn {
font: inherit;
font-size: 13px;
padding: 4px 12px;
border: 0.5px solid var(--hw-border);
border-radius: var(--hw-radius-sm);
background: var(--hw-bg-base);
color: var(--hw-text-base);
cursor: pointer;
}
.hw-sound__btn:hover {
background: var(--hw-bg-subtle);
}
.hw-sound__btn[aria-pressed="true"] {
background: var(--hw-color-primary-subtle);
border-color: var(--hw-color-primary);
color: var(--hw-color-primary);
}
.hw-sound__btn:focus-visible,
.hw-sound__range:focus-visible {
outline: 2px solid var(--hw-color-primary);
outline-offset: 1px;
}
.hw-sound__play {
display: inline-flex;
align-items: center;
gap: 6px;
}
.hw-sound__icon {
font-size: 11px;
width: 12px;
text-align: center;
}
/* Stat cards */
.hw-sound__stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px;
margin: 8px 0 4px;
}
.hw-sound__stat {
background: var(--hw-bg-subtle);
border-radius: var(--hw-radius-sm);
padding: 8px 10px;
}
.hw-sound__stat span {
display: block;
font-size: 12px;
color: var(--hw-text-muted);
}
.hw-sound__stat b {
font-size: 17px;
font-variant-numeric: tabular-nums;
}
@media print {
.hw-sound__btn {
display: none;
}
}
/* Header row above the harmonic sliders */
.hw-sound__row--split {
justify-content: space-between;
margin-top: 14px;
}
.hw-sound__check {
font-size: 13px;
color: var(--hw-text-muted);
cursor: pointer;
}
/* Eight vertical harmonic sliders */
.hw-sound__harm {
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
gap: 4px;
margin: 4px 0 12px;
}
.hw-sound__h {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
font-size: 11px;
color: var(--hw-text-muted);
font-variant-numeric: tabular-nums;
}
.hw-sound__h input {
writing-mode: vertical-lr;
direction: rtl;
width: 24px;
height: 110px;
margin: 0;
accent-color: var(--hw-color-primary);
}
.hw-sound__h input:focus-visible {
outline: 2px solid var(--hw-color-primary);
outline-offset: 1px;
}
.hw-sound__h b {
font-size: 13px;
color: var(--hw-text-base);
}
.hw-sound__h.is-off b {
color: var(--hw-text-muted);
text-decoration: line-through;
}
@media print {
.hw-sound__check {
display: none;
}
}
byczla1upe2ox32t32jsno3m0y1fv36
MediaWiki:Hiruwiki/modules/harmonics.js
8
189967
781429
2026-10-28T10:39:18Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781429
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/* =========================================================
* Hiruwiki module: harmonics
* Additive synthesis: eight harmonic sliders (f, 2f … 8f),
* the resulting waveform, optional component curves, timbre
* presets, a "remove the fundamental" button and real audio.
*
* <div class="hiruwiki" data-module="harmonics"
* data-preset="sine" data-fundamental="220"></div>
*
* data-preset sine | flute | clarinet | violin | oboe
* data-fundamental 110–440 Hz, default 220
* data-harmonics optional custom start: eight comma-
* separated values 0–1 ("1,0,0.33,0,0.2")
*
* Audio uses a PeriodicWave, which the browser normalises,
* so every preset sounds at roughly the same loudness: same
* pitch, same intensity, different timbre.
*
* The presets are stylised spectra, not measurements. Real
* instruments vary with register, dynamics and player.
* =========================================================
* i18n: add a new key to the `messages` object below and
* a matching entry for every language code you need.
* Banana falls back to 'en' when a key is missing.
* ========================================================= */
( function () {
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"fr": {
"_name": "Harmoniques et timbre",
"hint": "Même hauteur et même intensité, timbre différent : modifiez les harmoniques et écoutez",
"play": "Écouter",
"stop": "Arrêter",
"noAudio": "Votre navigateur ne peut pas jouer de son",
"showComponents": "Afficher les composantes",
"presets": "Préréglages",
"presetSine": "Sinus pur",
"presetFlute": "Flûte",
"presetClarinet": "Clarinette",
"presetViolin": "Violon",
"presetOboe": "Hautbois",
"harmonics": "Harmoniques (intensité)",
"removeFundamental": "Supprimer la fondamentale",
"harmonicAria": "Intensité de l'harmonique $1",
"fundamental": "Fréquence fondamentale",
"periods": "3 périodes",
"timeAxis": "temps →",
"silence": "Silence : toutes les harmoniques sont à zéro"
},
"eu": {
"_name": "Harmonikoak eta tinbrea",
"hint": "Altuera eta intentsitate bera, tinbre desberdina: aldatu harmonikoak eta entzun",
"play": "Entzun",
"stop": "Gelditu",
"noAudio": "Zure nabigatzaileak ezin du soinurik erreproduzitu",
"showComponents": "Osagaiak erakutsi",
"presets": "Aurrezarpenak",
"presetSine": "Sinu hutsa",
"presetFlute": "Txirula",
"presetClarinet": "Klarinetea",
"presetViolin": "Biolina",
"presetOboe": "Oboea",
"harmonics": "Harmonikoak (indarra)",
"removeFundamental": "Oinarrizkoa kendu",
"harmonicAria": "$1. harmonikoaren indarra",
"fundamental": "Oinarrizko maiztasuna",
"periods": "3 periodo",
"timeAxis": "denbora →",
"silence": "Isiltasuna: harmoniko guztiak zeroan"
},
"en": {
"_name": "Harmonics and timbre",
"hint": "Same pitch and loudness, different timbre: change the harmonics and listen",
"play": "Listen",
"stop": "Stop",
"noAudio": "Your browser can't play sound",
"showComponents": "Show components",
"presets": "Presets",
"presetSine": "Pure sine",
"presetFlute": "Flute",
"presetClarinet": "Clarinet",
"presetViolin": "Violin",
"presetOboe": "Oboe",
"harmonics": "Harmonics (strength)",
"removeFundamental": "Remove the fundamental",
"harmonicAria": "Strength of harmonic $1",
"fundamental": "Fundamental frequency",
"periods": "3 periods",
"timeAxis": "time →",
"silence": "Silence: every harmonic is at zero"
},
"qqq": {
"_name": "Name of the Harmonics module",
"hint": "Instruction text shown in the footer.",
"play": "Label of the button that starts the sound.",
"stop": "Label of the button that stops the sound.",
"noAudio": "Shown when the browser has no Web Audio support.",
"showComponents": "Checkbox: draw each harmonic as a faint curve under the total.",
"presets": "Label of the preset selector.",
"presetSine": "Preset: only the fundamental.",
"presetFlute": "Preset: stylised flute spectrum.",
"presetClarinet": "Preset: stylised clarinet spectrum (odd harmonics only).",
"presetViolin": "Preset: stylised violin spectrum (all harmonics, 1/n).",
"presetOboe": "Preset: stylised oboe spectrum (weak fundamental).",
"harmonics": "Label above the eight harmonic sliders.",
"removeFundamental": "Button that sets the first harmonic to zero (missing-fundamental demo).",
"harmonicAria": "Accessible label of each harmonic slider. $1 = harmonic number.",
"fundamental": "Label of the fundamental frequency slider.",
"periods": "Left label under the graph: how many periods are shown.",
"timeAxis": "Centre label under the graph.",
"silence": "Canvas message when all harmonics are zero."
},
"es": {
"_name": "Armónicos y timbre",
"hint": "Misma altura e intensidad, distinto timbre: cambia los armónicos y escucha",
"play": "Escuchar",
"stop": "Detener",
"noAudio": "Tu navegador no puede reproducir sonido",
"showComponents": "Mostrar componentes",
"presets": "Ajustes",
"presetSine": "Seno puro",
"presetFlute": "Flauta",
"presetClarinet": "Clarinete",
"presetViolin": "Violín",
"presetOboe": "Oboe",
"harmonics": "Armónicos (intensidad)",
"removeFundamental": "Quitar la fundamental",
"harmonicAria": "Intensidad del armónico $1",
"fundamental": "Frecuencia fundamental",
"periods": "3 periodos",
"timeAxis": "tiempo →",
"silence": "Silencio: todos los armónicos están a cero"
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
function t( key, params ) {
return banana.i18n.apply( banana, [ key ].concat( params || [] ) );
}
/* ── Constants ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var H_PX = 200; /* canvas height (px) */
var N = 8; /* number of harmonics */
var PERIODS = 3; /* periods shown */
var MAX_GAIN = 0.12; /* never louder than this */
var PRESETS = {
sine: [ 1, 0, 0, 0, 0, 0, 0, 0 ],
flute: [ 1, 0.45, 0.12, 0.06, 0.03, 0, 0, 0 ],
clarinet: [ 1, 0, 0.33, 0, 0.2, 0, 0.14, 0 ],
violin: [ 1, 0.5, 0.33, 0.25, 0.2, 0.17, 0.14, 0.12 ],
oboe: [ 0.4, 0.8, 1, 0.55, 0.35, 0.3, 0.15, 0.1 ]
};
var PRESET_KEYS = { sine: 'presetSine', flute: 'presetFlute', clarinet: 'presetClarinet', violin: 'presetViolin', oboe: 'presetOboe' };
var uid = 0;
/* ── Shared helpers ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
function el( tag, cls, text ) {
var e = document.createElement( tag );
if ( cls ) { e.className = cls; }
if ( text !== undefined ) { e.textContent = text; }
return e;
}
function num( v, min, max, def ) {
var n = parseFloat( v );
if ( isNaN( n ) ) { return def; }
return Math.max( min, Math.min( max, n ) );
}
function fmt( n, d ) {
try {
return n.toLocaleString( lang, { minimumFractionDigits: d, maximumFractionDigits: d } );
} catch ( e ) {
return n.toFixed( d );
}
}
function isDark() {
var root = document.documentElement;
return root.classList.contains( 'skin-theme-clientpref-night' ) ||
root.classList.contains( 'client-dark-mode' ) ||
document.body.classList.contains( 'mw-dark-mode' ) ||
( root.classList.contains( 'skin-theme-clientpref-os' ) &&
window.matchMedia && window.matchMedia( '(prefers-color-scheme: dark)' ).matches );
}
function palette() {
var d = isDark();
return {
wave: d ? '#5DCAA5' : '#0F6E56',
comp: d ? 'rgba(175,169,236,.55)' : 'rgba(83,74,183,.45)',
grid: d ? '#3a3a38' : '#e2e0d8',
axis: d ? '#6b6a65' : '#a8a69e',
text: d ? '#B4B2A9' : '#5F5E5A'
};
}
function audioHub() {
window.hiruwiki = window.hiruwiki || {};
if ( !window.hiruwiki.audio ) {
var hub = {
ctx: null,
current: null,
context: function () {
if ( !hub.ctx ) {
var AC = window.AudioContext || window.webkitAudioContext;
if ( !AC ) { return null; }
hub.ctx = new AC();
}
if ( hub.ctx.state === 'suspended' ) { hub.ctx.resume(); }
return hub.ctx;
},
claim: function ( owner ) {
if ( hub.current && hub.current !== owner ) { hub.current.stop(); }
hub.current = owner;
},
release: function ( owner ) {
if ( hub.current === owner ) { hub.current = null; }
}
};
document.addEventListener( 'visibilitychange', function () {
if ( document.hidden && hub.current ) { hub.current.stop(); }
} );
window.hiruwiki.audio = hub;
}
return window.hiruwiki.audio;
}
function makeFooter( container, text ) {
var footer = el( 'div', 'hw-footer' );
var logo = el( 'a', 'hw-footer-icon' );
logo.href = ( window.mw && mw.util && mw.util.getUrl( 'Wikipedia:Hiruwiki' ) ) || '#';
logo.title = 'Hiruwiki';
if ( window.hiruwiki && window.hiruwiki.getLogoSvg ) {
logo.insertAdjacentHTML( 'beforeend', window.hiruwiki.getLogoSvg( 22 ) );
}
footer.appendChild( logo );
footer.appendChild( el( 'span', 'hw-footer__text', text ) );
container.appendChild( footer );
}
function makeSlider( parent, label, min, max, step, value ) {
var id = 'hw-sound-' + ( ++uid );
var row = el( 'div', 'hw-sound__row' );
var lab = el( 'label', 'hw-sound__label', label );
lab.htmlFor = id;
var input = el( 'input', 'hw-sound__range' );
input.type = 'range'; input.id = id;
input.min = min; input.max = max; input.step = step; input.value = value;
var out = el( 'output', 'hw-sound__out' );
out.setAttribute( 'for', id );
row.appendChild( lab ); row.appendChild( input ); row.appendChild( out );
parent.appendChild( row );
return { row: row, input: input, out: out };
}
function makeSegment( parent, label, options, value, onChange ) {
var id = 'hw-sound-' + ( ++uid );
var row = el( 'div', 'hw-sound__row' );
var lab = el( 'span', 'hw-sound__label', label );
lab.id = id;
var seg = el( 'div', 'hw-sound__seg' );
seg.setAttribute( 'role', 'group' );
seg.setAttribute( 'aria-labelledby', id );
var buttons = options.map( function ( o ) {
var b = el( 'button', 'hw-sound__btn', o.label );
b.type = 'button';
b.addEventListener( 'click', function () { set( o.value ); onChange( o.value ); } );
seg.appendChild( b );
return { b: b, v: o.value };
} );
function set( v ) {
buttons.forEach( function ( x ) { x.b.setAttribute( 'aria-pressed', x.v === v ? 'true' : 'false' ); } );
}
set( value );
row.appendChild( lab ); row.appendChild( seg );
parent.appendChild( row );
return { row: row, set: set };
}
function makePlayButton( onClick ) {
var b = el( 'button', 'hw-sound__btn hw-sound__play' );
b.type = 'button';
var icon = el( 'span', 'hw-sound__icon' );
icon.setAttribute( 'aria-hidden', 'true' );
var txt = el( 'span' );
b.appendChild( icon ); b.appendChild( txt );
b.addEventListener( 'click', onClick );
return {
el: b,
set: function ( on, onLabel, offLabel ) {
icon.textContent = on ? '❚❚' : '▶';
txt.textContent = on ? onLabel : offLabel;
b.setAttribute( 'aria-pressed', on ? 'true' : 'false' );
}
};
}
/* ── Build widget ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
function buildWidget( container ) {
var ds = container.dataset;
var preset = PRESETS[ ds.preset ] ? ds.preset : 'sine';
var h = PRESETS[ preset ].slice();
if ( ds.harmonics ) {
var custom = ds.harmonics.split( ',' ).map( function ( s ) { return num( s, 0, 1, 0 ); } );
if ( custom.length ) {
h = [];
for ( var c = 0; c < N; c++ ) { h.push( custom[ c ] || 0 ); }
preset = null;
}
}
var state = { f0: num( ds.fundamental, 110, 440, 220 ), showComp: false };
container.innerHTML = '';
var root = el( 'div', 'hw-sound' );
container.appendChild( root );
/* -- top bar -- */
var bar = el( 'div', 'hw-sound__bar' );
var play = makePlayButton( function () { if ( voice.playing ) { voice.stop(); } else { voice.start(); } } );
bar.appendChild( play.el );
var compLabel = el( 'label', 'hw-sound__check' );
var compBox = el( 'input' );
compBox.type = 'checkbox';
compBox.addEventListener( 'change', function () { state.showComp = compBox.checked; draw(); } );
compLabel.appendChild( compBox );
compLabel.appendChild( document.createTextNode( ' ' + t( 'showComponents' ) ) );
bar.appendChild( compLabel );
root.appendChild( bar );
/* -- canvas -- */
var canvas = el( 'canvas', 'hw-sound__canvas' );
canvas.style.height = H_PX + 'px';
canvas.setAttribute( 'role', 'img' );
canvas.setAttribute( 'aria-label', t( '_name' ) );
root.appendChild( canvas );
var ctx = canvas.getContext( '2d' );
var axis = el( 'div', 'hw-sound__axis' );
axis.appendChild( el( 'span', null, t( 'periods' ) ) );
axis.appendChild( el( 'span', null, t( 'timeAxis' ) ) );
var axisEnd = el( 'span' );
axis.appendChild( axisEnd );
root.appendChild( axis );
/* -- presets -- */
var presetSeg = makeSegment( root, t( 'presets' ),
Object.keys( PRESETS ).map( function ( k ) { return { value: k, label: t( PRESET_KEYS[ k ] ) }; } ),
preset, function ( k ) { h = PRESETS[ k ].slice(); syncSliders(); update(); } );
/* -- harmonic sliders -- */
var head = el( 'div', 'hw-sound__row hw-sound__row--split' );
head.appendChild( el( 'span', 'hw-sound__label', t( 'harmonics' ) ) );
var missBtn = el( 'button', 'hw-sound__btn', t( 'removeFundamental' ) );
missBtn.type = 'button';
missBtn.addEventListener( 'click', function () { h[ 0 ] = 0; presetSeg.set( null ); syncSliders(); update(); } );
head.appendChild( missBtn );
root.appendChild( head );
var grid = el( 'div', 'hw-sound__harm' );
var cols = [];
for ( var i = 0; i < N; i++ ) {
( function ( k ) {
var col = el( 'div', 'hw-sound__h' );
var val = el( 'span' );
var input = el( 'input' );
input.type = 'range'; input.min = 0; input.max = 100; input.step = 1;
input.setAttribute( 'aria-label', t( 'harmonicAria', [ k + 1 ] ) );
input.addEventListener( 'input', function () {
h[ k ] = +input.value / 100;
presetSeg.set( null );
update();
} );
var name = el( 'b', null, ( k + 1 ) + 'f' );
var hz = el( 'span' );
col.appendChild( val ); col.appendChild( input ); col.appendChild( name ); col.appendChild( hz );
grid.appendChild( col );
cols.push( { col: col, val: val, input: input, hz: hz } );
}( i ) );
}
root.appendChild( grid );
/* -- fundamental -- */
var fSlider = makeSlider( root, t( 'fundamental' ), 110, 440, 1, state.f0 );
fSlider.input.addEventListener( 'input', function () { state.f0 = +fSlider.input.value; update(); } );
makeFooter( container, t( 'hint' ) );
function syncSliders() {
cols.forEach( function ( c, k ) { c.input.value = Math.round( h[ k ] * 100 ); } );
}
function anyOn() {
return h.some( function ( x ) { return x > 0; } );
}
/* -- audio -- */
var voice = {
playing: false, osc: null, gain: null, ac: null,
wave: function () {
var re = new Float32Array( N + 1 ), im = new Float32Array( N + 1 );
for ( var k = 0; k < N; k++ ) { im[ k + 1 ] = h[ k ]; }
return voice.ac.createPeriodicWave( re, im );
},
start: function () {
var hub = audioHub();
var ac = hub.context();
if ( !ac ) { play.el.title = t( 'noAudio' ); return; }
hub.claim( voice );
voice.ac = ac;
voice.osc = ac.createOscillator();
voice.gain = ac.createGain();
voice.gain.gain.value = 0;
voice.osc.frequency.value = state.f0;
if ( anyOn() ) { voice.osc.setPeriodicWave( voice.wave() ); }
voice.osc.connect( voice.gain ).connect( ac.destination );
voice.osc.start();
voice.playing = true;
voice.refresh();
play.set( true, t( 'stop' ), t( 'play' ) );
},
stop: function () {
if ( !voice.playing ) { return; }
var o = voice.osc;
voice.gain.gain.setTargetAtTime( 0, voice.ac.currentTime, 0.02 );
setTimeout( function () { o.stop(); o.disconnect(); }, 150 );
voice.playing = false;
audioHub().release( voice );
play.set( false, t( 'stop' ), t( 'play' ) );
},
refresh: function () {
if ( !voice.playing ) { return; }
var on = anyOn(), now = voice.ac.currentTime;
voice.osc.frequency.setTargetAtTime( state.f0, now, 0.02 );
if ( on ) { voice.osc.setPeriodicWave( voice.wave() ); }
voice.gain.gain.setTargetAtTime( on ? MAX_GAIN : 0, now, 0.02 );
}
};
play.set( false, t( 'stop' ), t( 'play' ) );
/* -- drawing -- */
function sum( x ) {
var s = 0;
for ( var k = 0; k < N; k++ ) { s += h[ k ] * Math.sin( 2 * Math.PI * ( k + 1 ) * x ); }
return s;
}
function draw() {
var p = palette();
var W = canvas.clientWidth, H = H_PX, mid = H / 2, R = H / 2 - 14;
var px, k, y;
ctx.clearRect( 0, 0, W, H );
ctx.lineWidth = 1;
ctx.strokeStyle = p.grid;
for ( var i = 1; i < PERIODS; i++ ) {
ctx.beginPath(); ctx.moveTo( W * i / PERIODS, 0 ); ctx.lineTo( W * i / PERIODS, H ); ctx.stroke();
}
ctx.strokeStyle = p.axis;
ctx.beginPath(); ctx.moveTo( 0, mid ); ctx.lineTo( W, mid ); ctx.stroke();
/* normalise the drawing to the curve's own peak, as the audio does */
var mx = 0;
for ( px = 0; px <= W; px++ ) { mx = Math.max( mx, Math.abs( sum( px / W * PERIODS ) ) ); }
if ( mx < 1e-6 ) {
ctx.fillStyle = p.text;
ctx.font = '13px sans-serif';
ctx.textAlign = 'center';
ctx.fillText( t( 'silence' ), W / 2, mid - 10 );
return;
}
if ( state.showComp ) {
ctx.strokeStyle = p.comp;
ctx.lineWidth = 1;
for ( k = 0; k < N; k++ ) {
if ( !h[ k ] ) { continue; }
ctx.beginPath();
for ( px = 0; px <= W; px++ ) {
y = mid - h[ k ] * Math.sin( 2 * Math.PI * ( k + 1 ) * px / W * PERIODS ) / mx * R;
if ( px ) { ctx.lineTo( px, y ); } else { ctx.moveTo( px, y ); }
}
ctx.stroke();
}
}
ctx.strokeStyle = p.wave;
ctx.lineWidth = 2.4;
ctx.beginPath();
for ( px = 0; px <= W; px++ ) {
y = mid - sum( px / W * PERIODS ) / mx * R;
if ( px ) { ctx.lineTo( px, y ); } else { ctx.moveTo( px, y ); }
}
ctx.stroke();
}
function update() {
fSlider.out.textContent = fmt( state.f0, 0 ) + ' Hz';
axisEnd.textContent = fmt( PERIODS * 1000 / state.f0, 1 ) + ' ms';
cols.forEach( function ( c, k ) {
c.val.textContent = fmt( h[ k ] * 100, 0 );
c.hz.textContent = fmt( state.f0 * ( k + 1 ), 0 );
c.col.classList.toggle( 'is-off', h[ k ] === 0 );
} );
voice.refresh();
draw();
}
function resize() {
var dpr = window.devicePixelRatio || 1;
canvas.width = Math.round( canvas.clientWidth * dpr );
canvas.height = Math.round( H_PX * dpr );
ctx.setTransform( dpr, 0, 0, dpr, 0, 0 );
draw();
}
new ResizeObserver( resize ).observe( canvas );
/* dark-mode live update (MediaWiki) */
var mo = new MutationObserver( draw );
mo.observe( document.documentElement, { attributes: true, attributeFilter: [ 'class' ] } );
mo.observe( document.body, { attributes: true, attributeFilter: [ 'class' ] } );
syncSliders();
update();
}
/* ----------------------------------------------------------
* Initialise all matching containers on the page
* ---------------------------------------------------------- */
document.querySelectorAll( '.hiruwiki[data-module="harmonics"]' ).forEach( buildWidget );
}() );
scir9zwp42ig50jas9f5d2ib8h5divp
MediaWiki:Hiruwiki/modules/sound-propagation.css
8
189968
781445
2026-10-28T10:40:11Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781445
css
text/css
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/* =========================================================
* Hiruwiki module: sound-propagation — stylesheet
* Colours come from the --hw-* tokens in hiruwiki-shared.css,
* which the core loads first and which handle dark mode.
* The .hw-sound__* rules are repeated in every sound module's
* stylesheet (sound-wave, harmonics, sound-propagation) because
* the core loads one CSS file per module; keep them in sync.
* ========================================================= */
/* Card */
.hw-sound {
font-family: var(--hw-font);
font-size: 14px;
line-height: 1.4;
color: var(--hw-text-base);
background: var(--hw-bg-base);
border: 0.5px solid var(--hw-border);
border-radius: var(--hw-radius-lg);
padding: 12px 16px;
margin: 1em 0 0;
box-sizing: border-box;
}
.hw-sound__bar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.hw-sound__note,
.hw-sound__axis {
font-size: 12px;
color: var(--hw-text-muted);
}
/* Canvas */
.hw-sound__canvas {
display: block;
width: 100%;
border-radius: var(--hw-radius-sm);
background: var(--hw-bg-subtle);
touch-action: pan-y;
}
.hw-sound__axis {
display: flex;
justify-content: space-between;
margin: 4px 0 12px;
}
/* Control rows */
.hw-sound__row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 12px;
margin: 0 0 10px;
}
.hw-sound__row--disabled {
opacity: 0.4;
}
.hw-sound__label {
min-width: 110px;
color: var(--hw-text-muted);
}
.hw-sound__range {
flex: 1;
min-width: 120px;
accent-color: var(--hw-color-primary);
}
.hw-sound__out {
min-width: 72px;
text-align: right;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
/* Buttons and segmented choices */
.hw-sound__seg {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.hw-sound__btn {
font: inherit;
font-size: 13px;
padding: 4px 12px;
border: 0.5px solid var(--hw-border);
border-radius: var(--hw-radius-sm);
background: var(--hw-bg-base);
color: var(--hw-text-base);
cursor: pointer;
}
.hw-sound__btn:hover {
background: var(--hw-bg-subtle);
}
.hw-sound__btn[aria-pressed="true"] {
background: var(--hw-color-primary-subtle);
border-color: var(--hw-color-primary);
color: var(--hw-color-primary);
}
.hw-sound__btn:focus-visible,
.hw-sound__range:focus-visible {
outline: 2px solid var(--hw-color-primary);
outline-offset: 1px;
}
.hw-sound__play {
display: inline-flex;
align-items: center;
gap: 6px;
}
.hw-sound__icon {
font-size: 11px;
width: 12px;
text-align: center;
}
/* Stat cards */
.hw-sound__stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px;
margin: 8px 0 4px;
}
.hw-sound__stat {
background: var(--hw-bg-subtle);
border-radius: var(--hw-radius-sm);
padding: 8px 10px;
}
.hw-sound__stat span {
display: block;
font-size: 12px;
color: var(--hw-text-muted);
}
.hw-sound__stat b {
font-size: 17px;
font-variant-numeric: tabular-nums;
}
@media print {
.hw-sound__btn {
display: none;
}
}
rkd6v69vyohbye9shqpge0m0ooghyna
MediaWiki:Hiruwiki/modules/sound-propagation.js
8
189969
781446
2026-10-28T10:40:14Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781446
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/* =========================================================
* Hiruwiki module: sound-propagation
* A longitudinal wave shown two ways on the same distance axis:
* top — particles oscillating around their rest positions
* ξ = A·sin(kx₀ − ωt); one highlighted tracer particle
* shows that matter stays put while the wave travels
* bottom — pressure, p ∝ −∂ξ/∂x, peaks under the compressions
*
* Controls: medium (air, water, steel), frequency, and air
* temperature (v = 331,3 + 0,606·T, the article's formula).
* The time slowdown is the same for every medium, so in steel
* the wave visibly races ~15× faster than in air, and at the
* same frequency its wavelength stretches.
*
* <div class="hiruwiki" data-module="sound-propagation"
* data-medium="air" data-frequency="800"
* data-temperature="20"></div>
*
* data-medium air | water | steel
* data-frequency 200–4000 Hz, default 800
* data-temperature −20–40 °C, default 20
*
* Particle displacement is exaggerated (real air molecules move
* micrometres) and capped at λ/7 so neighbours never cross.
* Particle positions are seeded, so every load looks the same.
* No audio: the heard pitch is identical in every medium.
* =========================================================
* i18n: add a new key to the `messages` object below and
* a matching entry for every language code you need.
* Banana falls back to 'en' when a key is missing.
* ========================================================= */
( function () {
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"fr": {
"_name": "Propagation du son",
"hint": "La particule mise en évidence n'avance pas : elle vibre sur place. C'est l'onde qui se propage.",
"pause": "Pause",
"resume": "Reprendre",
"slowNote": "Animation ralentie 500 fois (de la même façon dans tous les milieux)",
"medium": "Milieu",
"air": "Air",
"water": "Eau",
"steel": "Acier",
"frequency": "Fréquence",
"temperature": "Température de l'air",
"speed": "Vitesse du son",
"wavelength": "Longueur d'onde (λ = v / f)",
"time100": "Temps pour parcourir 100 m",
"distanceAxis": "distance →",
"particles": "particules",
"pressure": "pression",
"compression": "compression",
"rarefaction": "raréfaction"
},
"eu": {
"_name": "Soinuaren hedapena",
"hint": "Nabarmendutako partikula ez da aurrera joaten: bere lekuan dardaratzen da. Uhina da hedatzen dena.",
"pause": "Pausatu",
"resume": "Jarraitu",
"slowNote": "Animazioa 500 aldiz moteldua (ingurune guztietan berdin)",
"medium": "Ingurunea",
"air": "Airea",
"water": "Ura",
"steel": "Altzairua",
"frequency": "Maiztasuna",
"temperature": "Airearen tenperatura",
"speed": "Soinuaren abiadura",
"wavelength": "Uhin-luzera (λ = v / f)",
"time100": "100 m egiteko denbora",
"distanceAxis": "distantzia →",
"particles": "partikulak",
"pressure": "presioa",
"compression": "konpresioa",
"rarefaction": "enrarezimendua"
},
"en": {
"_name": "Sound propagation",
"hint": "The highlighted particle doesn't move forward: it vibrates in place. What travels is the wave.",
"pause": "Pause",
"resume": "Resume",
"slowNote": "Animation slowed down 500× (equally in every medium)",
"medium": "Medium",
"air": "Air",
"water": "Water",
"steel": "Steel",
"frequency": "Frequency",
"temperature": "Air temperature",
"speed": "Speed of sound",
"wavelength": "Wavelength (λ = v / f)",
"time100": "Time to travel 100 m",
"distanceAxis": "distance →",
"particles": "particles",
"pressure": "pressure",
"compression": "compression",
"rarefaction": "rarefaction"
},
"qqq": {
"_name": "Name of the Sound propagation module",
"hint": "Instruction text shown in the footer.",
"pause": "Button that pauses the animation.",
"resume": "Button that resumes the animation.",
"slowNote": "Note above the graph explaining the time scale.",
"medium": "Label of the medium selector.",
"air": "Medium option: air (speed depends on temperature).",
"water": "Medium option: water at about 20 °C (1482 m/s).",
"steel": "Medium option: steel (5100 m/s, thin-bar value).",
"frequency": "Label of the frequency slider.",
"temperature": "Label of the air temperature slider (only active for air).",
"speed": "Stat card: speed of sound in the chosen medium.",
"wavelength": "Stat card: wavelength.",
"time100": "Stat card: time sound needs to travel 100 metres.",
"distanceAxis": "Centre label under the horizontal axis.",
"particles": "Canvas label above the particle band.",
"pressure": "Canvas label above the pressure curve.",
"compression": "Canvas label at the top of the pressure axis.",
"rarefaction": "Canvas label at the bottom of the pressure axis."
},
"es": {
"_name": "Propagación del sonido",
"hint": "La partícula resaltada no avanza: vibra en su sitio. Lo que viaja es la onda.",
"pause": "Pausar",
"resume": "Continuar",
"slowNote": "Animación ralentizada 500 veces (igual en todos los medios)",
"medium": "Medio",
"air": "Aire",
"water": "Agua",
"steel": "Acero",
"frequency": "Frecuencia",
"temperature": "Temperatura del aire",
"speed": "Velocidad del sonido",
"wavelength": "Longitud de onda (λ = v / f)",
"time100": "Tiempo para recorrer 100 m",
"distanceAxis": "distancia →",
"particles": "partículas",
"pressure": "presión",
"compression": "compresión",
"rarefaction": "rarefacción"
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
function t( key, params ) {
return banana.i18n.apply( banana, [ key ].concat( params || [] ) );
}
/* ── Constants ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var H_PX = 300; /* canvas height (px) */
var L = 3; /* visible distance (m) */
var SLOW = 1 / 500; /* simulated seconds per real second */
var BAND = 150; /* particle band height (px) */
var TOP = 16; /* top margin above the band (px) */
var PR = 46; /* pressure curve half-height (px) */
/* Steel: 5100 m/s is the article's value (thin bar); bulk steel
* is ~5900 m/s. Water: 1482 m/s at ~20 °C. */
var MEDIA = { water: 1482, steel: 5100 };
var MEDIA_KEYS = [ 'air', 'water', 'steel' ];
var uid = 0;
/* Seeded particle field: positions as fractions of the band, so the
* picture is identical on every load and in print/PDF. */
var FIELD = ( function () {
var seed = 7, pts = [];
function rnd() { seed = ( seed * 16807 ) % 2147483647; return seed / 2147483647; }
for ( var i = 0; i < 1400; i++ ) { pts.push( { x: rnd(), y: rnd() } ); }
return pts;
}() );
/* ── Shared helpers ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
function el( tag, cls, text ) {
var e = document.createElement( tag );
if ( cls ) { e.className = cls; }
if ( text !== undefined ) { e.textContent = text; }
return e;
}
function num( v, min, max, def ) {
var n = parseFloat( v );
if ( isNaN( n ) ) { return def; }
return Math.max( min, Math.min( max, n ) );
}
function fmt( n, d ) {
try {
return n.toLocaleString( lang, { minimumFractionDigits: d, maximumFractionDigits: d } );
} catch ( e ) {
return n.toFixed( d );
}
}
function isDark() {
var root = document.documentElement;
return root.classList.contains( 'skin-theme-clientpref-night' ) ||
root.classList.contains( 'client-dark-mode' ) ||
document.body.classList.contains( 'mw-dark-mode' ) ||
( root.classList.contains( 'skin-theme-clientpref-os' ) &&
window.matchMedia && window.matchMedia( '(prefers-color-scheme: dark)' ).matches );
}
function palette() {
var d = isDark();
return {
wave: d ? '#5DCAA5' : '#0F6E56',
tracer: d ? '#F0997B' : '#D85A30',
particle: d ? '#B4B2A9' : '#5F5E5A',
grid: d ? '#3a3a38' : '#e2e0d8',
axis: d ? '#6b6a65' : '#a8a69e',
text: d ? '#B4B2A9' : '#5F5E5A'
};
}
function makeFooter( container, text ) {
var footer = el( 'div', 'hw-footer' );
var logo = el( 'a', 'hw-footer-icon' );
logo.href = ( window.mw && mw.util && mw.util.getUrl( 'Wikipedia:Hiruwiki' ) ) || '#';
logo.title = 'Hiruwiki';
if ( window.hiruwiki && window.hiruwiki.getLogoSvg ) {
logo.insertAdjacentHTML( 'beforeend', window.hiruwiki.getLogoSvg( 22 ) );
}
footer.appendChild( logo );
footer.appendChild( el( 'span', 'hw-footer__text', text ) );
container.appendChild( footer );
}
function makeSlider( parent, label, min, max, step, value ) {
var id = 'hw-sound-' + ( ++uid );
var row = el( 'div', 'hw-sound__row' );
var lab = el( 'label', 'hw-sound__label', label );
lab.htmlFor = id;
var input = el( 'input', 'hw-sound__range' );
input.type = 'range'; input.id = id;
input.min = min; input.max = max; input.step = step; input.value = value;
var out = el( 'output', 'hw-sound__out' );
out.setAttribute( 'for', id );
row.appendChild( lab ); row.appendChild( input ); row.appendChild( out );
parent.appendChild( row );
return { row: row, input: input, out: out };
}
function makeSegment( parent, label, options, value, onChange ) {
var id = 'hw-sound-' + ( ++uid );
var row = el( 'div', 'hw-sound__row' );
var lab = el( 'span', 'hw-sound__label', label );
lab.id = id;
var seg = el( 'div', 'hw-sound__seg' );
seg.setAttribute( 'role', 'group' );
seg.setAttribute( 'aria-labelledby', id );
var buttons = options.map( function ( o ) {
var b = el( 'button', 'hw-sound__btn', o.label );
b.type = 'button';
b.addEventListener( 'click', function () { set( o.value ); onChange( o.value ); } );
seg.appendChild( b );
return { b: b, v: o.value };
} );
function set( v ) {
buttons.forEach( function ( x ) { x.b.setAttribute( 'aria-pressed', x.v === v ? 'true' : 'false' ); } );
}
set( value );
row.appendChild( lab ); row.appendChild( seg );
parent.appendChild( row );
return { row: row, set: set };
}
function animator( target, frame ) {
var visible = true, raf = null, last = 0;
var reduce = window.matchMedia && window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches;
function tick( now ) {
raf = null;
var dt = last ? Math.min( ( now - last ) / 1000, 0.05 ) : 0;
last = now;
frame( reduce ? 0 : dt );
if ( visible && !reduce ) { raf = requestAnimationFrame( tick ); }
}
function start() {
if ( !raf ) { last = 0; raf = requestAnimationFrame( tick ); }
}
if ( 'IntersectionObserver' in window ) {
new IntersectionObserver( function ( entries ) {
visible = entries[ 0 ].isIntersecting;
if ( visible ) { start(); }
} ).observe( target );
}
start();
return { redraw: function () { frame( 0 ); }, reduced: reduce };
}
/* ── Build widget ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
function buildWidget( container ) {
var ds = container.dataset;
var state = {
medium: MEDIA_KEYS.indexOf( ds.medium ) >= 0 ? ds.medium : 'air',
f: num( ds.frequency, 200, 4000, 800 ),
T: num( ds.temperature, -20, 40, 20 ),
time: 0,
run: true
};
function speed() {
return state.medium === 'air' ? 331.3 + 0.606 * state.T : MEDIA[ state.medium ];
}
container.innerHTML = '';
var root = el( 'div', 'hw-sound' );
container.appendChild( root );
/* -- top bar -- */
var bar = el( 'div', 'hw-sound__bar' );
var pauseBtn = el( 'button', 'hw-sound__btn hw-sound__play' );
pauseBtn.type = 'button';
var pIcon = el( 'span', 'hw-sound__icon' );
pIcon.setAttribute( 'aria-hidden', 'true' );
var pText = el( 'span' );
pauseBtn.appendChild( pIcon ); pauseBtn.appendChild( pText );
function setPauseUI() {
pIcon.textContent = state.run ? '❚❚' : '▶';
pText.textContent = state.run ? t( 'pause' ) : t( 'resume' );
}
pauseBtn.addEventListener( 'click', function () { state.run = !state.run; setPauseUI(); } );
bar.appendChild( pauseBtn );
bar.appendChild( el( 'span', 'hw-sound__note', t( 'slowNote' ) ) );
root.appendChild( bar );
/* -- canvas -- */
var canvas = el( 'canvas', 'hw-sound__canvas' );
canvas.style.height = H_PX + 'px';
canvas.setAttribute( 'role', 'img' );
canvas.setAttribute( 'aria-label', t( '_name' ) );
root.appendChild( canvas );
var ctx = canvas.getContext( '2d' );
var axis = el( 'div', 'hw-sound__axis' );
axis.appendChild( el( 'span', null, '0 m' ) );
axis.appendChild( el( 'span', null, t( 'distanceAxis' ) ) );
axis.appendChild( el( 'span', null, fmt( L, 0 ) + ' m' ) );
root.appendChild( axis );
/* -- controls -- */
makeSegment( root, t( 'medium' ), MEDIA_KEYS.map( function ( m ) { return { value: m, label: t( m ) }; } ),
state.medium, function ( m ) { state.medium = m; update(); } );
var fSlider = makeSlider( root, t( 'frequency' ), 200, 4000, 10, state.f );
fSlider.input.addEventListener( 'input', function () { state.f = +fSlider.input.value; update(); } );
var tSlider = makeSlider( root, t( 'temperature' ), -20, 40, 1, state.T );
tSlider.input.addEventListener( 'input', function () { state.T = +tSlider.input.value; update(); } );
/* -- stat cards -- */
var stats = el( 'div', 'hw-sound__stats' );
function stat( label ) {
var c = el( 'div', 'hw-sound__stat' );
c.appendChild( el( 'span', null, label ) );
var b = el( 'b' );
c.appendChild( b );
stats.appendChild( c );
return b;
}
var sSpeed = stat( t( 'speed' ) );
var sLambda = stat( t( 'wavelength' ) );
var sTime = stat( t( 'time100' ) );
root.appendChild( stats );
makeFooter( container, t( 'hint' ) );
/* -- drawing -- */
function draw() {
var p = palette();
var W = canvas.clientWidth, H = H_PX;
var v = speed(), lam = v / state.f;
var k = 2 * Math.PI / lam, w = 2 * Math.PI * state.f, sx = W / L;
var A = Math.min( lam / 7, 0.06 );
var tt = state.time, px, x, xi, y, i;
ctx.clearRect( 0, 0, W, H );
/* half-metre grid */
ctx.lineWidth = 1;
ctx.strokeStyle = p.grid;
for ( var m = 0.5; m < L; m += 0.5 ) {
ctx.beginPath(); ctx.moveTo( m * sx, 0 ); ctx.lineTo( m * sx, H ); ctx.stroke();
}
ctx.fillStyle = p.text;
ctx.font = '11px sans-serif';
ctx.textAlign = 'left';
ctx.fillText( t( 'particles' ), 8, TOP - 4 );
/* particles — density scales with width */
var count = Math.min( FIELD.length, Math.round( W * 0.8 ) );
ctx.fillStyle = p.particle;
for ( i = 0; i < count; i++ ) {
x = FIELD[ i ].x * L;
xi = A * Math.sin( k * x - w * tt );
y = TOP + 8 + FIELD[ i ].y * ( BAND - 16 );
ctx.beginPath(); ctx.arc( ( x + xi ) * sx, y, 2.2, 0, 2 * Math.PI ); ctx.fill();
}
/* tracer particle + its rest position */
var tx = L / 2, txi = A * Math.sin( k * tx - w * tt );
ctx.strokeStyle = p.tracer;
ctx.setLineDash( [ 3, 3 ] );
ctx.beginPath(); ctx.moveTo( tx * sx, TOP ); ctx.lineTo( tx * sx, TOP + BAND ); ctx.stroke();
ctx.setLineDash( [] );
ctx.fillStyle = p.tracer;
ctx.beginPath(); ctx.arc( ( tx + txi ) * sx, TOP + BAND / 2, 5.5, 0, 2 * Math.PI ); ctx.fill();
/* pressure curve: p ∝ −∂ξ/∂x = −A·k·cos(kx − ωt) */
var mid = TOP + BAND + 70;
ctx.strokeStyle = p.axis;
ctx.beginPath(); ctx.moveTo( 0, mid ); ctx.lineTo( W, mid ); ctx.stroke();
ctx.fillStyle = p.text;
ctx.fillText( t( 'pressure' ), 8, TOP + BAND + 16 );
ctx.textAlign = 'right';
ctx.fillText( t( 'compression' ), W - 8, mid - PR - 6 );
ctx.fillText( t( 'rarefaction' ), W - 8, mid + PR + 14 );
ctx.strokeStyle = p.wave;
ctx.lineWidth = 2.2;
ctx.beginPath();
for ( px = 0; px <= W; px += 2 ) {
x = px / sx;
y = mid + Math.cos( k * x - w * tt ) * PR;
if ( px ) { ctx.lineTo( px, y ); } else { ctx.moveTo( px, y ); }
}
ctx.stroke();
}
function frame( dt ) {
if ( state.run ) { state.time += dt * SLOW; }
draw();
}
function update() {
var v = speed(), lam = v / state.f, t100 = 100 / v;
fSlider.out.textContent = fmt( state.f, 0 ) + ' Hz';
tSlider.out.textContent = fmt( state.T, 0 ) + ' °C';
tSlider.input.disabled = state.medium !== 'air';
tSlider.row.classList.toggle( 'hw-sound__row--disabled', state.medium !== 'air' );
sSpeed.textContent = fmt( Math.round( v ), 0 ) + ' m/s';
sLambda.textContent = lam >= 1 ? fmt( lam, 2 ) + ' m' : fmt( lam * 100, 0 ) + ' cm';
sTime.textContent = t100 >= 0.1 ? fmt( t100, 2 ) + ' s' : fmt( t100 * 1000, 1 ) + ' ms';
anim.redraw();
}
function resize() {
var dpr = window.devicePixelRatio || 1;
canvas.width = Math.round( canvas.clientWidth * dpr );
canvas.height = Math.round( H_PX * dpr );
ctx.setTransform( dpr, 0, 0, dpr, 0, 0 );
anim.redraw();
}
var anim = animator( canvas, frame );
if ( anim.reduced ) { state.run = false; }
setPauseUI();
new ResizeObserver( resize ).observe( canvas );
/* dark-mode live update (MediaWiki) */
var mo = new MutationObserver( function () { anim.redraw(); } );
mo.observe( document.documentElement, { attributes: true, attributeFilter: [ 'class' ] } );
mo.observe( document.body, { attributes: true, attributeFilter: [ 'class' ] } );
update();
}
/* ----------------------------------------------------------
* Initialise all matching containers on the page
* ---------------------------------------------------------- */
document.querySelectorAll( '.hiruwiki[data-module="sound-propagation"]' ).forEach( buildWidget );
}() );
poh4i11kuicak8wzxhzt9zqv41mn28u
MediaWiki:Hiruwiki/modules/sound-wave.css
8
189970
781447
2026-10-28T10:40:18Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781447
css
text/css
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/* =========================================================
* Hiruwiki module: sound-wave — stylesheet
* Colours come from the --hw-* tokens in hiruwiki-shared.css,
* which the core loads first and which handle dark mode.
* The .hw-sound__* rules are repeated in every sound module's
* stylesheet (sound-wave, harmonics, sound-propagation) because
* the core loads one CSS file per module; keep them in sync.
* ========================================================= */
/* Card */
.hw-sound {
font-family: var(--hw-font);
font-size: 14px;
line-height: 1.4;
color: var(--hw-text-base);
background: var(--hw-bg-base);
border: 0.5px solid var(--hw-border);
border-radius: var(--hw-radius-lg);
padding: 12px 16px;
margin: 1em 0 0;
box-sizing: border-box;
}
.hw-sound__bar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.hw-sound__note,
.hw-sound__axis {
font-size: 12px;
color: var(--hw-text-muted);
}
/* Canvas */
.hw-sound__canvas {
display: block;
width: 100%;
border-radius: var(--hw-radius-sm);
background: var(--hw-bg-subtle);
touch-action: pan-y;
}
.hw-sound__axis {
display: flex;
justify-content: space-between;
margin: 4px 0 12px;
}
/* Control rows */
.hw-sound__row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 12px;
margin: 0 0 10px;
}
.hw-sound__row--disabled {
opacity: 0.4;
}
.hw-sound__label {
min-width: 110px;
color: var(--hw-text-muted);
}
.hw-sound__range {
flex: 1;
min-width: 120px;
accent-color: var(--hw-color-primary);
}
.hw-sound__out {
min-width: 72px;
text-align: right;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
/* Buttons and segmented choices */
.hw-sound__seg {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.hw-sound__btn {
font: inherit;
font-size: 13px;
padding: 4px 12px;
border: 0.5px solid var(--hw-border);
border-radius: var(--hw-radius-sm);
background: var(--hw-bg-base);
color: var(--hw-text-base);
cursor: pointer;
}
.hw-sound__btn:hover {
background: var(--hw-bg-subtle);
}
.hw-sound__btn[aria-pressed="true"] {
background: var(--hw-color-primary-subtle);
border-color: var(--hw-color-primary);
color: var(--hw-color-primary);
}
.hw-sound__btn:focus-visible,
.hw-sound__range:focus-visible {
outline: 2px solid var(--hw-color-primary);
outline-offset: 1px;
}
.hw-sound__play {
display: inline-flex;
align-items: center;
gap: 6px;
}
.hw-sound__icon {
font-size: 11px;
width: 12px;
text-align: center;
}
/* Stat cards */
.hw-sound__stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 8px;
margin: 8px 0 4px;
}
.hw-sound__stat {
background: var(--hw-bg-subtle);
border-radius: var(--hw-radius-sm);
padding: 8px 10px;
}
.hw-sound__stat span {
display: block;
font-size: 12px;
color: var(--hw-text-muted);
}
.hw-sound__stat b {
font-size: 17px;
font-variant-numeric: tabular-nums;
}
@media print {
.hw-sound__btn {
display: none;
}
}
3ra7wx3v3psn6a6gy8yy6vhsw06gcni
MediaWiki:Hiruwiki/modules/sound-wave.js
8
189971
781448
2026-10-28T10:40:21Z
ItsNyoty
68948
Repo at ea48ed0: Deployment update
781448
javascript
text/javascript
/*
* DO NOT EDIT THIS PAGE DIRECTLY ON-WIKI!
* This page is automatically deployed from GitHub.
* Any changes made here will be overwritten by the next deployment.
* Source: https://github.com/ItsNyoty/Hiruwiki
*/
/* =========================================================
* Hiruwiki module: sound-wave
* A single oscillator: pressure–time graph plus real audio.
* Controls: frequency (log scale, 50–2000 Hz), amplitude,
* waveform (sine, triangle, square, sawtooth).
*
* The same module can be embedded several times, each
* instance showing only the controls its section needs:
*
* <div class="hiruwiki" data-module="sound-wave"
* data-controls="frequency,amplitude,waveform"
* data-frequency="440" data-amplitude="70"
* data-waveform="sine"></div>
*
* data-controls comma list; any of frequency, amplitude,
* waveform (default: all three)
* data-frequency 50–2000, default 440
* data-amplitude 0–100, default 70
* data-waveform sine | triangle | square | sawtooth
*
* Time axis: fixed 10 ms window. The animation advances time
* at a constant slowed-down rate, so the scroll speed is the
* same at every frequency.
* =========================================================
* i18n: add a new key to the `messages` object below and
* a matching entry for every language code you need.
* Banana falls back to 'en' when a key is missing.
* `octaveOffset` shifts octave numbers: 0 gives A4 = 440 Hz
* (scientific), -1 gives La3 = 440 Hz (Franco-Spanish).
* ========================================================= */
( function () {
/* ── I18N ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var messages = /* I18N_START */ {
"fr": {
"_name": "Onde sonore",
"hint": "Déplacez les curseurs et appuyez sur « Écouter » pour entendre le changement",
"play": "Écouter",
"stop": "Arrêter",
"noAudio": "Votre navigateur ne peut pas jouer de son",
"frequency": "Fréquence",
"amplitude": "Amplitude",
"waveform": "Forme d'onde",
"sine": "Sinusoïdale",
"triangle": "Triangulaire",
"square": "Carrée",
"sawtooth": "Dent de scie",
"period": "Période",
"wavelength": "Longueur d'onde dans l'air",
"note": "Note la plus proche",
"window": "Fenêtre temporelle : 10 ms · animation ralentie",
"timeAxis": "temps →",
"pressurePlus": "+ pression",
"pressureMinus": "− pression",
"amplitudeMark": "amplitude",
"noteNames": "Do Do♯ Ré Ré♯ Mi Fa Fa♯ Sol Sol♯ La La♯ Si",
"octaveOffset": "-1"
},
"eu": {
"_name": "Soinu-uhina",
"hint": "Mugitu graduatzaileak eta sakatu «Entzun» aldaketa entzuteko",
"play": "Entzun",
"stop": "Gelditu",
"noAudio": "Zure nabigatzaileak ezin du soinurik erreproduzitu",
"frequency": "Maiztasuna",
"amplitude": "Anplitudea",
"waveform": "Uhin mota",
"sine": "Sinusoidala",
"triangle": "Triangeluarra",
"square": "Karratua",
"sawtooth": "Zerra-hortza",
"period": "Periodoa",
"wavelength": "Uhin-luzera airean",
"note": "Nota hurbilena",
"window": "Denbora-leihoa: 10 ms · animazioa moteldua",
"timeAxis": "denbora →",
"pressurePlus": "+ presioa",
"pressureMinus": "− presioa",
"amplitudeMark": "anplitudea",
"noteNames": "Do Do♯ Re Re♯ Mi Fa Fa♯ Sol Sol♯ La La♯ Si",
"octaveOffset": "-1"
},
"en": {
"_name": "Sound wave",
"hint": "Move the sliders and press “Listen” to hear the change",
"play": "Listen",
"stop": "Stop",
"noAudio": "Your browser can't play sound",
"frequency": "Frequency",
"amplitude": "Amplitude",
"waveform": "Waveform",
"sine": "Sine",
"triangle": "Triangle",
"square": "Square",
"sawtooth": "Sawtooth",
"period": "Period",
"wavelength": "Wavelength in air",
"note": "Nearest note",
"window": "Time window: 10 ms · animation slowed down",
"timeAxis": "time →",
"pressurePlus": "+ pressure",
"pressureMinus": "− pressure",
"amplitudeMark": "amplitude",
"noteNames": "C C♯ D D♯ E F F♯ G G♯ A A♯ B",
"octaveOffset": "0"
},
"qqq": {
"_name": "Name of the Sound wave module",
"hint": "Instruction text shown in the footer.",
"play": "Label of the button that starts the sound.",
"stop": "Label of the button that stops the sound.",
"noAudio": "Shown when the browser has no Web Audio support.",
"frequency": "Label of the frequency slider.",
"amplitude": "Label of the amplitude slider.",
"waveform": "Label of the waveform selector.",
"sine": "Waveform option: sine wave.",
"triangle": "Waveform option: triangle wave.",
"square": "Waveform option: square wave.",
"sawtooth": "Waveform option: sawtooth wave.",
"period": "Stat card: duration of one cycle.",
"wavelength": "Stat card: wavelength in air at 343 m/s.",
"note": "Stat card: nearest musical note.",
"window": "Note above the graph explaining the time scale.",
"timeAxis": "Label under the horizontal axis.",
"pressurePlus": "Canvas label at the top of the vertical axis.",
"pressureMinus": "Canvas label at the bottom of the vertical axis.",
"amplitudeMark": "Canvas label next to the dashed amplitude line.",
"noteNames": "Twelve note names from C upwards, separated by single spaces.",
"octaveOffset": "Integer added to octave numbers. 0 = A4 is 440 Hz; -1 = La3 is 440 Hz."
},
"es": {
"_name": "Onda sonora",
"hint": "Mueve los controles y pulsa «Escuchar» para oír el cambio",
"play": "Escuchar",
"stop": "Detener",
"noAudio": "Tu navegador no puede reproducir sonido",
"frequency": "Frecuencia",
"amplitude": "Amplitud",
"waveform": "Forma de onda",
"sine": "Sinusoidal",
"triangle": "Triangular",
"square": "Cuadrada",
"sawtooth": "Diente de sierra",
"period": "Periodo",
"wavelength": "Longitud de onda en el aire",
"note": "Nota más cercana",
"window": "Ventana de tiempo: 10 ms · animación ralentizada",
"timeAxis": "tiempo →",
"pressurePlus": "+ presión",
"pressureMinus": "− presión",
"amplitudeMark": "amplitud",
"noteNames": "Do Do♯ Re Re♯ Mi Fa Fa♯ Sol Sol♯ La La♯ Si",
"octaveOffset": "-1"
}
} /* I18N_END */
var lang = (window.mw && mw.config.get('wgUserLanguage')) || 'en';
var banana = new Banana(lang.split('-')[0]);
banana.load(messages);
function t(key, vars) {
var args = Array.isArray(vars) ? vars : [];
var str = banana.i18n(key, ...args);
if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
Object.keys(vars).forEach(function(k) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
});
}
return str;
}
function t( key, params ) {
return banana.i18n.apply( banana, [ key ].concat( params || [] ) );
}
/* ── Constants ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
var H_PX = 220; /* canvas height (px) */
var F_MIN = 50; /* frequency slider range (Hz) */
var F_MAX = 2000;
var WIN = 0.010; /* visible time window (s) */
var SLOW = WIN / 4; /* simulated seconds per real second */
var V_AIR = 343; /* speed of sound for the wavelength card */
var MAX_GAIN = 0.15; /* never louder than this */
var WAVEFORMS = [ 'sine', 'triangle', 'square', 'sawtooth' ];
var uid = 0;
/* ── Shared helpers ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
function el( tag, cls, text ) {
var e = document.createElement( tag );
if ( cls ) { e.className = cls; }
if ( text !== undefined ) { e.textContent = text; }
return e;
}
function num( v, min, max, def ) {
var n = parseFloat( v );
if ( isNaN( n ) ) { return def; }
return Math.max( min, Math.min( max, n ) );
}
function fmt( n, d ) {
try {
return n.toLocaleString( lang, { minimumFractionDigits: d, maximumFractionDigits: d } );
} catch ( e ) {
return n.toFixed( d );
}
}
function isDark() {
var root = document.documentElement;
return root.classList.contains( 'skin-theme-clientpref-night' ) ||
root.classList.contains( 'client-dark-mode' ) ||
document.body.classList.contains( 'mw-dark-mode' ) ||
( root.classList.contains( 'skin-theme-clientpref-os' ) &&
window.matchMedia && window.matchMedia( '(prefers-color-scheme: dark)' ).matches );
}
function palette() {
var d = isDark();
return {
wave: d ? '#5DCAA5' : '#0F6E56',
accent: d ? '#FAC775' : '#BA7517',
grid: d ? '#3a3a38' : '#e2e0d8',
axis: d ? '#6b6a65' : '#a8a69e',
text: d ? '#B4B2A9' : '#5F5E5A'
};
}
/* One AudioContext for every Hiruwiki sound module on the page,
* created on the first click; only one module sounds at a time. */
function audioHub() {
window.hiruwiki = window.hiruwiki || {};
if ( !window.hiruwiki.audio ) {
var hub = {
ctx: null,
current: null,
context: function () {
if ( !hub.ctx ) {
var AC = window.AudioContext || window.webkitAudioContext;
if ( !AC ) { return null; }
hub.ctx = new AC();
}
if ( hub.ctx.state === 'suspended' ) { hub.ctx.resume(); }
return hub.ctx;
},
claim: function ( owner ) {
if ( hub.current && hub.current !== owner ) { hub.current.stop(); }
hub.current = owner;
},
release: function ( owner ) {
if ( hub.current === owner ) { hub.current = null; }
}
};
document.addEventListener( 'visibilitychange', function () {
if ( document.hidden && hub.current ) { hub.current.stop(); }
} );
window.hiruwiki.audio = hub;
}
return window.hiruwiki.audio;
}
function makeFooter( container, text ) {
var footer = el( 'div', 'hw-footer' );
var logo = el( 'a', 'hw-footer-icon' );
logo.href = ( window.mw && mw.util && mw.util.getUrl( 'Wikipedia:Hiruwiki' ) ) || '#';
logo.title = 'Hiruwiki';
if ( window.hiruwiki && window.hiruwiki.getLogoSvg ) {
logo.insertAdjacentHTML( 'beforeend', window.hiruwiki.getLogoSvg( 22 ) );
}
footer.appendChild( logo );
footer.appendChild( el( 'span', 'hw-footer__text', text ) );
container.appendChild( footer );
}
function makeSlider( parent, label, min, max, step, value ) {
var id = 'hw-sound-' + ( ++uid );
var row = el( 'div', 'hw-sound__row' );
var lab = el( 'label', 'hw-sound__label', label );
lab.htmlFor = id;
var input = el( 'input', 'hw-sound__range' );
input.type = 'range'; input.id = id;
input.min = min; input.max = max; input.step = step; input.value = value;
var out = el( 'output', 'hw-sound__out' );
out.setAttribute( 'for', id );
row.appendChild( lab ); row.appendChild( input ); row.appendChild( out );
parent.appendChild( row );
return { row: row, input: input, out: out };
}
function makeSegment( parent, label, options, value, onChange ) {
var id = 'hw-sound-' + ( ++uid );
var row = el( 'div', 'hw-sound__row' );
var lab = el( 'span', 'hw-sound__label', label );
lab.id = id;
var seg = el( 'div', 'hw-sound__seg' );
seg.setAttribute( 'role', 'group' );
seg.setAttribute( 'aria-labelledby', id );
var buttons = options.map( function ( o ) {
var b = el( 'button', 'hw-sound__btn', o.label );
b.type = 'button';
b.addEventListener( 'click', function () { set( o.value ); onChange( o.value ); } );
seg.appendChild( b );
return { b: b, v: o.value };
} );
function set( v ) {
buttons.forEach( function ( x ) { x.b.setAttribute( 'aria-pressed', x.v === v ? 'true' : 'false' ); } );
}
set( value );
row.appendChild( lab ); row.appendChild( seg );
parent.appendChild( row );
return { row: row, set: set };
}
function makePlayButton( onClick ) {
var b = el( 'button', 'hw-sound__btn hw-sound__play' );
b.type = 'button';
var icon = el( 'span', 'hw-sound__icon' );
icon.setAttribute( 'aria-hidden', 'true' );
var txt = el( 'span' );
b.appendChild( icon ); b.appendChild( txt );
b.addEventListener( 'click', onClick );
return {
el: b,
set: function ( on, onLabel, offLabel ) {
icon.textContent = on ? '❚❚' : '▶';
txt.textContent = on ? onLabel : offLabel;
b.setAttribute( 'aria-pressed', on ? 'true' : 'false' );
}
};
}
/* rAF loop that only runs while the widget is on screen and
* draws a single static frame when reduced motion is requested. */
function animator( target, frame ) {
var visible = true, raf = null, last = 0;
var reduce = window.matchMedia && window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches;
function tick( now ) {
raf = null;
var dt = last ? Math.min( ( now - last ) / 1000, 0.05 ) : 0;
last = now;
frame( reduce ? 0 : dt );
if ( visible && !reduce ) { raf = requestAnimationFrame( tick ); }
}
function start() {
if ( !raf ) { last = 0; raf = requestAnimationFrame( tick ); }
}
if ( 'IntersectionObserver' in window ) {
new IntersectionObserver( function ( entries ) {
visible = entries[ 0 ].isIntersecting;
if ( visible ) { start(); }
} ).observe( target );
}
start();
return { redraw: function () { frame( 0 ); } };
}
/* ── Module-specific helpers ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
function waveValue( type, x ) {
var p = x - Math.floor( x );
if ( type === 'sine' ) { return Math.sin( 2 * Math.PI * x ); }
if ( type === 'square' ) { return p < 0.5 ? 1 : -1; }
if ( type === 'triangle' ) { return p < 0.25 ? 4 * p : ( p < 0.75 ? 2 - 4 * p : 4 * p - 4 ); }
return 2 * p - 1; /* sawtooth */
}
function noteName( f ) {
var names = t( 'noteNames' ).split( ' ' );
var off = parseInt( t( 'octaveOffset' ), 10 ) || 0;
var n = Math.round( 12 * Math.log2( f / 440 ) ) + 57;
return names[ ( ( n % 12 ) + 12 ) % 12 ] + ( Math.floor( n / 12 ) + off );
}
function sliderToFreq( v ) { return F_MIN * Math.pow( F_MAX / F_MIN, v / 1000 ); }
function freqToSlider( f ) { return Math.round( 1000 * Math.log( f / F_MIN ) / Math.log( F_MAX / F_MIN ) ); }
/* ── Build widget ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ── */
function buildWidget( container ) {
var ds = container.dataset;
var controls = ( ds.controls || 'frequency,amplitude,waveform' ).split( ',' ).map( function ( s ) { return s.trim(); } );
var state = {
f: num( ds.frequency, F_MIN, F_MAX, 440 ),
a: num( ds.amplitude, 0, 100, 70 ) / 100,
type: WAVEFORMS.indexOf( ds.waveform ) >= 0 ? ds.waveform : 'sine',
phase: 0
};
container.innerHTML = '';
var root = el( 'div', 'hw-sound' );
container.appendChild( root );
/* -- top bar -- */
var bar = el( 'div', 'hw-sound__bar' );
var play = makePlayButton( function () { if ( voice.playing ) { voice.stop(); } else { voice.start(); } } );
bar.appendChild( play.el );
bar.appendChild( el( 'span', 'hw-sound__note', t( 'window' ) ) );
root.appendChild( bar );
/* -- canvas -- */
var canvas = el( 'canvas', 'hw-sound__canvas' );
canvas.style.height = H_PX + 'px';
canvas.setAttribute( 'role', 'img' );
canvas.setAttribute( 'aria-label', t( '_name' ) );
root.appendChild( canvas );
var ctx = canvas.getContext( '2d' );
var axis = el( 'div', 'hw-sound__axis' );
axis.appendChild( el( 'span', null, '0 ms' ) );
axis.appendChild( el( 'span', null, t( 'timeAxis' ) ) );
axis.appendChild( el( 'span', null, fmt( WIN * 1000, 0 ) + ' ms' ) );
root.appendChild( axis );
/* -- controls -- */
var fSlider, aSlider;
if ( controls.indexOf( 'frequency' ) >= 0 ) {
fSlider = makeSlider( root, t( 'frequency' ), 0, 1000, 1, freqToSlider( state.f ) );
fSlider.input.addEventListener( 'input', function () { state.f = sliderToFreq( +fSlider.input.value ); update(); } );
}
if ( controls.indexOf( 'amplitude' ) >= 0 ) {
aSlider = makeSlider( root, t( 'amplitude' ), 0, 100, 1, Math.round( state.a * 100 ) );
aSlider.input.addEventListener( 'input', function () { state.a = +aSlider.input.value / 100; update(); } );
}
if ( controls.indexOf( 'waveform' ) >= 0 ) {
makeSegment( root, t( 'waveform' ), WAVEFORMS.map( function ( w ) { return { value: w, label: t( w ) }; } ),
state.type, function ( w ) { state.type = w; voice.retype(); anim.redraw(); } );
}
/* -- stat cards -- */
var stats = el( 'div', 'hw-sound__stats' );
function stat( label ) {
var c = el( 'div', 'hw-sound__stat' );
c.appendChild( el( 'span', null, label ) );
var b = el( 'b' );
c.appendChild( b );
stats.appendChild( c );
return b;
}
var sPeriod = stat( t( 'period' ) );
var sLambda = stat( t( 'wavelength' ) );
var sNote = stat( t( 'note' ) );
root.appendChild( stats );
makeFooter( container, t( 'hint' ) );
/* -- audio -- */
function level() {
return state.a * MAX_GAIN * ( state.type === 'square' || state.type === 'sawtooth' ? 0.5 : 1 );
}
var voice = {
playing: false, osc: null, gain: null, ac: null,
start: function () {
var hub = audioHub();
var ac = hub.context();
if ( !ac ) { play.el.title = t( 'noAudio' ); return; }
hub.claim( voice );
voice.ac = ac;
voice.osc = ac.createOscillator();
voice.gain = ac.createGain();
voice.osc.type = state.type;
voice.osc.frequency.value = state.f;
voice.gain.gain.value = 0;
voice.osc.connect( voice.gain ).connect( ac.destination );
voice.osc.start();
voice.gain.gain.setTargetAtTime( level(), ac.currentTime, 0.02 );
voice.playing = true;
play.set( true, t( 'stop' ), t( 'play' ) );
},
stop: function () {
if ( !voice.playing ) { return; }
var o = voice.osc;
voice.gain.gain.setTargetAtTime( 0, voice.ac.currentTime, 0.02 );
setTimeout( function () { o.stop(); o.disconnect(); }, 150 );
voice.playing = false;
audioHub().release( voice );
play.set( false, t( 'stop' ), t( 'play' ) );
},
retune: function () {
if ( !voice.playing ) { return; }
voice.osc.frequency.setTargetAtTime( state.f, voice.ac.currentTime, 0.02 );
voice.gain.gain.setTargetAtTime( level(), voice.ac.currentTime, 0.02 );
},
retype: function () {
if ( !voice.playing ) { return; }
voice.osc.type = state.type;
voice.gain.gain.setTargetAtTime( level(), voice.ac.currentTime, 0.02 );
}
};
play.set( false, t( 'stop' ), t( 'play' ) );
/* -- drawing -- */
function draw() {
var p = palette();
var W = canvas.clientWidth, H = H_PX, mid = H / 2, R = H / 2 - 18;
var px, y;
ctx.clearRect( 0, 0, W, H );
ctx.lineWidth = 1;
ctx.strokeStyle = p.grid;
for ( var i = 1; i < 10; i++ ) {
ctx.beginPath(); ctx.moveTo( W * i / 10, 0 ); ctx.lineTo( W * i / 10, H ); ctx.stroke();
}
ctx.strokeStyle = p.axis;
ctx.beginPath(); ctx.moveTo( 0, mid ); ctx.lineTo( W, mid ); ctx.stroke();
ctx.fillStyle = p.text;
ctx.font = '11px sans-serif';
ctx.textAlign = 'left';
ctx.fillText( t( 'pressurePlus' ), 8, 14 );
ctx.fillText( t( 'pressureMinus' ), 8, H - 8 );
ctx.strokeStyle = p.wave;
ctx.lineWidth = 2.2;
ctx.beginPath();
for ( px = 0; px <= W; px++ ) {
y = mid - waveValue( state.type, state.f * ( px / W * WIN ) - state.phase ) * state.a * R;
if ( px ) { ctx.lineTo( px, y ); } else { ctx.moveTo( px, y ); }
}
ctx.stroke();
if ( state.a > 0 ) {
ctx.setLineDash( [ 4, 4 ] );
ctx.strokeStyle = p.accent;
ctx.lineWidth = 1;
[ mid - state.a * R, mid + state.a * R ].forEach( function ( yy ) {
ctx.beginPath(); ctx.moveTo( 0, yy ); ctx.lineTo( W, yy ); ctx.stroke();
} );
ctx.setLineDash( [] );
ctx.fillStyle = p.accent;
ctx.textAlign = 'right';
ctx.fillText( t( 'amplitudeMark' ), W - 8, mid - state.a * R - 5 );
}
}
function frame( dt ) {
state.phase = ( state.phase + state.f * SLOW * dt ) % 1e6;
draw();
}
function update() {
if ( fSlider ) { fSlider.out.textContent = fmt( state.f, 0 ) + ' Hz'; }
if ( aSlider ) { aSlider.out.textContent = fmt( state.a * 100, 0 ) + ' %'; }
sPeriod.textContent = fmt( 1000 / state.f, 2 ) + ' ms';
var l = V_AIR / state.f;
sLambda.textContent = l >= 1 ? fmt( l, 2 ) + ' m' : fmt( l * 100, 0 ) + ' cm';
sNote.textContent = noteName( state.f );
voice.retune();
anim.redraw();
}
function resize() {
var dpr = window.devicePixelRatio || 1;
canvas.width = Math.round( canvas.clientWidth * dpr );
canvas.height = Math.round( H_PX * dpr );
ctx.setTransform( dpr, 0, 0, dpr, 0, 0 );
anim.redraw();
}
var anim = animator( canvas, frame );
new ResizeObserver( resize ).observe( canvas );
/* dark-mode live update (MediaWiki) */
var mo = new MutationObserver( function () { anim.redraw(); } );
mo.observe( document.documentElement, { attributes: true, attributeFilter: [ 'class' ] } );
mo.observe( document.body, { attributes: true, attributeFilter: [ 'class' ] } );
update();
}
/* ----------------------------------------------------------
* Initialise all matching containers on the page
* ---------------------------------------------------------- */
document.querySelectorAll( '.hiruwiki[data-module="sound-wave"]' ).forEach( buildWidget );
}() );
sxvxwi7gi3fnsjaec6w9o463btz3lt0
Template:Hiruwiki
10
189972
781456
2026-10-28T10:49:58Z
ItsNyoty
68948
Created page with "<div class="hiruwiki" data-module="{{{module|{{{1|}}}}}}"></div> <includeonly>[[Category:Wikipedia:Hiruwiki]]</includeonly>"
781456
wikitext
text/x-wiki
<div class="hiruwiki" data-module="{{{module|{{{1|}}}}}}"></div>
<includeonly>[[Category:Wikipedia:Hiruwiki]]</includeonly>
a0s2p29w0wecgcojos8846hiwh7c7op
Hiruwiki
0
189973
781457
2026-10-28T10:51:46Z
ItsNyoty
68948
Created page with "See [[mw:Hiruwiki|Hiruwiki on mediawiki]] for documentation. {{Hiruwiki|angle-guess}}{{Hiruwiki|angles}}{{Hiruwiki|circle-proof}}{{Hiruwiki|circumcentre}}{{Hiruwiki|dividend}}{{Hiruwiki|doppler}}{{Hiruwiki|echo}}{{Hiruwiki|euler-line}}{{Hiruwiki|factors}}{{Hiruwiki|fibonacci}}{{Hiruwiki|gauss-legendre}}{{Hiruwiki|harmonics}}{{Hiruwiki|hexagon-angles}}{{Hiruwiki|incentre}}{{Hiruwiki|integer-coordinates}}{{Hiruwiki|marble-jar}}{{Hiruwiki|monte-carlo}}{{Hiruwiki|nine-poin..."
781457
wikitext
text/x-wiki
See [[mw:Hiruwiki|Hiruwiki on mediawiki]] for documentation.
{{Hiruwiki|angle-guess}}{{Hiruwiki|angles}}{{Hiruwiki|circle-proof}}{{Hiruwiki|circumcentre}}{{Hiruwiki|dividend}}{{Hiruwiki|doppler}}{{Hiruwiki|echo}}{{Hiruwiki|euler-line}}{{Hiruwiki|factors}}{{Hiruwiki|fibonacci}}{{Hiruwiki|gauss-legendre}}{{Hiruwiki|harmonics}}{{Hiruwiki|hexagon-angles}}{{Hiruwiki|incentre}}{{Hiruwiki|integer-coordinates}}{{Hiruwiki|marble-jar}}{{Hiruwiki|monte-carlo}}{{Hiruwiki|nine-point-circle}}{{Hiruwiki|orthocentre}}{{Hiruwiki|pentagon-angles}}{{Hiruwiki|percent}}{{Hiruwiki|positive-coordinates}}{{Hiruwiki|pythagoras}}{{Hiruwiki|quadrangle-angles}}{{Hiruwiki|quadrangles}}{{Hiruwiki|radian}}{{Hiruwiki|slope}}{{Hiruwiki|sound-propagation}}{{Hiruwiki|sound-wave}}{{Hiruwiki|transformation}}{{Hiruwiki|triangle-angles}}{{Hiruwiki|triangle-area}}{{Hiruwiki|triangle-centroid}}{{Hiruwiki|triangles}}{{Hiruwiki|trigonometry}}
mutzhhcj4t8zslvhf8sp2kco19p4sn6