summaryrefslogtreecommitdiff
path: root/resources/src
diff options
context:
space:
mode:
Diffstat (limited to 'resources/src')
-rw-r--r--resources/src/es5-skip.js18
-rw-r--r--resources/src/jquery.json-deprecate.js8
-rw-r--r--resources/src/jquery.tipsy/images/tipsy.pngbin0 -> 133 bytes
-rw-r--r--resources/src/jquery.tipsy/jquery.tipsy.css73
-rw-r--r--resources/src/jquery.tipsy/jquery.tipsy.js259
-rw-r--r--resources/src/jquery/images/jquery.arrowSteps.divider-ltr.pngbin0 -> 126 bytes
-rw-r--r--resources/src/jquery/images/jquery.arrowSteps.divider-rtl.pngbin0 -> 127 bytes
-rw-r--r--resources/src/jquery/images/jquery.arrowSteps.head-ltr.pngbin0 -> 303 bytes
-rw-r--r--resources/src/jquery/images/jquery.arrowSteps.head-rtl.pngbin0 -> 311 bytes
-rw-r--r--resources/src/jquery/images/jquery.arrowSteps.tail-ltr.pngbin0 -> 222 bytes
-rw-r--r--resources/src/jquery/images/jquery.arrowSteps.tail-rtl.pngbin0 -> 219 bytes
-rw-r--r--resources/src/jquery/images/marker.pngbin0 -> 472 bytes
-rw-r--r--resources/src/jquery/images/mask.pngbin0 -> 1795 bytes
-rw-r--r--resources/src/jquery/images/sort_both.gifbin0 -> 1184 bytes
-rw-r--r--resources/src/jquery/images/sort_down.gifbin0 -> 1174 bytes
-rw-r--r--resources/src/jquery/images/sort_none.gifbin0 -> 462 bytes
-rw-r--r--resources/src/jquery/images/sort_up.gifbin0 -> 1174 bytes
-rw-r--r--resources/src/jquery/images/spinner-large.gifbin0 -> 1788 bytes
-rw-r--r--resources/src/jquery/images/spinner.gifbin0 -> 1819 bytes
-rw-r--r--resources/src/jquery/images/wheel.pngbin0 -> 11505 bytes
-rw-r--r--resources/src/jquery/jquery.accessKeyLabel.js200
-rw-r--r--resources/src/jquery/jquery.arrowSteps.css45
-rw-r--r--resources/src/jquery/jquery.arrowSteps.js98
-rw-r--r--resources/src/jquery/jquery.autoEllipsis.js168
-rw-r--r--resources/src/jquery/jquery.badge.css36
-rw-r--r--resources/src/jquery/jquery.badge.js88
-rw-r--r--resources/src/jquery/jquery.byteLength.js40
-rw-r--r--resources/src/jquery/jquery.byteLimit.js235
-rw-r--r--resources/src/jquery/jquery.checkboxShiftClick.js43
-rw-r--r--resources/src/jquery/jquery.client.js301
-rw-r--r--resources/src/jquery/jquery.color.js55
-rw-r--r--resources/src/jquery/jquery.colorUtil.js262
-rw-r--r--resources/src/jquery/jquery.confirmable.css28
-rw-r--r--resources/src/jquery/jquery.confirmable.js170
-rw-r--r--resources/src/jquery/jquery.confirmable.mediawiki.js14
-rw-r--r--resources/src/jquery/jquery.expandableField.js140
-rw-r--r--resources/src/jquery/jquery.farbtastic.css54
-rw-r--r--resources/src/jquery/jquery.farbtastic.js286
-rw-r--r--resources/src/jquery/jquery.footHovzer.css6
-rw-r--r--resources/src/jquery/jquery.footHovzer.js66
-rw-r--r--resources/src/jquery/jquery.getAttrs.js42
-rw-r--r--resources/src/jquery/jquery.hidpi.js129
-rw-r--r--resources/src/jquery/jquery.highlightText.js73
-rw-r--r--resources/src/jquery/jquery.localize.js170
-rw-r--r--resources/src/jquery/jquery.makeCollapsible.css27
-rw-r--r--resources/src/jquery/jquery.makeCollapsible.js404
-rw-r--r--resources/src/jquery/jquery.mw-jump.js15
-rw-r--r--resources/src/jquery/jquery.mwExtension.js122
-rw-r--r--resources/src/jquery/jquery.placeholder.js229
-rw-r--r--resources/src/jquery/jquery.qunit.completenessTest.js305
-rw-r--r--resources/src/jquery/jquery.spinner.css40
-rw-r--r--resources/src/jquery/jquery.spinner.js112
-rw-r--r--resources/src/jquery/jquery.suggestions.css76
-rw-r--r--resources/src/jquery/jquery.suggestions.js684
-rw-r--r--resources/src/jquery/jquery.tabIndex.js57
-rw-r--r--resources/src/jquery/jquery.tablesorter.css17
-rw-r--r--resources/src/jquery/jquery.tablesorter.js1161
-rw-r--r--resources/src/jquery/jquery.textSelection.js572
-rw-r--r--resources/src/json-skip.js4
-rw-r--r--resources/src/mediawiki.action/images/green-checkmark.pngbin0 -> 681 bytes
-rw-r--r--resources/src/mediawiki.action/images/nextredirect-ltr.pngbin0 -> 121 bytes
-rw-r--r--resources/src/mediawiki.action/images/nextredirect-rtl.pngbin0 -> 121 bytes
-rw-r--r--resources/src/mediawiki.action/images/redirect-ltr.pngbin0 -> 128 bytes
-rw-r--r--resources/src/mediawiki.action/images/redirect-rtl.pngbin0 -> 132 bytes
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.collapsibleFooter.css17
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.collapsibleFooter.js54
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.css18
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.editWarning.js59
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.js217
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.preview.js165
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.styles.css44
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ar/button_bold.pngbin0 -> 533 bytes
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ar/button_headline.pngbin0 -> 484 bytes
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ar/button_italic.pngbin0 -> 532 bytes
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ar/button_link.pngbin0 -> 557 bytes
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ar/button_nowiki.pngbin0 -> 874 bytes
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/be-tarask/button_bold.pngbin0 -> 550 bytes
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/be-tarask/button_italic.pngbin0 -> 539 bytes
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/be-tarask/button_link.pngbin0 -> 419 bytes
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/de/button_bold.pngbin0 -> 255 bytes
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/de/button_italic.pngbin0 -> 260 bytes
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_bold.pngbin0 -> 250 bytes
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_extlink.pngbin0 -> 435 bytes
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_headline.pngbin0 -> 440 bytes
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_hr.pngbin0 -> 200 bytes
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_image.pngbin0 -> 483 bytes
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_italic.pngbin0 -> 250 bytes
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_link.pngbin0 -> 280 bytes
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_media.pngbin0 -> 728 bytes
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_nowiki.pngbin0 -> 322 bytes
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_sig.pngbin0 -> 920 bytes
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/fa/button_bold.pngbin0 -> 459 bytes
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/fa/button_headline.pngbin0 -> 392 bytes
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/fa/button_italic.pngbin0 -> 512 bytes
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/fa/button_link.pngbin0 -> 485 bytes
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/fa/button_nowiki.pngbin0 -> 874 bytes
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ksh/LICENSE7
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ksh/button_italic.pngbin0 -> 368 bytes
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ru/LICENSE17
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ru/button_bold.pngbin0 -> 254 bytes
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ru/button_italic.pngbin0 -> 423 bytes
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ru/button_link.pngbin0 -> 278 bytes
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.edit.toolbar/mediawiki.action.edit.toolbar.less42
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.history.css4
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.history.diff.css96
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.history.js111
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.view.dblClickEdit.js12
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.view.metadata.css6
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.view.metadata.js45
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.view.postEdit.css76
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.view.postEdit.js86
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.view.redirect.js65
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.view.redirectPage.css53
-rw-r--r--resources/src/mediawiki.action/mediawiki.action.view.rightClickEdit.js26
-rw-r--r--resources/src/mediawiki.api/mediawiki.api.category.js146
-rw-r--r--resources/src/mediawiki.api/mediawiki.api.edit.js87
-rw-r--r--resources/src/mediawiki.api/mediawiki.api.js397
-rw-r--r--resources/src/mediawiki.api/mediawiki.api.login.js54
-rw-r--r--resources/src/mediawiki.api/mediawiki.api.parse.js45
-rw-r--r--resources/src/mediawiki.api/mediawiki.api.watch.js79
-rw-r--r--resources/src/mediawiki.hidpi-skip.js4
-rw-r--r--resources/src/mediawiki.language/languages/bs.js19
-rw-r--r--resources/src/mediawiki.language/languages/dsb.js19
-rw-r--r--resources/src/mediawiki.language/languages/fi.js47
-rw-r--r--resources/src/mediawiki.language/languages/ga.js38
-rw-r--r--resources/src/mediawiki.language/languages/he.js29
-rw-r--r--resources/src/mediawiki.language/languages/hsb.js19
-rw-r--r--resources/src/mediawiki.language/languages/hu.js23
-rw-r--r--resources/src/mediawiki.language/languages/hy.js29
-rw-r--r--resources/src/mediawiki.language/languages/la.js50
-rw-r--r--resources/src/mediawiki.language/languages/os.js69
-rw-r--r--resources/src/mediawiki.language/languages/ru.js57
-rw-r--r--resources/src/mediawiki.language/languages/sl.js19
-rw-r--r--resources/src/mediawiki.language/languages/uk.js37
-rw-r--r--resources/src/mediawiki.language/mediawiki.cldr.js32
-rw-r--r--resources/src/mediawiki.language/mediawiki.language.fallback.js35
-rw-r--r--resources/src/mediawiki.language/mediawiki.language.init.js81
-rw-r--r--resources/src/mediawiki.language/mediawiki.language.js171
-rw-r--r--resources/src/mediawiki.language/mediawiki.language.months.js56
-rw-r--r--resources/src/mediawiki.language/mediawiki.language.numbers.js258
-rw-r--r--resources/src/mediawiki.legacy/ajax.js194
-rw-r--r--resources/src/mediawiki.legacy/commonPrint.css435
-rw-r--r--resources/src/mediawiki.legacy/images/ajax-loader.gifbin0 -> 1788 bytes
-rw-r--r--resources/src/mediawiki.legacy/images/checker.pngbin0 -> 81 bytes
-rw-r--r--resources/src/mediawiki.legacy/images/feed-icon.pngbin0 -> 542 bytes
-rw-r--r--resources/src/mediawiki.legacy/images/feed-icon.svg1
-rw-r--r--resources/src/mediawiki.legacy/images/help-question-hover.gifbin0 -> 1246 bytes
-rw-r--r--resources/src/mediawiki.legacy/images/help-question.gifbin0 -> 126 bytes
-rw-r--r--resources/src/mediawiki.legacy/images/question.pngbin0 -> 316 bytes
-rw-r--r--resources/src/mediawiki.legacy/images/question.svg1
-rw-r--r--resources/src/mediawiki.legacy/images/spinner.gifbin0 -> 1819 bytes
-rw-r--r--resources/src/mediawiki.legacy/oldshared.css489
-rw-r--r--resources/src/mediawiki.legacy/protect.js240
-rw-r--r--resources/src/mediawiki.legacy/shared.css1167
-rw-r--r--resources/src/mediawiki.legacy/wikibits.js204
-rw-r--r--resources/src/mediawiki.less/mediawiki.mixins.animation.less12
-rw-r--r--resources/src/mediawiki.less/mediawiki.mixins.less61
-rw-r--r--resources/src/mediawiki.less/mediawiki.mixins.rotation.less33
-rw-r--r--resources/src/mediawiki.less/mediawiki.ui/mixins.less122
-rw-r--r--resources/src/mediawiki.less/mediawiki.ui/variables.less62
-rw-r--r--resources/src/mediawiki.libs/CLDRPluralRuleParser.js475
-rw-r--r--resources/src/mediawiki.libs/mediawiki.libs.jpegmeta.js737
-rw-r--r--resources/src/mediawiki.page/mediawiki.page.gallery.js212
-rw-r--r--resources/src/mediawiki.page/mediawiki.page.image.pagination.js101
-rw-r--r--resources/src/mediawiki.page/mediawiki.page.patrol.ajax.js65
-rw-r--r--resources/src/mediawiki.page/mediawiki.page.ready.js64
-rw-r--r--resources/src/mediawiki.page/mediawiki.page.startup.js33
-rw-r--r--resources/src/mediawiki.page/mediawiki.page.watch.ajax.js178
-rw-r--r--resources/src/mediawiki.skinning/content.css227
-rw-r--r--resources/src/mediawiki.skinning/content.externallinks.css102
-rw-r--r--resources/src/mediawiki.skinning/content.parsoid.less131
-rw-r--r--resources/src/mediawiki.skinning/elements.css273
-rw-r--r--resources/src/mediawiki.skinning/images/audio-ltr.pngbin0 -> 401 bytes
-rw-r--r--resources/src/mediawiki.skinning/images/audio-ltr.svg8
-rw-r--r--resources/src/mediawiki.skinning/images/audio-rtl.pngbin0 -> 417 bytes
-rw-r--r--resources/src/mediawiki.skinning/images/audio-rtl.svg8
-rw-r--r--resources/src/mediawiki.skinning/images/chat-ltr.pngbin0 -> 304 bytes
-rw-r--r--resources/src/mediawiki.skinning/images/chat-ltr.svg6
-rw-r--r--resources/src/mediawiki.skinning/images/chat-rtl.pngbin0 -> 297 bytes
-rw-r--r--resources/src/mediawiki.skinning/images/chat-rtl.svg6
-rw-r--r--resources/src/mediawiki.skinning/images/document-ltr.pngbin0 -> 275 bytes
-rw-r--r--resources/src/mediawiki.skinning/images/document-ltr.svg5
-rw-r--r--resources/src/mediawiki.skinning/images/document-rtl.pngbin0 -> 264 bytes
-rw-r--r--resources/src/mediawiki.skinning/images/document-rtl.svg5
-rw-r--r--resources/src/mediawiki.skinning/images/external link icons.svg697
-rw-r--r--resources/src/mediawiki.skinning/images/external-ltr.pngbin0 -> 403 bytes
-rw-r--r--resources/src/mediawiki.skinning/images/external-ltr.svg8
-rw-r--r--resources/src/mediawiki.skinning/images/external-rtl.pngbin0 -> 411 bytes
-rw-r--r--resources/src/mediawiki.skinning/images/external-rtl.svg8
-rw-r--r--resources/src/mediawiki.skinning/images/ftp-ltr.pngbin0 -> 356 bytes
-rw-r--r--resources/src/mediawiki.skinning/images/ftp-ltr.svg9
-rw-r--r--resources/src/mediawiki.skinning/images/ftp-rtl.pngbin0 -> 334 bytes
-rw-r--r--resources/src/mediawiki.skinning/images/ftp-rtl.svg9
-rw-r--r--resources/src/mediawiki.skinning/images/magnify-clip-ltr.pngbin0 -> 204 bytes
-rw-r--r--resources/src/mediawiki.skinning/images/magnify-clip-rtl.pngbin0 -> 149 bytes
-rw-r--r--resources/src/mediawiki.skinning/images/mail.pngbin0 -> 334 bytes
-rw-r--r--resources/src/mediawiki.skinning/images/mail.svg7
-rw-r--r--resources/src/mediawiki.skinning/images/video.pngbin0 -> 358 bytes
-rw-r--r--resources/src/mediawiki.skinning/images/video.svg14
-rw-r--r--resources/src/mediawiki.skinning/interface.css68
-rw-r--r--resources/src/mediawiki.special/images/glyph-people-large.pngbin0 -> 1663 bytes
-rw-r--r--resources/src/mediawiki.special/images/icon-contributors.pngbin0 -> 1169 bytes
-rw-r--r--resources/src/mediawiki.special/images/icon-edits.pngbin0 -> 780 bytes
-rw-r--r--resources/src/mediawiki.special/images/icon-lock.pngbin0 -> 172 bytes
-rw-r--r--resources/src/mediawiki.special/images/icon-pages.pngbin0 -> 528 bytes
-rw-r--r--resources/src/mediawiki.special/mediawiki.special.block.css11
-rw-r--r--resources/src/mediawiki.special/mediawiki.special.block.js45
-rw-r--r--resources/src/mediawiki.special/mediawiki.special.changeemail.css19
-rw-r--r--resources/src/mediawiki.special/mediawiki.special.changeemail.js52
-rw-r--r--resources/src/mediawiki.special/mediawiki.special.changeslist.css7
-rw-r--r--resources/src/mediawiki.special/mediawiki.special.changeslist.enhanced.css61
-rw-r--r--resources/src/mediawiki.special/mediawiki.special.changeslist.legend.css29
-rw-r--r--resources/src/mediawiki.special/mediawiki.special.changeslist.legend.js25
-rw-r--r--resources/src/mediawiki.special/mediawiki.special.css120
-rw-r--r--resources/src/mediawiki.special/mediawiki.special.import.js35
-rw-r--r--resources/src/mediawiki.special/mediawiki.special.javaScriptTest.js36
-rw-r--r--resources/src/mediawiki.special/mediawiki.special.js9
-rw-r--r--resources/src/mediawiki.special/mediawiki.special.movePage.js6
-rw-r--r--resources/src/mediawiki.special/mediawiki.special.pageLanguage.js9
-rw-r--r--resources/src/mediawiki.special/mediawiki.special.pagesWithProp.css4
-rw-r--r--resources/src/mediawiki.special/mediawiki.special.preferences.css21
-rw-r--r--resources/src/mediawiki.special/mediawiki.special.preferences.js305
-rw-r--r--resources/src/mediawiki.special/mediawiki.special.recentchanges.js39
-rw-r--r--resources/src/mediawiki.special/mediawiki.special.search.css173
-rw-r--r--resources/src/mediawiki.special/mediawiki.special.search.js58
-rw-r--r--resources/src/mediawiki.special/mediawiki.special.undelete.js11
-rw-r--r--resources/src/mediawiki.special/mediawiki.special.unwatchedPages.css9
-rw-r--r--resources/src/mediawiki.special/mediawiki.special.unwatchedPages.js52
-rw-r--r--resources/src/mediawiki.special/mediawiki.special.upload.js565
-rw-r--r--resources/src/mediawiki.special/mediawiki.special.userlogin.common.css66
-rw-r--r--resources/src/mediawiki.special/mediawiki.special.userlogin.common.js72
-rw-r--r--resources/src/mediawiki.special/mediawiki.special.userlogin.login.css22
-rw-r--r--resources/src/mediawiki.special/mediawiki.special.userlogin.signup.css66
-rw-r--r--resources/src/mediawiki.special/mediawiki.special.userlogin.signup.js140
-rw-r--r--resources/src/mediawiki.special/mediawiki.special.version.css14
-rw-r--r--resources/src/mediawiki.ui/components/anchors.less77
-rw-r--r--resources/src/mediawiki.ui/components/buttons.less276
-rw-r--r--resources/src/mediawiki.ui/components/checkbox.less100
-rw-r--r--resources/src/mediawiki.ui/components/forms.less166
-rw-r--r--resources/src/mediawiki.ui/components/images/checked.pngbin0 -> 327 bytes
-rw-r--r--resources/src/mediawiki.ui/components/images/checked.svg1
-rw-r--r--resources/src/mediawiki.ui/components/inputs.less126
-rw-r--r--resources/src/mediawiki.ui/components/utilities.less47
-rw-r--r--resources/src/mediawiki.ui/default.less5
-rw-r--r--resources/src/mediawiki.ui/styleguide.md11
-rw-r--r--resources/src/mediawiki/images/arrow-collapsed-ltr.pngbin0 -> 133 bytes
-rw-r--r--resources/src/mediawiki/images/arrow-collapsed-ltr.svg1
-rw-r--r--resources/src/mediawiki/images/arrow-collapsed-rtl.pngbin0 -> 136 bytes
-rw-r--r--resources/src/mediawiki/images/arrow-collapsed-rtl.svg1
-rw-r--r--resources/src/mediawiki/images/arrow-expanded.pngbin0 -> 134 bytes
-rw-r--r--resources/src/mediawiki/images/arrow-expanded.svg1
-rw-r--r--resources/src/mediawiki/images/arrow-sort-ascending.pngbin0 -> 244 bytes
-rw-r--r--resources/src/mediawiki/images/arrow-sort-ascending.svg1
-rw-r--r--resources/src/mediawiki/images/arrow-sort-descending.pngbin0 -> 245 bytes
-rw-r--r--resources/src/mediawiki/images/arrow-sort-descending.svg1
-rw-r--r--resources/src/mediawiki/images/pager-arrow-disabled-fastforward-ltr.pngbin0 -> 323 bytes
-rw-r--r--resources/src/mediawiki/images/pager-arrow-disabled-fastforward-rtl.pngbin0 -> 318 bytes
-rw-r--r--resources/src/mediawiki/images/pager-arrow-disabled-forward-ltr.pngbin0 -> 307 bytes
-rw-r--r--resources/src/mediawiki/images/pager-arrow-disabled-forward-rtl.pngbin0 -> 301 bytes
-rw-r--r--resources/src/mediawiki/images/pager-arrow-fastforward-ltr.pngbin0 -> 342 bytes
-rw-r--r--resources/src/mediawiki/images/pager-arrow-fastforward-rtl.pngbin0 -> 352 bytes
-rw-r--r--resources/src/mediawiki/images/pager-arrow-forward-ltr.pngbin0 -> 337 bytes
-rw-r--r--resources/src/mediawiki/images/pager-arrow-forward-rtl.pngbin0 -> 330 bytes
-rw-r--r--resources/src/mediawiki/mediawiki.Title.js939
-rw-r--r--resources/src/mediawiki/mediawiki.Uri.js403
-rw-r--r--resources/src/mediawiki/mediawiki.content.json.css53
-rw-r--r--resources/src/mediawiki/mediawiki.cookie.js126
-rw-r--r--resources/src/mediawiki/mediawiki.debug.init.js3
-rw-r--r--resources/src/mediawiki/mediawiki.debug.js391
-rw-r--r--resources/src/mediawiki/mediawiki.debug.less189
-rw-r--r--resources/src/mediawiki/mediawiki.debug.profile.css45
-rw-r--r--resources/src/mediawiki/mediawiki.debug.profile.js556
-rw-r--r--resources/src/mediawiki/mediawiki.feedback.css9
-rw-r--r--resources/src/mediawiki/mediawiki.feedback.js320
-rw-r--r--resources/src/mediawiki/mediawiki.feedback.spinner.gifbin0 -> 1108 bytes
-rw-r--r--resources/src/mediawiki/mediawiki.hidpi.js5
-rw-r--r--resources/src/mediawiki/mediawiki.hlist.css78
-rw-r--r--resources/src/mediawiki/mediawiki.hlist.js31
-rw-r--r--resources/src/mediawiki/mediawiki.htmlform.js408
-rw-r--r--resources/src/mediawiki/mediawiki.icon.less19
-rw-r--r--resources/src/mediawiki/mediawiki.inspect.js284
-rw-r--r--resources/src/mediawiki/mediawiki.jqueryMsg.js1251
-rw-r--r--resources/src/mediawiki/mediawiki.jqueryMsg.peg85
-rw-r--r--resources/src/mediawiki/mediawiki.js2399
-rw-r--r--resources/src/mediawiki/mediawiki.log.js84
-rw-r--r--resources/src/mediawiki/mediawiki.notification.css27
-rw-r--r--resources/src/mediawiki/mediawiki.notification.hideForPrint.css3
-rw-r--r--resources/src/mediawiki/mediawiki.notification.js523
-rw-r--r--resources/src/mediawiki/mediawiki.notify.js27
-rw-r--r--resources/src/mediawiki/mediawiki.pager.tablePager.less84
-rw-r--r--resources/src/mediawiki/mediawiki.searchSuggest.css24
-rw-r--r--resources/src/mediawiki/mediawiki.searchSuggest.js199
-rw-r--r--resources/src/mediawiki/mediawiki.toc.js60
-rw-r--r--resources/src/mediawiki/mediawiki.user.js258
-rw-r--r--resources/src/mediawiki/mediawiki.util.js531
-rw-r--r--resources/src/polyfill-object-create.js62
-rw-r--r--resources/src/startup.js62
297 files changed, 28962 insertions, 0 deletions
diff --git a/resources/src/es5-skip.js b/resources/src/es5-skip.js
new file mode 100644
index 00000000..a4039d89
--- /dev/null
+++ b/resources/src/es5-skip.js
@@ -0,0 +1,18 @@
+/*!
+ * Skip function for es5-shim module.
+ *
+ * Test for strict mode as a proxy for full ES5 function support (but not syntax)
+ * Per http://kangax.github.io/compat-table/es5/ this is a reasonable shortcut
+ * that still allows this to be as short as possible (there are no browsers we
+ * support that have strict mode, but lack other features).
+ *
+ * Do explicitly test for Function#bind because of PhantomJS (which implements
+ * strict mode, but lacks Function#bind).
+ *
+ * IE9 supports all features except strict mode, so loading es5-shim should be close to
+ * a no-op but does increase page payload).
+ */
+return ( function () {
+ 'use strict';
+ return !this && !!Function.prototype.bind;
+}() );
diff --git a/resources/src/jquery.json-deprecate.js b/resources/src/jquery.json-deprecate.js
new file mode 100644
index 00000000..f38decd9
--- /dev/null
+++ b/resources/src/jquery.json-deprecate.js
@@ -0,0 +1,8 @@
+( function ( mw, $ ) {
+ // @deprecated since 1.24. The 'jquery.json' module will be removed in MW 1.25. Use the 'json' module.
+
+ mw.log.deprecate( $, 'toJSON', $.toJSON, 'Use JSON.stringify instead (module "json" for polyfill).' );
+ mw.log.deprecate( $, 'evalJSON', $.evalJSON, 'Use JSON.parse instead (module "json" for polyfill).' );
+ mw.log.deprecate( $, 'secureEvalJSON', $.secureEvalJSON, 'Use JSON.parse instead (module "json" for polyfill).' );
+ mw.log.deprecate( $, 'quoteString', $.quoteString, 'Use JSON.stringify instead (module "json" for polyfill).' );
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/jquery.tipsy/images/tipsy.png b/resources/src/jquery.tipsy/images/tipsy.png
new file mode 100644
index 00000000..ef17cc32
--- /dev/null
+++ b/resources/src/jquery.tipsy/images/tipsy.png
Binary files differ
diff --git a/resources/src/jquery.tipsy/jquery.tipsy.css b/resources/src/jquery.tipsy/jquery.tipsy.css
new file mode 100644
index 00000000..6471516d
--- /dev/null
+++ b/resources/src/jquery.tipsy/jquery.tipsy.css
@@ -0,0 +1,73 @@
+.tipsy {
+ padding: 5px;
+ position: absolute;
+ z-index: 100000;
+ cursor: default;
+}
+.tipsy-inner {
+ padding: 5px 8px 4px 8px;
+ /*background-color: #e8f2f8;*/
+ background-color: #ffffff;
+ border: solid 1px #a7d7f9;
+ color: black;
+ max-width: 15em;
+ border-radius: 4px;
+ /*
+ -moz-box-shadow: 0px 2px 8px #cccccc;
+ -webkit-box-shadow: 0px 2px 8px #cccccc;
+ box-shadow: 0px 2px 8px #cccccc;
+ -ms-filter: "progid:DXImageTransform.Microsoft.DropShadow(OffX=0, OffY=2, Strength=6, Direction=90, Color='#cccccc')";
+ filter: progid:DXImageTransform.Microsoft.DropShadow(OffX=0, OffY=2, Strength=6, Direction=90, Color='#cccccc');
+ */
+}
+.tipsy-arrow {
+ position: absolute;
+ /* @embed */
+ background: url('images/tipsy.png') no-repeat top left;
+ width: 11px;
+ height: 6px;
+}
+/* @noflip */ .tipsy-n .tipsy-arrow {
+ top: 0px;
+ left: 50%;
+ margin-left: -5px;
+}
+/* @noflip */ .tipsy-nw .tipsy-arrow {
+ top: 1px;
+ left: 10px;
+}
+/* @noflip */ .tipsy-ne .tipsy-arrow {
+ top: 1px;
+ right: 10px;
+}
+/* @noflip */ .tipsy-s .tipsy-arrow {
+ bottom: 0px;
+ left: 50%;
+ margin-left: -5px;
+ background-position: bottom left;
+}
+/* @noflip */ .tipsy-sw .tipsy-arrow {
+ bottom: 0px;
+ left: 10px;
+ background-position: bottom left;
+}
+/* @noflip */ .tipsy-se .tipsy-arrow {
+ bottom: 0px;
+ right: 10px;
+ background-position: bottom left;
+}
+/* @noflip */ .tipsy-e .tipsy-arrow {
+ top: 50%;
+ margin-top: -5px;
+ right: 1px;
+ width: 5px;
+ height: 11px;
+ background-position: top right;
+}
+/* @noflip */ .tipsy-w .tipsy-arrow {
+ top: 50%;
+ margin-top: -5px;
+ left: 0px;
+ width: 6px;
+ height: 11px;
+}
diff --git a/resources/src/jquery.tipsy/jquery.tipsy.js b/resources/src/jquery.tipsy/jquery.tipsy.js
new file mode 100644
index 00000000..58a99a59
--- /dev/null
+++ b/resources/src/jquery.tipsy/jquery.tipsy.js
@@ -0,0 +1,259 @@
+// tipsy, facebook style tooltips for jquery
+// version 1.0.0a*
+// (c) 2008-2010 jason frame [jason@onehackoranother.com]
+// released under the MIT license
+
+// * This installation of tipsy includes several local modifications to both Javascript and CSS.
+// Please be careful when upgrading.
+
+(function($) {
+
+ function maybeCall(thing, ctx) {
+ return (typeof thing == 'function') ? (thing.call(ctx)) : thing;
+ }
+
+ function Tipsy(element, options) {
+ this.$element = $(element);
+ this.options = options;
+ this.enabled = true;
+ this.fixTitle();
+ }
+
+ Tipsy.prototype = {
+ show: function() {
+ var title = this.getTitle();
+ if (title && this.enabled) {
+ var $tip = this.tip();
+
+ $tip.find('.tipsy-inner')[this.options.html ? 'html' : 'text'](title);
+ $tip[0].className = 'tipsy'; // reset classname in case of dynamic gravity
+ if (this.options.className) {
+ $tip.addClass(maybeCall(this.options.className, this.$element[0]));
+ }
+ $tip.remove().css({top: 0, left: 0, visibility: 'hidden', display: 'block'}).appendTo(document.body);
+
+ var pos = $.extend({}, this.$element.offset(), {
+ width: this.$element[0].offsetWidth,
+ height: this.$element[0].offsetHeight
+ });
+
+ var gravity = (typeof this.options.gravity == 'function')
+ ? this.options.gravity.call(this.$element[0])
+ : this.options.gravity;
+
+ // Attach css classes before checking height/width so they
+ // can be applied.
+ $tip.addClass('tipsy-' + gravity);
+ if (this.options.className) {
+ $tip.addClass(maybeCall(this.options.className, this.$element[0]));
+ }
+
+ var actualWidth = $tip[0].offsetWidth, actualHeight = $tip[0].offsetHeight;
+ var tp;
+ switch (gravity.charAt(0)) {
+ case 'n':
+ tp = {top: pos.top + pos.height + this.options.offset, left: pos.left + pos.width / 2 - actualWidth / 2};
+ break;
+ case 's':
+ tp = {top: pos.top - actualHeight - this.options.offset, left: pos.left + pos.width / 2 - actualWidth / 2};
+ break;
+ case 'e':
+ tp = {top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left - actualWidth - this.options.offset};
+ break;
+ case 'w':
+ tp = {top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left + pos.width + this.options.offset};
+ break;
+ }
+
+ if (gravity.length == 2) {
+ if (gravity.charAt(1) == 'w') {
+ if (this.options.center) {
+ tp.left = pos.left + pos.width / 2 - 15;
+ } else {
+ tp.left = pos.left;
+ }
+ } else {
+ if (this.options.center) {
+ tp.left = pos.left + pos.width / 2 - actualWidth + 15;
+ } else {
+ tp.left = pos.left + pos.width;
+ }
+ }
+ }
+ $tip.css(tp);
+
+ if (this.options.fade) {
+ $tip.stop().css({opacity: 0, display: 'block', visibility: 'visible'}).animate({opacity: this.options.opacity}, 100);
+ } else {
+ $tip.css({visibility: 'visible', opacity: this.options.opacity});
+ }
+ }
+ },
+
+ hide: function() {
+ if (this.options.fade) {
+ this.tip().stop().fadeOut(100, function() { $(this).remove(); });
+ } else {
+ this.tip().remove();
+ }
+ },
+
+
+ fixTitle: function() {
+ var $e = this.$element;
+ if ($e.attr('title') || typeof($e.attr('original-title')) != 'string') {
+ $e.attr('original-title', $e.attr('title') || '').removeAttr('title');
+ }
+ },
+
+ getTitle: function() {
+ var title, $e = this.$element, o = this.options;
+ this.fixTitle();
+ if (typeof o.title == 'string') {
+ title = $e.attr(o.title == 'title' ? 'original-title' : o.title);
+ } else if (typeof o.title == 'function') {
+ title = o.title.call($e[0]);
+ }
+ title = ('' + title).replace(/(^\s*|\s*$)/, "");
+ return title || o.fallback;
+ },
+
+ tip: function() {
+ if (!this.$tip) {
+ this.$tip = $('<div class="tipsy"></div>').html('<div class="tipsy-arrow"></div><div class="tipsy-inner"></div>');
+ }
+ return this.$tip;
+ },
+
+ validate: function() {
+ if (!this.$element[0].parentNode) {
+ this.hide();
+ this.$element = null;
+ this.options = null;
+ }
+ },
+
+ enable: function() { this.enabled = true; },
+ disable: function() { this.enabled = false; },
+ toggleEnabled: function() { this.enabled = !this.enabled; }
+ };
+
+ $.fn.tipsy = function(options) {
+
+ if (options === true) {
+ return this.data('tipsy');
+ } else if (typeof options == 'string') {
+ var tipsy = this.data('tipsy');
+ if (tipsy) tipsy[options]();
+ return this;
+ }
+
+ options = $.extend({}, $.fn.tipsy.defaults, options);
+
+ function get(ele) {
+ var tipsy = $.data(ele, 'tipsy');
+ if (!tipsy) {
+ tipsy = new Tipsy(ele, $.fn.tipsy.elementOptions(ele, options));
+ $.data(ele, 'tipsy', tipsy);
+ }
+ return tipsy;
+ }
+
+ function enter() {
+ var tipsy = get(this);
+ tipsy.hoverState = 'in';
+ if (options.delayIn == 0) {
+ tipsy.show();
+ } else {
+ tipsy.fixTitle();
+ setTimeout(function() { if (tipsy.hoverState == 'in') tipsy.show(); }, options.delayIn);
+ }
+ };
+
+ function leave() {
+ var tipsy = get(this);
+ tipsy.hoverState = 'out';
+ if (options.delayOut == 0) {
+ tipsy.hide();
+ } else {
+ setTimeout(function() { if (tipsy.hoverState == 'out') tipsy.hide(); }, options.delayOut);
+ }
+ };
+
+ if (!options.live) this.each(function() { get(this); });
+
+ if (options.trigger != 'manual') {
+ var binder = options.live ? 'live' : 'bind',
+ eventIn = options.trigger == 'hover' ? 'mouseenter' : 'focus',
+ eventOut = options.trigger == 'hover' ? 'mouseleave' : 'blur';
+ this[binder](eventIn, enter)[binder](eventOut, leave);
+ }
+
+ return this;
+
+ };
+
+ $.fn.tipsy.defaults = {
+ className: null,
+ delayIn: 0,
+ delayOut: 0,
+ fade: true,
+ fallback: '',
+ gravity: 'n',
+ center: true,
+ html: false,
+ live: false,
+ offset: 0,
+ opacity: 1.0,
+ title: 'title',
+ trigger: 'hover'
+ };
+
+ // Overwrite this method to provide options on a per-element basis.
+ // For example, you could store the gravity in a 'tipsy-gravity' attribute:
+ // return $.extend({}, options, {gravity: $(ele).attr('tipsy-gravity') || 'n' });
+ // (remember - do not modify 'options' in place!)
+ $.fn.tipsy.elementOptions = function(ele, options) {
+ return $.metadata ? $.extend({}, options, $(ele).metadata()) : options;
+ };
+
+ $.fn.tipsy.autoNS = function() {
+ return $(this).offset().top > ($(document).scrollTop() + $(window).height() / 2) ? 's' : 'n';
+ };
+
+ $.fn.tipsy.autoWE = function() {
+ return $(this).offset().left > ($(document).scrollLeft() + $(window).width() / 2) ? 'e' : 'w';
+ };
+
+ /**
+ * yields a closure of the supplied parameters, producing a function that takes
+ * no arguments and is suitable for use as an autogravity function like so:
+ *
+ * @param margin (int) - distance from the viewable region edge that an
+ * element should be before setting its tooltip's gravity to be away
+ * from that edge.
+ * @param prefer (string, e.g. 'n', 'sw', 'w') - the direction to prefer
+ * if there are no viewable region edges effecting the tooltip's
+ * gravity. It will try to vary from this minimally, for example,
+ * if 'sw' is preferred and an element is near the right viewable
+ * region edge, but not the top edge, it will set the gravity for
+ * that element's tooltip to be 'se', preserving the southern
+ * component.
+ */
+ $.fn.tipsy.autoBounds = function(margin, prefer) {
+ return function() {
+ var dir = {ns: prefer[0], ew: (prefer.length > 1 ? prefer[1] : false)},
+ boundTop = $(document).scrollTop() + margin,
+ boundLeft = $(document).scrollLeft() + margin,
+ $this = $(this);
+
+ if ($this.offset().top < boundTop) dir.ns = 'n';
+ if ($this.offset().left < boundLeft) dir.ew = 'w';
+ if ($(window).width() + $(document).scrollLeft() - $this.offset().left < margin) dir.ew = 'e';
+ if ($(window).height() + $(document).scrollTop() - $this.offset().top < margin) dir.ns = 's';
+
+ return dir.ns + (dir.ew ? dir.ew : '');
+ }
+ };
+
+})(jQuery);
diff --git a/resources/src/jquery/images/jquery.arrowSteps.divider-ltr.png b/resources/src/jquery/images/jquery.arrowSteps.divider-ltr.png
new file mode 100644
index 00000000..84ed2a2d
--- /dev/null
+++ b/resources/src/jquery/images/jquery.arrowSteps.divider-ltr.png
Binary files differ
diff --git a/resources/src/jquery/images/jquery.arrowSteps.divider-rtl.png b/resources/src/jquery/images/jquery.arrowSteps.divider-rtl.png
new file mode 100644
index 00000000..7cfbfeba
--- /dev/null
+++ b/resources/src/jquery/images/jquery.arrowSteps.divider-rtl.png
Binary files differ
diff --git a/resources/src/jquery/images/jquery.arrowSteps.head-ltr.png b/resources/src/jquery/images/jquery.arrowSteps.head-ltr.png
new file mode 100644
index 00000000..eb070280
--- /dev/null
+++ b/resources/src/jquery/images/jquery.arrowSteps.head-ltr.png
Binary files differ
diff --git a/resources/src/jquery/images/jquery.arrowSteps.head-rtl.png b/resources/src/jquery/images/jquery.arrowSteps.head-rtl.png
new file mode 100644
index 00000000..7ea2fdb5
--- /dev/null
+++ b/resources/src/jquery/images/jquery.arrowSteps.head-rtl.png
Binary files differ
diff --git a/resources/src/jquery/images/jquery.arrowSteps.tail-ltr.png b/resources/src/jquery/images/jquery.arrowSteps.tail-ltr.png
new file mode 100644
index 00000000..3ad990b6
--- /dev/null
+++ b/resources/src/jquery/images/jquery.arrowSteps.tail-ltr.png
Binary files differ
diff --git a/resources/src/jquery/images/jquery.arrowSteps.tail-rtl.png b/resources/src/jquery/images/jquery.arrowSteps.tail-rtl.png
new file mode 100644
index 00000000..1d3048ef
--- /dev/null
+++ b/resources/src/jquery/images/jquery.arrowSteps.tail-rtl.png
Binary files differ
diff --git a/resources/src/jquery/images/marker.png b/resources/src/jquery/images/marker.png
new file mode 100644
index 00000000..19efb6ce
--- /dev/null
+++ b/resources/src/jquery/images/marker.png
Binary files differ
diff --git a/resources/src/jquery/images/mask.png b/resources/src/jquery/images/mask.png
new file mode 100644
index 00000000..fe08de0e
--- /dev/null
+++ b/resources/src/jquery/images/mask.png
Binary files differ
diff --git a/resources/src/jquery/images/sort_both.gif b/resources/src/jquery/images/sort_both.gif
new file mode 100644
index 00000000..50ad15a0
--- /dev/null
+++ b/resources/src/jquery/images/sort_both.gif
Binary files differ
diff --git a/resources/src/jquery/images/sort_down.gif b/resources/src/jquery/images/sort_down.gif
new file mode 100644
index 00000000..ec4f41b0
--- /dev/null
+++ b/resources/src/jquery/images/sort_down.gif
Binary files differ
diff --git a/resources/src/jquery/images/sort_none.gif b/resources/src/jquery/images/sort_none.gif
new file mode 100644
index 00000000..edd07e58
--- /dev/null
+++ b/resources/src/jquery/images/sort_none.gif
Binary files differ
diff --git a/resources/src/jquery/images/sort_up.gif b/resources/src/jquery/images/sort_up.gif
new file mode 100644
index 00000000..80189185
--- /dev/null
+++ b/resources/src/jquery/images/sort_up.gif
Binary files differ
diff --git a/resources/src/jquery/images/spinner-large.gif b/resources/src/jquery/images/spinner-large.gif
new file mode 100644
index 00000000..72203fdd
--- /dev/null
+++ b/resources/src/jquery/images/spinner-large.gif
Binary files differ
diff --git a/resources/src/jquery/images/spinner.gif b/resources/src/jquery/images/spinner.gif
new file mode 100644
index 00000000..6146be4e
--- /dev/null
+++ b/resources/src/jquery/images/spinner.gif
Binary files differ
diff --git a/resources/src/jquery/images/wheel.png b/resources/src/jquery/images/wheel.png
new file mode 100644
index 00000000..7e53103e
--- /dev/null
+++ b/resources/src/jquery/images/wheel.png
Binary files differ
diff --git a/resources/src/jquery/jquery.accessKeyLabel.js b/resources/src/jquery/jquery.accessKeyLabel.js
new file mode 100644
index 00000000..7b49cb2d
--- /dev/null
+++ b/resources/src/jquery/jquery.accessKeyLabel.js
@@ -0,0 +1,200 @@
+/**
+ * jQuery plugin to update the tooltip to show the correct access key
+ *
+ * @class jQuery.plugin.accessKeyLabel
+ */
+( function ( $, mw ) {
+
+// Cached access key prefix for used browser
+var cachedAccessKeyPrefix,
+
+ // Wether to use 'test-' instead of correct prefix (used for testing)
+ useTestPrefix = false,
+
+ // tag names which can have a label tag
+ // https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Content_categories#Form-associated_content
+ labelable = 'button, input, textarea, keygen, meter, output, progress, select';
+
+/**
+ * Get the prefix for the access key for browsers that don't support accessKeyLabel.
+ *
+ * For browsers that support accessKeyLabel, #getAccessKeyLabel never calls here.
+ *
+ * @private
+ * @param {Object} [ua] An object with a 'userAgent' and 'platform' property.
+ * @return {string} Access key prefix
+ */
+function getAccessKeyPrefix( ua ) {
+ // use cached prefix if possible
+ if ( !ua && cachedAccessKeyPrefix ) {
+ return cachedAccessKeyPrefix;
+ }
+
+ var profile = $.client.profile( ua ),
+ accessKeyPrefix = 'alt-';
+
+ // Opera on any platform
+ if ( profile.name === 'opera' ) {
+ accessKeyPrefix = 'shift-esc-';
+
+ // Chrome on any platform
+ } else if ( profile.name === 'chrome' ) {
+ accessKeyPrefix = (
+ profile.platform === 'mac'
+ // Chrome on Mac
+ ? 'ctrl-option-'
+ // Chrome on Windows or Linux
+ // (both alt- and alt-shift work, but alt with E, D, F etc does not
+ // work since they are browser shortcuts)
+ : 'alt-shift-'
+ );
+
+ // Non-Windows Safari with webkit_version > 526
+ } else if ( profile.platform !== 'win'
+ && profile.name === 'safari'
+ && profile.layoutVersion > 526
+ ) {
+ accessKeyPrefix = 'ctrl-alt-';
+
+ // Safari/Konqueror on any platform, or any browser on Mac
+ // (but not Safari on Windows)
+ } else if ( !( profile.platform === 'win' && profile.name === 'safari' )
+ && ( profile.name === 'safari'
+ || profile.platform === 'mac'
+ || profile.name === 'konqueror' )
+ ) {
+ accessKeyPrefix = 'ctrl-';
+
+ // Firefox/Iceweasel 2.x and later
+ } else if ( ( profile.name === 'firefox' || profile.name === 'iceweasel' )
+ && profile.versionBase > '1'
+ ) {
+ accessKeyPrefix = 'alt-shift-';
+ }
+
+ // cache prefix
+ if ( !ua ) {
+ cachedAccessKeyPrefix = accessKeyPrefix;
+ }
+ return accessKeyPrefix;
+}
+
+/**
+ * Get the access key label for an element.
+ *
+ * Will use native accessKeyLabel if available (currently only in Firefox 8+),
+ * falls back to #getAccessKeyPrefix.
+ *
+ * @private
+ * @param {HTMLElement} element Element to get the label for
+ * @return {string} Access key label
+ */
+function getAccessKeyLabel( element ) {
+ // abort early if no access key
+ if ( !element.accessKey ) {
+ return '';
+ }
+ // use accessKeyLabel if possible
+ // http://www.whatwg.org/specs/web-apps/current-work/multipage/editing.html#dom-accesskeylabel
+ if ( !useTestPrefix && element.accessKeyLabel ) {
+ return element.accessKeyLabel;
+ }
+ return ( useTestPrefix ? 'test-' : getAccessKeyPrefix() ) + element.accessKey;
+}
+
+/**
+ * Update the title for an element (on the element with the access key or it's label) to show
+ * the correct access key label.
+ *
+ * @private
+ * @param {HTMLElement} element Element with the accesskey
+ * @param {HTMLElement} titleElement Element with the title to update (may be the same as `element`)
+ */
+function updateTooltipOnElement( element, titleElement ) {
+ var array = ( mw.msg( 'word-separator' ) + mw.msg( 'brackets' ) ).split( '$1' ),
+ regexp = new RegExp( $.map( array, $.escapeRE ).join( '.*?' ) + '$' ),
+ oldTitle = titleElement.title,
+ rawTitle = oldTitle.replace( regexp, '' ),
+ newTitle = rawTitle,
+ accessKeyLabel = getAccessKeyLabel( element );
+
+ // don't add a title if the element didn't have one before
+ if ( !oldTitle ) {
+ return;
+ }
+
+ if ( accessKeyLabel ) {
+ // Should be build the same as in Linker::titleAttrib
+ newTitle += mw.msg( 'word-separator' ) + mw.msg( 'brackets', accessKeyLabel );
+ }
+ if ( oldTitle !== newTitle ) {
+ titleElement.title = newTitle;
+ }
+}
+
+/**
+ * Update the title for an element to show the correct access key label.
+ *
+ * @private
+ * @param {HTMLElement} element Element with the accesskey
+ */
+function updateTooltip( element ) {
+ var id, $element, $label, $labelParent;
+ updateTooltipOnElement( element, element );
+
+ // update associated label if there is one
+ $element = $( element );
+ if ( $element.is( labelable ) ) {
+ // Search it using 'for' attribute
+ id = element.id.replace( /"/g, '\\"' );
+ if ( id ) {
+ $label = $( 'label[for="' + id + '"]' );
+ if ( $label.length === 1 ) {
+ updateTooltipOnElement( element, $label[0] );
+ }
+ }
+
+ // Search it as parent, because the form control can also be inside the label element itself
+ $labelParent = $element.parents( 'label' );
+ if ( $labelParent.length === 1 ) {
+ updateTooltipOnElement( element, $labelParent[0] );
+ }
+ }
+}
+
+/**
+ * Update the titles for all elements in a jQuery selection.
+ *
+ * @return {jQuery}
+ * @chainable
+ */
+$.fn.updateTooltipAccessKeys = function () {
+ return this.each( function () {
+ updateTooltip( this );
+ } );
+};
+
+/**
+ * Exposed for testing.
+ *
+ * @method updateTooltipAccessKeys_getAccessKeyPrefix
+ * @inheritdoc #getAccessKeyPrefix
+ */
+$.fn.updateTooltipAccessKeys.getAccessKeyPrefix = getAccessKeyPrefix;
+
+/**
+ * Switch test mode on and off.
+ *
+ * @method updateTooltipAccessKeys_setTestMode
+ * @param {boolean} mode New mode
+ */
+$.fn.updateTooltipAccessKeys.setTestMode = function ( mode ) {
+ useTestPrefix = mode;
+};
+
+/**
+ * @class jQuery
+ * @mixins jQuery.plugin.accessKeyLabel
+ */
+
+}( jQuery, mediaWiki ) );
diff --git a/resources/src/jquery/jquery.arrowSteps.css b/resources/src/jquery/jquery.arrowSteps.css
new file mode 100644
index 00000000..f8f6e951
--- /dev/null
+++ b/resources/src/jquery/jquery.arrowSteps.css
@@ -0,0 +1,45 @@
+.arrowSteps {
+ list-style-type: none;
+ list-style-image: none;
+ border: 1px solid #666666;
+ position: relative;
+}
+
+.arrowSteps li {
+ float: left;
+ padding: 0px;
+ margin: 0px;
+ border: 0 none;
+}
+
+.arrowSteps li div {
+ padding: 0.5em;
+ text-align: center;
+ white-space: nowrap;
+ overflow: hidden;
+}
+
+.arrowSteps li.arrow div {
+ /* @embed */
+ background: url(images/jquery.arrowSteps.divider-ltr.png) no-repeat right center;
+}
+
+/* applied to the element preceding the highlighted step */
+.arrowSteps li.arrow.tail div {
+ /* @embed */
+ background: url(images/jquery.arrowSteps.tail-ltr.png) no-repeat right center;
+}
+
+/* this applies to all highlighted, including the last */
+.arrowSteps li.head div {
+ /* @embed */
+ background: url(images/jquery.arrowSteps.head-ltr.png) no-repeat left center;
+ font-weight: bold;
+}
+
+/* this applies to all highlighted arrows except the last */
+.arrowSteps li.arrow.head div {
+ /* TODO: eliminate duplication of jquery.arrowSteps.head.png embedding */
+ /* @embed */
+ background: url(images/jquery.arrowSteps.head-ltr.png) no-repeat right center;
+}
diff --git a/resources/src/jquery/jquery.arrowSteps.js b/resources/src/jquery/jquery.arrowSteps.js
new file mode 100644
index 00000000..f8641e10
--- /dev/null
+++ b/resources/src/jquery/jquery.arrowSteps.js
@@ -0,0 +1,98 @@
+/*!
+ * jQuery arrowSteps plugin
+ * Copyright Neil Kandalgaonkar, 2010
+ *
+ * This work is licensed under the terms of the GNU General Public License,
+ * version 2 or later.
+ * (see http://www.fsf.org/licensing/licenses/gpl.html).
+ * Derivative works and later versions of the code must be free software
+ * licensed under the same or a compatible license.
+ */
+
+/**
+ * @class jQuery.plugin.arrowSteps
+ */
+( function ( $ ) {
+ /**
+ * Show users their progress through a series of steps, via a row of items that fit
+ * together like arrows. One item can be highlighted at a time.
+ *
+ * <ul id="robin-hood-daffy">
+ * <li id="guard"><div>Guard!</div></li>
+ * <li id="turn"><div>Turn!</div></li>
+ * <li id="parry"><div>Parry!</div></li>
+ * <li id="dodge"><div>Dodge!</div></li>
+ * <li id="spin"><div>Spin!</div></li>
+ * <li id="ha"><div>Ha!</div></li>
+ * <li id="thrust"><div>Thrust!</div></li>
+ * </ul>
+ *
+ * <script>
+ * $( '#robin-hood-daffy' ).arrowSteps();
+ * </script>
+ *
+ * @return {jQuery}
+ * @chainable
+ */
+ $.fn.arrowSteps = function () {
+ var $steps, width, arrowWidth, $stepDiv,
+ $el = this,
+ paddingSide = $( 'body' ).hasClass( 'rtl' ) ? 'padding-left' : 'padding-right';
+
+ $el.addClass( 'arrowSteps' );
+ $steps = $el.find( 'li' );
+
+ width = parseInt( 100 / $steps.length, 10 );
+ $steps.css( 'width', width + '%' );
+
+ // Every step except the last one has an arrow pointing forward:
+ // at the right hand side in LTR languages, and at the left hand side in RTL.
+ // Also add in the padding for the calculated arrow width.
+ $stepDiv = $steps.filter( ':not(:last-child)' ).addClass( 'arrow' ).find( 'div' );
+
+ // Execute when complete page is fully loaded, including all frames, objects and images
+ $( window ).load( function () {
+ arrowWidth = parseInt( $el.outerHeight(), 10 );
+ $stepDiv.css( paddingSide, arrowWidth.toString() + 'px' );
+ } );
+
+ $el.data( 'arrowSteps', $steps );
+
+ return this;
+ };
+
+ /**
+ * Highlights the element selected by the selector.
+ *
+ * $( '#robin-hood-daffy' ).arrowStepsHighlight( '#guard' );
+ * // 'Guard!' is highlighted.
+ *
+ * // ... user completes the 'guard' step ...
+ *
+ * $( '#robin-hood-daffy' ).arrowStepsHighlight( '#turn' );
+ * // 'Turn!' is highlighted.
+ *
+ * @param {string} selector
+ */
+ $.fn.arrowStepsHighlight = function ( selector ) {
+ var $previous,
+ $steps = this.data( 'arrowSteps' );
+ $.each( $steps, function ( i, step ) {
+ var $step = $( step );
+ if ( $step.is( selector ) ) {
+ if ($previous) {
+ $previous.addClass( 'tail' );
+ }
+ $step.addClass( 'head' );
+ } else {
+ $step.removeClass( 'head tail lasthead' );
+ }
+ $previous = $step;
+ } );
+ };
+
+ /**
+ * @class jQuery
+ * @mixins jQuery.plugin.arrowSteps
+ */
+}( jQuery ) );
diff --git a/resources/src/jquery/jquery.autoEllipsis.js b/resources/src/jquery/jquery.autoEllipsis.js
new file mode 100644
index 00000000..9a196b5d
--- /dev/null
+++ b/resources/src/jquery/jquery.autoEllipsis.js
@@ -0,0 +1,168 @@
+/**
+ * @class jQuery.plugin.autoEllipsis
+ */
+( function ( $ ) {
+
+var
+ // Cache ellipsed substrings for every string-width-position combination
+ cache = {},
+
+ // Use a separate cache when match highlighting is enabled
+ matchTextCache = {};
+
+/**
+ * Automatically truncate the plain text contents of an element and add an ellipsis
+ *
+ * @param {Object} options
+ * @param {'center'|'left'|'right'} [options.position='center'] Where to remove text.
+ * @param {boolean} [options.tooltip=false] Whether to show a tooltip with the remainder
+ * of the text.
+ * @param {boolean} [options.restoreText=false] Whether to save the text for restoring
+ * later.
+ * @param {boolean} [options.hasSpan=false] Whether the element is already a container,
+ * or if the library should create a new container for it.
+ * @param {string|null} [options.matchText=null] Text to highlight, e.g. search terms.
+ * @return {jQuery}
+ * @chainable
+ */
+$.fn.autoEllipsis = function ( options ) {
+ options = $.extend( {
+ position: 'center',
+ tooltip: false,
+ restoreText: false,
+ hasSpan: false,
+ matchText: null
+ }, options );
+
+ return this.each( function () {
+ var $trimmableText,
+ text, trimmableText, w, pw,
+ l, r, i, side, m,
+ // container element - used for measuring against
+ $container = $( this );
+
+ if ( options.restoreText ) {
+ if ( !$container.data( 'autoEllipsis.originalText' ) ) {
+ $container.data( 'autoEllipsis.originalText', $container.text() );
+ } else {
+ $container.text( $container.data( 'autoEllipsis.originalText' ) );
+ }
+ }
+
+ // trimmable text element - only the text within this element will be trimmed
+ if ( options.hasSpan ) {
+ $trimmableText = $container.children( options.selector );
+ } else {
+ $trimmableText = $( '<span>' )
+ .css( 'whiteSpace', 'nowrap' )
+ .text( $container.text() );
+ $container
+ .empty()
+ .append( $trimmableText );
+ }
+
+ text = $container.text();
+ trimmableText = $trimmableText.text();
+ w = $container.width();
+ pw = 0;
+
+ // Try cache
+ if ( options.matchText ) {
+ if ( !( text in matchTextCache ) ) {
+ matchTextCache[text] = {};
+ }
+ if ( !( options.matchText in matchTextCache[text] ) ) {
+ matchTextCache[text][options.matchText] = {};
+ }
+ if ( !( w in matchTextCache[text][options.matchText] ) ) {
+ matchTextCache[text][options.matchText][w] = {};
+ }
+ if ( options.position in matchTextCache[text][options.matchText][w] ) {
+ $container.html( matchTextCache[text][options.matchText][w][options.position] );
+ if ( options.tooltip ) {
+ $container.attr( 'title', text );
+ }
+ return;
+ }
+ } else {
+ if ( !( text in cache ) ) {
+ cache[text] = {};
+ }
+ if ( !( w in cache[text] ) ) {
+ cache[text][w] = {};
+ }
+ if ( options.position in cache[text][w] ) {
+ $container.html( cache[text][w][options.position] );
+ if ( options.tooltip ) {
+ $container.attr( 'title', text );
+ }
+ return;
+ }
+ }
+
+ if ( $trimmableText.width() + pw > w ) {
+ switch ( options.position ) {
+ case 'right':
+ // Use binary search-like technique for efficiency
+ l = 0;
+ r = trimmableText.length;
+ do {
+ m = Math.ceil( ( l + r ) / 2 );
+ $trimmableText.text( trimmableText.slice( 0, m ) + '...' );
+ if ( $trimmableText.width() + pw > w ) {
+ // Text is too long
+ r = m - 1;
+ } else {
+ l = m;
+ }
+ } while ( l < r );
+ $trimmableText.text( trimmableText.slice( 0, l ) + '...' );
+ break;
+ case 'center':
+ // TODO: Use binary search like for 'right'
+ i = [Math.round( trimmableText.length / 2 ), Math.round( trimmableText.length / 2 )];
+ // Begin with making the end shorter
+ side = 1;
+ while ( $trimmableText.outerWidth() + pw > w && i[0] > 0 ) {
+ $trimmableText.text( trimmableText.slice( 0, i[0] ) + '...' + trimmableText.slice( i[1] ) );
+ // Alternate between trimming the end and begining
+ if ( side === 0 ) {
+ // Make the begining shorter
+ i[0]--;
+ side = 1;
+ } else {
+ // Make the end shorter
+ i[1]++;
+ side = 0;
+ }
+ }
+ break;
+ case 'left':
+ // TODO: Use binary search like for 'right'
+ r = 0;
+ while ( $trimmableText.outerWidth() + pw > w && r < trimmableText.length ) {
+ $trimmableText.text( '...' + trimmableText.slice( r ) );
+ r++;
+ }
+ break;
+ }
+ }
+ if ( options.tooltip ) {
+ $container.attr( 'title', text );
+ }
+ if ( options.matchText ) {
+ $container.highlightText( options.matchText );
+ matchTextCache[text][options.matchText][w][options.position] = $container.html();
+ } else {
+ cache[text][w][options.position] = $container.html();
+ }
+
+ } );
+};
+
+/**
+ * @class jQuery
+ * @mixins jQuery.plugin.autoEllipsis
+ */
+
+}( jQuery ) );
diff --git a/resources/src/jquery/jquery.badge.css b/resources/src/jquery/jquery.badge.css
new file mode 100644
index 00000000..fa7ea702
--- /dev/null
+++ b/resources/src/jquery/jquery.badge.css
@@ -0,0 +1,36 @@
+.mw-badge {
+ min-width: 7px;
+ border-radius: 2px;
+ padding: 1px 4px;
+ text-align: center;
+ font-size: 12px;
+ line-height: 12px;
+ background-color: #d2d2d2;
+ cursor: pointer;
+}
+
+.mw-badge-content {
+ font-weight: bold;
+ color: white;
+ vertical-align: baseline;
+ text-shadow: 0 1px rgba(0, 0, 0, 0.4);
+}
+
+.mw-badge-inline {
+ margin-left: 3px;
+ display: inline-block;
+ /* Hack for IE6 and IE7 (bug 47926) */
+ zoom: 1;
+ *display: inline;
+
+}
+.mw-badge-overlay {
+ position: absolute;
+ bottom: -1px;
+ right: -3px;
+ z-index: 50;
+}
+
+.mw-badge-important {
+ background-color: #cc0000;
+}
diff --git a/resources/src/jquery/jquery.badge.js b/resources/src/jquery/jquery.badge.js
new file mode 100644
index 00000000..023b6e28
--- /dev/null
+++ b/resources/src/jquery/jquery.badge.js
@@ -0,0 +1,88 @@
+/*!
+ * jQuery Badge plugin
+ *
+ * @license MIT
+ *
+ * @author Ryan Kaldari <rkaldari@wikimedia.org>, 2012
+ * @author Andrew Garrett <agarrett@wikimedia.org>, 2012
+ * @author Marius Hoch <hoo@online.de>, 2012
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * This program is distributed WITHOUT ANY WARRANTY.
+ */
+
+/**
+ * @class jQuery.plugin.badge
+ */
+( function ( $, mw ) {
+ /**
+ * Put a badge on an item on the page. The badge container will be appended to the selected element(s).
+ *
+ * $element.badge( text );
+ * $element.badge( 5 );
+ * $element.badge( '100+' );
+ * $element.badge( text, inline );
+ * $element.badge( 'New', true );
+ *
+ * @param {number|string} text The value to display in the badge. If the value is falsey (0,
+ * null, false, '', etc.), any existing badge will be removed.
+ * @param {boolean} [inline=true] True if the badge should be displayed inline, false
+ * if the badge should overlay the parent element.
+ * @param {boolean} [displayZero=false] True if the number zero should be displayed,
+ * false if the number zero should result in the badge being hidden
+ * @return {jQuery}
+ * @chainable
+ */
+ $.fn.badge = function ( text, inline, displayZero ) {
+ var $badge = this.find( '.mw-badge' ),
+ badgeStyleClass = 'mw-badge-' + ( inline ? 'inline' : 'overlay' ),
+ isImportant = true, displayBadge = true;
+
+ // If we're displaying zero, ensure style to be non-important
+ if ( mw.language.convertNumber( text, true ) === 0 ) {
+ isImportant = false;
+ if ( !displayZero ) {
+ displayBadge = false;
+ }
+ // If text is falsey (besides 0), hide the badge
+ } else if ( !text ) {
+ displayBadge = false;
+ }
+
+ if ( displayBadge ) {
+ // If a badge already exists, reuse it
+ if ( $badge.length ) {
+ $badge
+ .toggleClass( 'mw-badge-important', isImportant )
+ .find( '.mw-badge-content' )
+ .text( text );
+ } else {
+ // Otherwise, create a new badge with the specified text and style
+ $badge = $( '<div class="mw-badge"></div>' )
+ .addClass( badgeStyleClass )
+ .toggleClass( 'mw-badge-important', isImportant )
+ .append(
+ $( '<span class="mw-badge-content"></span>' ).text( text )
+ )
+ .appendTo( this );
+ }
+ } else {
+ $badge.remove();
+ }
+ return this;
+ };
+
+ /**
+ * @class jQuery
+ * @mixins jQuery.plugin.badge
+ */
+}( jQuery, mediaWiki ) );
diff --git a/resources/src/jquery/jquery.byteLength.js b/resources/src/jquery/jquery.byteLength.js
new file mode 100644
index 00000000..7fe25ee3
--- /dev/null
+++ b/resources/src/jquery/jquery.byteLength.js
@@ -0,0 +1,40 @@
+/**
+ * @class jQuery.plugin.byteLength
+ * @author Jan Paul Posma, 2011
+ * @author Timo Tijhof, 2012
+ * @author David Chan, 2013
+ */
+
+/**
+ * Calculate the byte length of a string (accounting for UTF-8).
+ *
+ * @static
+ * @inheritable
+ * @param {string} str
+ * @return {string}
+ */
+jQuery.byteLength = function ( str ) {
+ // This basically figures out how many bytes a UTF-16 string (which is what js sees)
+ // will take in UTF-8 by replacing a 2 byte character with 2 *'s, etc, and counting that.
+ // Note, surrogate (\uD800-\uDFFF) characters are counted as 2 bytes, since there's two of them
+ // and the actual character takes 4 bytes in UTF-8 (2*2=4). Might not work perfectly in
+ // edge cases such as illegal sequences, but that should never happen.
+
+ // https://en.wikipedia.org/wiki/UTF-8#Description
+ // The mapping from UTF-16 code units to UTF-8 bytes is as follows:
+ // > Range 0000-007F: codepoints that become 1 byte of UTF-8
+ // > Range 0080-07FF: codepoints that become 2 bytes of UTF-8
+ // > Range 0800-D7FF: codepoints that become 3 bytes of UTF-8
+ // > Range D800-DFFF: Surrogates (each pair becomes 4 bytes of UTF-8)
+ // > Range E000-FFFF: codepoints that become 3 bytes of UTF-8 (continued)
+
+ return str
+ .replace( /[\u0080-\u07FF\uD800-\uDFFF]/g, '**' )
+ .replace( /[\u0800-\uD7FF\uE000-\uFFFF]/g, '***' )
+ .length;
+};
+
+/**
+ * @class jQuery
+ * @mixins jQuery.plugin.byteLength
+ */
diff --git a/resources/src/jquery/jquery.byteLimit.js b/resources/src/jquery/jquery.byteLimit.js
new file mode 100644
index 00000000..5551232a
--- /dev/null
+++ b/resources/src/jquery/jquery.byteLimit.js
@@ -0,0 +1,235 @@
+/**
+ * @class jQuery.plugin.byteLimit
+ */
+( function ( $ ) {
+
+ /**
+ * Utility function to trim down a string, based on byteLimit
+ * and given a safe start position. It supports insertion anywhere
+ * in the string, so "foo" to "fobaro" if limit is 4 will result in
+ * "fobo", not "foba". Basically emulating the native maxlength by
+ * reconstructing where the insertion occurred.
+ *
+ * @private
+ * @param {string} safeVal Known value that was previously returned by this
+ * function, if none, pass empty string.
+ * @param {string} newVal New value that may have to be trimmed down.
+ * @param {number} byteLimit Number of bytes the value may be in size.
+ * @param {Function} [fn] See jQuery.byteLimit.
+ * @return {Object}
+ * @return {string} return.newVal
+ * @return {boolean} return.trimmed
+ */
+ function trimValForByteLength( safeVal, newVal, byteLimit, fn ) {
+ var startMatches, endMatches, matchesLen, inpParts,
+ oldVal = safeVal;
+
+ // Run the hook if one was provided, but only on the length
+ // assessment. The value itself is not to be affected by the hook.
+ if ( $.byteLength( fn ? fn( newVal ) : newVal ) <= byteLimit ) {
+ // Limit was not reached, just remember the new value
+ // and let the user continue.
+ return {
+ newVal: newVal,
+ trimmed: false
+ };
+ }
+
+ // Current input is longer than the active limit.
+ // Figure out what was added and limit the addition.
+ startMatches = 0;
+ endMatches = 0;
+
+ // It is important that we keep the search within the range of
+ // the shortest string's length.
+ // Imagine a user adds text that matches the end of the old value
+ // (e.g. "foo" -> "foofoo"). startMatches would be 3, but without
+ // limiting both searches to the shortest length, endMatches would
+ // also be 3.
+ matchesLen = Math.min( newVal.length, oldVal.length );
+
+ // Count same characters from the left, first.
+ // (if "foo" -> "foofoo", assume addition was at the end).
+ while (
+ startMatches < matchesLen &&
+ oldVal.charAt( startMatches ) === newVal.charAt( startMatches )
+ ) {
+ startMatches += 1;
+ }
+
+ while (
+ endMatches < ( matchesLen - startMatches ) &&
+ oldVal.charAt( oldVal.length - 1 - endMatches ) === newVal.charAt( newVal.length - 1 - endMatches )
+ ) {
+ endMatches += 1;
+ }
+
+ inpParts = [
+ // Same start
+ newVal.slice( 0, startMatches ),
+ // Inserted content
+ newVal.slice( startMatches, newVal.length - endMatches ),
+ // Same end
+ newVal.slice( newVal.length - endMatches )
+ ];
+
+ // Chop off characters from the end of the "inserted content" string
+ // until the limit is statisfied.
+ if ( fn ) {
+ // stop, when there is nothing to slice - bug 41450
+ while ( $.byteLength( fn( inpParts.join( '' ) ) ) > byteLimit && inpParts[1].length > 0 ) {
+ inpParts[1] = inpParts[1].slice( 0, -1 );
+ }
+ } else {
+ while ( $.byteLength( inpParts.join( '' ) ) > byteLimit ) {
+ inpParts[1] = inpParts[1].slice( 0, -1 );
+ }
+ }
+
+ newVal = inpParts.join( '' );
+
+ return {
+ newVal: newVal,
+ trimmed: true
+ };
+ }
+
+ var eventKeys = [
+ 'keyup.byteLimit',
+ 'keydown.byteLimit',
+ 'change.byteLimit',
+ 'mouseup.byteLimit',
+ 'cut.byteLimit',
+ 'paste.byteLimit',
+ 'focus.byteLimit',
+ 'blur.byteLimit'
+ ].join( ' ' );
+
+ /**
+ * Enforces a byte limit on an input field, so that UTF-8 entries are counted as well,
+ * when, for example, a database field has a byte limit rather than a character limit.
+ * Plugin rationale: Browser has native maxlength for number of characters, this plugin
+ * exists to limit number of bytes instead.
+ *
+ * Can be called with a custom limit (to use that limit instead of the maxlength attribute
+ * value), a filter function (in case the limit should apply to something other than the
+ * exact input value), or both. Order of parameters is important!
+ *
+ * @param {number} [limit] Limit to enforce, fallsback to maxLength-attribute,
+ * called with fetched value as argument.
+ * @param {Function} [fn] Function to call on the string before assessing the length.
+ * @return {jQuery}
+ * @chainable
+ */
+ $.fn.byteLimit = function ( limit, fn ) {
+ // If the first argument is the function,
+ // set fn to the first argument's value and ignore the second argument.
+ if ( $.isFunction( limit ) ) {
+ fn = limit;
+ limit = undefined;
+ // Either way, verify it is a function so we don't have to call
+ // isFunction again after this.
+ } else if ( !fn || !$.isFunction( fn ) ) {
+ fn = undefined;
+ }
+
+ // The following is specific to each element in the collection.
+ return this.each( function ( i, el ) {
+ var $el, elLimit, prevSafeVal;
+
+ $el = $( el );
+
+ // If no limit was passed to byteLimit(), use the maxlength value.
+ // Can't re-use 'limit' variable because it's in the higher scope
+ // that would affect the next each() iteration as well.
+ // Note that we use attribute to read the value instead of property,
+ // because in Chrome the maxLength property by default returns the
+ // highest supported value (no indication that it is being enforced
+ // by choice). We don't want to bind all of this for some ridiculously
+ // high default number, unless it was explicitly set in the HTML.
+ // Also cast to a (primitive) number (most commonly because the maxlength
+ // attribute contains a string, but theoretically the limit parameter
+ // could be something else as well).
+ elLimit = Number( limit === undefined ? $el.attr( 'maxlength' ) : limit );
+
+ // If there is no (valid) limit passed or found in the property,
+ // skip this. The < 0 check is required for Firefox, which returns
+ // -1 (instead of undefined) for maxLength if it is not set.
+ if ( !elLimit || elLimit < 0 ) {
+ return;
+ }
+
+ if ( fn ) {
+ // Save function for reference
+ $el.data( 'byteLimit.callback', fn );
+ }
+
+ // Remove old event handlers (if there are any)
+ $el.off( '.byteLimit' );
+
+ if ( fn ) {
+ // Disable the native maxLength (if there is any), because it interferes
+ // with the (differently calculated) byte limit.
+ // Aside from being differently calculated (average chars with byteLimit
+ // is lower), we also support a callback which can make it to allow longer
+ // values (e.g. count "Foo" from "User:Foo").
+ // maxLength is a strange property. Removing or setting the property to
+ // undefined directly doesn't work. Instead, it can only be unset internally
+ // by the browser when removing the associated attribute (Firefox/Chrome).
+ // http://code.google.com/p/chromium/issues/detail?id=136004
+ $el.removeAttr( 'maxlength' );
+
+ } else {
+ // If we don't have a callback the bytelimit can only be lower than the charlimit
+ // (that is, there are no characters less than 1 byte in size). So lets (re-)enforce
+ // the native limit for efficiency when possible (it will make the while-loop below
+ // faster by there being less left to interate over).
+ $el.attr( 'maxlength', elLimit );
+ }
+
+ // Safe base value, used to determine the path between the previous state
+ // and the state that triggered the event handler below - and enforce the
+ // limit approppiately (e.g. don't chop from the end if text was inserted
+ // at the beginning of the string).
+ prevSafeVal = '';
+
+ // We need to listen to after the change has already happened because we've
+ // learned that trying to guess the new value and canceling the event
+ // accordingly doesn't work because the new value is not always as simple as:
+ // oldValue + String.fromCharCode( e.which ); because of cut, paste, select-drag
+ // replacements, and custom input methods and what not.
+ // Even though we only trim input after it was changed (never prevent it), we do
+ // listen on events that input text, because there are cases where the text has
+ // changed while text is being entered and keyup/change will not be fired yet
+ // (such as holding down a single key, fires keydown, and after each keydown,
+ // we can trim the previous one).
+ // See http://www.w3.org/TR/DOM-Level-3-Events/#events-keyboard-event-order for
+ // the order and characteristics of the key events.
+ $el.on( eventKeys, function () {
+ var res = trimValForByteLength(
+ prevSafeVal,
+ this.value,
+ elLimit,
+ fn
+ );
+
+ // Only set value property if it was trimmed, because whenever the
+ // value property is set, the browser needs to re-initiate the text context,
+ // which moves the cursor at the end the input, moving it away from wherever it was.
+ // This is a side-effect of limiting after the fact.
+ if ( res.trimmed === true ) {
+ this.value = res.newVal;
+ }
+ // Always adjust prevSafeVal to reflect the input value. Not doing this could cause
+ // trimValForByteLength to compare the new value to an empty string instead of the
+ // old value, resulting in trimming always from the end (bug 40850).
+ prevSafeVal = res.newVal;
+ } );
+ } );
+ };
+
+ /**
+ * @class jQuery
+ * @mixins jQuery.plugin.byteLimit
+ */
+}( jQuery ) );
diff --git a/resources/src/jquery/jquery.checkboxShiftClick.js b/resources/src/jquery/jquery.checkboxShiftClick.js
new file mode 100644
index 00000000..d99e9f0a
--- /dev/null
+++ b/resources/src/jquery/jquery.checkboxShiftClick.js
@@ -0,0 +1,43 @@
+/**
+ * @class jQuery.plugin.checkboxShiftClick
+ */
+( function ( $ ) {
+
+ /**
+ * Enable checkboxes to be checked or unchecked in a row by clicking one,
+ * holding shift and clicking another one.
+ *
+ * @return {jQuery}
+ * @chainable
+ */
+ $.fn.checkboxShiftClick = function () {
+ var prevCheckbox = null,
+ $box = this;
+ // When our boxes are clicked..
+ $box.click( function ( e ) {
+ // And one has been clicked before...
+ if ( prevCheckbox !== null && e.shiftKey ) {
+ // Check or uncheck this one and all in-between checkboxes,
+ // except for disabled ones
+ $box
+ .slice(
+ Math.min( $box.index( prevCheckbox ), $box.index( e.target ) ),
+ Math.max( $box.index( prevCheckbox ), $box.index( e.target ) ) + 1
+ )
+ .filter( function () {
+ return !this.disabled;
+ } )
+ .prop( 'checked', !!e.target.checked );
+ }
+ // Either way, update the prevCheckbox variable to the one clicked now
+ prevCheckbox = e.target;
+ } );
+ return $box;
+ };
+
+ /**
+ * @class jQuery
+ * @mixins jQuery.plugin.checkboxShiftClick
+ */
+
+}( jQuery ) );
diff --git a/resources/src/jquery/jquery.client.js b/resources/src/jquery/jquery.client.js
new file mode 100644
index 00000000..662a6887
--- /dev/null
+++ b/resources/src/jquery/jquery.client.js
@@ -0,0 +1,301 @@
+/**
+ * User-agent detection
+ *
+ * @class jQuery.client
+ * @singleton
+ */
+( function ( $ ) {
+
+ /**
+ * @private
+ * @property {Object} profileCache Keyed by userAgent string,
+ * value is the parsed $.client.profile object for that user agent.
+ */
+ var profileCache = {};
+
+ $.client = {
+
+ /**
+ * Get an object containing information about the client.
+ *
+ * @param {Object} [nav] An object with a 'userAgent' and 'platform' property.
+ * Defaults to the global `navigator` object.
+ * @return {Object} The resulting client object will be in the following format:
+ *
+ * {
+ * 'name': 'firefox',
+ * 'layout': 'gecko',
+ * 'layoutVersion': 20101026,
+ * 'platform': 'linux'
+ * 'version': '3.5.1',
+ * 'versionBase': '3',
+ * 'versionNumber': 3.5,
+ * }
+ */
+ profile: function ( nav ) {
+ /*jshint boss: true */
+
+ if ( nav === undefined ) {
+ nav = window.navigator;
+ }
+
+ // Use the cached version if possible
+ if ( profileCache[ nav.userAgent + '|' + nav.platform ] !== undefined ) {
+ return profileCache[ nav.userAgent + '|' + nav.platform ];
+ }
+
+ var
+ versionNumber,
+ key = nav.userAgent + '|' + nav.platform,
+
+ // Configuration
+
+ // Name of browsers or layout engines we don't recognize
+ uk = 'unknown',
+ // Generic version digit
+ x = 'x',
+ // Strings found in user agent strings that need to be conformed
+ wildUserAgents = ['Opera', 'Navigator', 'Minefield', 'KHTML', 'Chrome', 'PLAYSTATION 3', 'Iceweasel'],
+ // Translations for conforming user agent strings
+ userAgentTranslations = [
+ // Tons of browsers lie about being something they are not
+ [/(Firefox|MSIE|KHTML,?\slike\sGecko|Konqueror)/, ''],
+ // Chrome lives in the shadow of Safari still
+ ['Chrome Safari', 'Chrome'],
+ // KHTML is the layout engine not the browser - LIES!
+ ['KHTML', 'Konqueror'],
+ // Firefox nightly builds
+ ['Minefield', 'Firefox'],
+ // This helps keep different versions consistent
+ ['Navigator', 'Netscape'],
+ // This prevents version extraction issues, otherwise translation would happen later
+ ['PLAYSTATION 3', 'PS3']
+ ],
+ // Strings which precede a version number in a user agent string - combined and used as
+ // match 1 in version detection
+ versionPrefixes = [
+ 'camino', 'chrome', 'firefox', 'iceweasel', 'netscape', 'netscape6', 'opera', 'version', 'konqueror',
+ 'lynx', 'msie', 'safari', 'ps3', 'android'
+ ],
+ // Used as matches 2, 3 and 4 in version extraction - 3 is used as actual version number
+ versionSuffix = '(\\/|\\;?\\s|)([a-z0-9\\.\\+]*?)(\\;|dev|rel|\\)|\\s|$)',
+ // Names of known browsers
+ names = [
+ 'camino', 'chrome', 'firefox', 'iceweasel', 'netscape', 'konqueror', 'lynx', 'msie', 'opera',
+ 'safari', 'ipod', 'iphone', 'blackberry', 'ps3', 'rekonq', 'android'
+ ],
+ // Tanslations for conforming browser names
+ nameTranslations = [],
+ // Names of known layout engines
+ layouts = ['gecko', 'konqueror', 'msie', 'trident', 'opera', 'webkit'],
+ // Translations for conforming layout names
+ layoutTranslations = [ ['konqueror', 'khtml'], ['msie', 'trident'], ['opera', 'presto'] ],
+ // Names of supported layout engines for version number
+ layoutVersions = ['applewebkit', 'gecko', 'trident'],
+ // Names of known operating systems
+ platforms = ['win', 'wow64', 'mac', 'linux', 'sunos', 'solaris', 'iphone'],
+ // Translations for conforming operating system names
+ platformTranslations = [ ['sunos', 'solaris'], ['wow64', 'win'] ],
+
+ /**
+ * Performs multiple replacements on a string
+ * @ignore
+ */
+ translate = function ( source, translations ) {
+ var i;
+ for ( i = 0; i < translations.length; i++ ) {
+ source = source.replace( translations[i][0], translations[i][1] );
+ }
+ return source;
+ },
+
+ // Pre-processing
+
+ ua = nav.userAgent,
+ match,
+ name = uk,
+ layout = uk,
+ layoutversion = uk,
+ platform = uk,
+ version = x;
+
+ if ( match = new RegExp( '(' + wildUserAgents.join( '|' ) + ')' ).exec( ua ) ) {
+ // Takes a userAgent string and translates given text into something we can more easily work with
+ ua = translate( ua, userAgentTranslations );
+ }
+ // Everything will be in lowercase from now on
+ ua = ua.toLowerCase();
+
+ // Extraction
+
+ if ( match = new RegExp( '(' + names.join( '|' ) + ')' ).exec( ua ) ) {
+ name = translate( match[1], nameTranslations );
+ }
+ if ( match = new RegExp( '(' + layouts.join( '|' ) + ')' ).exec( ua ) ) {
+ layout = translate( match[1], layoutTranslations );
+ }
+ if ( match = new RegExp( '(' + layoutVersions.join( '|' ) + ')\\\/(\\d+)').exec( ua ) ) {
+ layoutversion = parseInt( match[2], 10 );
+ }
+ if ( match = new RegExp( '(' + platforms.join( '|' ) + ')' ).exec( nav.platform.toLowerCase() ) ) {
+ platform = translate( match[1], platformTranslations );
+ }
+ if ( match = new RegExp( '(' + versionPrefixes.join( '|' ) + ')' + versionSuffix ).exec( ua ) ) {
+ version = match[3];
+ }
+
+ // Edge Cases -- did I mention about how user agent string lie?
+
+ // Decode Safari's crazy 400+ version numbers
+ if ( name === 'safari' && version > 400 ) {
+ version = '2.0';
+ }
+ // Expose Opera 10's lies about being Opera 9.8
+ if ( name === 'opera' && version >= 9.8 ) {
+ match = ua.match( /\bversion\/([0-9\.]*)/ );
+ if ( match && match[1] ) {
+ version = match[1];
+ } else {
+ version = '10';
+ }
+ }
+ // And Opera 15's lies about being Chrome
+ if ( name === 'chrome' && ( match = ua.match( /\bopr\/([0-9\.]*)/ ) ) ) {
+ if ( match[1] ) {
+ name = 'opera';
+ version = match[1];
+ }
+ }
+ // And IE 11's lies about being not being IE
+ if ( layout === 'trident' && layoutversion >= 7 && ( match = ua.match( /\brv[ :\/]([0-9\.]*)/ ) ) ) {
+ if ( match[1] ) {
+ name = 'msie';
+ version = match[1];
+ }
+ }
+ // And Amazon Silk's lies about being Android on mobile or Safari on desktop
+ if ( match = ua.match( /\bsilk\/([0-9.\-_]*)/ ) ) {
+ if ( match[1] ) {
+ name = 'silk';
+ version = match[1];
+ }
+ }
+
+ versionNumber = parseFloat( version, 10 ) || 0.0;
+
+ // Caching
+
+ return profileCache[ key ] = {
+ name: name,
+ layout: layout,
+ layoutVersion: layoutversion,
+ platform: platform,
+ version: version,
+ versionBase: ( version !== x ? Math.floor( versionNumber ).toString() : x ),
+ versionNumber: versionNumber
+ };
+ },
+
+ /**
+ * Checks the current browser against a support map object.
+ *
+ * Version numbers passed as numeric values will be compared like numbers (1.2 > 1.11).
+ * Version numbers passed as string values will be compared using a simple component-wise
+ * algorithm, similar to PHP's version_compare ('1.2' < '1.11').
+ *
+ * A browser map is in the following format:
+ *
+ * {
+ * // Multiple rules with configurable operators
+ * 'msie': [['>=', 7], ['!=', 9]],
+ * // Match no versions
+ * 'iphone': false,
+ * // Match any version
+ * 'android': null
+ * }
+ *
+ * It can optionally be split into ltr/rtl sections:
+ *
+ * {
+ * 'ltr': {
+ * 'android': null,
+ * 'iphone': false
+ * },
+ * 'rtl': {
+ * 'android': false,
+ * // rules are not inherited from ltr
+ * 'iphone': false
+ * }
+ * }
+ *
+ * @param {Object} map Browser support map
+ * @param {Object} [profile] A client-profile object
+ * @param {boolean} [exactMatchOnly=false] Only return true if the browser is matched, otherwise
+ * returns true if the browser is not found.
+ *
+ * @return {boolean} The current browser is in the support map
+ */
+ test: function ( map, profile, exactMatchOnly ) {
+ /*jshint evil: true */
+
+ var conditions, dir, i, op, val, j, pieceVersion, pieceVal, compare;
+ profile = $.isPlainObject( profile ) ? profile : $.client.profile();
+ if ( map.ltr && map.rtl ) {
+ dir = $( 'body' ).is( '.rtl' ) ? 'rtl' : 'ltr';
+ map = map[dir];
+ }
+ // Check over each browser condition to determine if we are running in a compatible client
+ if ( typeof map !== 'object' || map[profile.name] === undefined ) {
+ // Not found, return true if exactMatchOnly not set, false otherwise
+ return !exactMatchOnly;
+ }
+ conditions = map[profile.name];
+ if ( conditions === false ) {
+ // Match no versions
+ return false;
+ }
+ if ( conditions === null ) {
+ // Match all versions
+ return true;
+ }
+ for ( i = 0; i < conditions.length; i++ ) {
+ op = conditions[i][0];
+ val = conditions[i][1];
+ if ( typeof val === 'string' ) {
+ // Perform a component-wise comparison of versions, similar to PHP's version_compare
+ // but simpler. '1.11' is larger than '1.2'.
+ pieceVersion = profile.version.toString().split( '.' );
+ pieceVal = val.split( '.' );
+ // Extend with zeroes to equal length
+ while ( pieceVersion.length < pieceVal.length ) {
+ pieceVersion.push( '0' );
+ }
+ while ( pieceVal.length < pieceVersion.length ) {
+ pieceVal.push( '0' );
+ }
+ // Compare components
+ compare = 0;
+ for ( j = 0; j < pieceVersion.length; j++ ) {
+ if ( Number( pieceVersion[j] ) < Number( pieceVal[j] ) ) {
+ compare = -1;
+ break;
+ } else if ( Number( pieceVersion[j] ) > Number( pieceVal[j] ) ) {
+ compare = 1;
+ break;
+ }
+ }
+ // compare will be -1, 0 or 1, depending on comparison result
+ if ( !( eval( '' + compare + op + '0' ) ) ) {
+ return false;
+ }
+ } else if ( typeof val === 'number' ) {
+ if ( !( eval( 'profile.versionNumber' + op + val ) ) ) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+ };
+}( jQuery ) );
diff --git a/resources/src/jquery/jquery.color.js b/resources/src/jquery/jquery.color.js
new file mode 100644
index 00000000..04f8047b
--- /dev/null
+++ b/resources/src/jquery/jquery.color.js
@@ -0,0 +1,55 @@
+/**
+ * jQuery Color Animations
+ *
+ * @author John Resig, 2007
+ * @author Krinkle, 2011
+ * Released under the MIT and GPL licenses.
+ *
+ * - 2011-01-05: Forked for MediaWiki. See also jQuery.colorUtil plugin
+ */
+( function ( $ ) {
+
+ function getColor( elem, attr ) {
+ /*jshint boss:true */
+ var color;
+
+ do {
+ color = $.css( elem, attr );
+
+ // Keep going until we find an element that has color, or we hit the body
+ if ( color !== '' && color !== 'transparent' || $.nodeName( elem, 'body' ) ) {
+ break;
+ }
+
+ attr = 'backgroundColor';
+ } while ( elem = elem.parentNode );
+
+ return $.colorUtil.getRGB( color );
+ }
+
+ // We override the animation for all of these color styles
+ $.each([
+ 'backgroundColor',
+ 'borderBottomColor',
+ 'borderLeftColor',
+ 'borderRightColor',
+ 'borderTopColor',
+ 'color',
+ 'outlineColor'
+ ], function ( i, attr ) {
+ $.fx.step[attr] = function ( fx ) {
+ if ( !fx.colorInit ) {
+ fx.start = getColor( fx.elem, attr );
+ fx.end = $.colorUtil.getRGB( fx.end );
+ fx.colorInit = true;
+ }
+
+ fx.elem.style[attr] = 'rgb(' + [
+ Math.max( Math.min( parseInt( (fx.pos * (fx.end[0] - fx.start[0])) + fx.start[0], 10 ), 255 ), 0 ),
+ Math.max( Math.min( parseInt( (fx.pos * (fx.end[1] - fx.start[1])) + fx.start[1], 10 ), 255 ), 0 ),
+ Math.max( Math.min( parseInt( (fx.pos * (fx.end[2] - fx.start[2])) + fx.start[2], 10 ), 255 ), 0 )
+ ].join( ',' ) + ')';
+ };
+ } );
+
+}( jQuery ) );
diff --git a/resources/src/jquery/jquery.colorUtil.js b/resources/src/jquery/jquery.colorUtil.js
new file mode 100644
index 00000000..a6ff8bc8
--- /dev/null
+++ b/resources/src/jquery/jquery.colorUtil.js
@@ -0,0 +1,262 @@
+/*!
+ * jQuery Color Utilities
+ *
+ * Released under the MIT and GPL licenses.
+ *
+ * Mostly based on other plugins and functions (linted and optimized a little).
+ * Sources cited inline.
+ */
+( function ( $ ) {
+ /**
+ * @class jQuery.colorUtil
+ * @singleton
+ */
+ $.colorUtil = {
+
+ /**
+ * Parse CSS color strings looking for color tuples
+ *
+ * Based on highlightFade by Blair Mitchelmore
+ * <http://jquery.offput.ca/highlightFade/>
+ *
+ * @param {Array|string} color
+ * @return {Array}
+ */
+ getRGB: function ( color ) {
+ /*jshint boss:true */
+ var result;
+
+ // Check if we're already dealing with an array of colors
+ if ( color && $.isArray( color ) && color.length === 3 ) {
+ return color;
+ }
+
+ // Look for rgb(num,num,num)
+ if (result = /rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)/.exec(color)) {
+ return [
+ parseInt( result[1], 10 ),
+ parseInt( result[2], 10 ),
+ parseInt( result[3], 10 )
+ ];
+ }
+
+ // Look for rgb(num%,num%,num%)
+ if (result = /rgb\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*\)/.exec(color)) {
+ return [
+ parseFloat( result[1] ) * 2.55,
+ parseFloat( result[2] ) * 2.55,
+ parseFloat( result[3] ) * 2.55
+ ];
+ }
+
+ // Look for #a0b1c2
+ if (result = /#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/.exec(color)) {
+ return [
+ parseInt( result[1], 16 ),
+ parseInt( result[2], 16 ),
+ parseInt( result[3], 16 )
+ ];
+ }
+
+ // Look for #fff
+ if (result = /#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])/.exec(color)) {
+ return [
+ parseInt( result[1] + result[1], 16 ),
+ parseInt( result[2] + result[2], 16 ),
+ parseInt( result[3] + result[3], 16)
+ ];
+ }
+
+ // Look for rgba(0, 0, 0, 0) == transparent in Safari 3
+ if (result = /rgba\(0, 0, 0, 0\)/.exec(color)) {
+ return $.colorUtil.colors.transparent;
+ }
+
+ // Otherwise, we're most likely dealing with a named color
+ return $.colorUtil.colors[$.trim(color).toLowerCase()];
+ },
+
+ /**
+ * Named color map
+ *
+ * Based on Interface by Stefan Petre
+ * <http://interface.eyecon.ro/>
+ *
+ * @property {Object}
+ */
+ colors: {
+ aqua: [0, 255, 255],
+ azure: [240, 255, 255],
+ beige: [245, 245, 220],
+ black: [0, 0, 0],
+ blue: [0, 0, 255],
+ brown: [165, 42, 42],
+ cyan: [0, 255, 255],
+ darkblue: [0, 0, 139],
+ darkcyan: [0, 139, 139],
+ darkgrey: [169, 169, 169],
+ darkgreen: [0, 100, 0],
+ darkkhaki: [189, 183, 107],
+ darkmagenta: [139, 0, 139],
+ darkolivegreen: [85, 107, 47],
+ darkorange: [255, 140, 0],
+ darkorchid: [153, 50, 204],
+ darkred: [139, 0, 0],
+ darksalmon: [233, 150, 122],
+ darkviolet: [148, 0, 211],
+ fuchsia: [255, 0, 255],
+ gold: [255, 215, 0],
+ green: [0, 128, 0],
+ indigo: [75, 0, 130],
+ khaki: [240, 230, 140],
+ lightblue: [173, 216, 230],
+ lightcyan: [224, 255, 255],
+ lightgreen: [144, 238, 144],
+ lightgrey: [211, 211, 211],
+ lightpink: [255, 182, 193],
+ lightyellow: [255, 255, 224],
+ lime: [0, 255, 0],
+ magenta: [255, 0, 255],
+ maroon: [128, 0, 0],
+ navy: [0, 0, 128],
+ olive: [128, 128, 0],
+ orange: [255, 165, 0],
+ pink: [255, 192, 203],
+ purple: [128, 0, 128],
+ violet: [128, 0, 128],
+ red: [255, 0, 0],
+ silver: [192, 192, 192],
+ white: [255, 255, 255],
+ yellow: [255, 255, 0],
+ transparent: [255, 255, 255]
+ },
+
+ /**
+ * Convert an RGB color value to HSL.
+ *
+ * Conversion formula based on
+ * <http://mjijackson.com/2008/02/rgb-to-hsl-and-rgb-to-hsv-color-model-conversion-algorithms-in-javascript>
+ *
+ * Adapted from <https://en.wikipedia.org/wiki/HSL_color_space>.
+ *
+ * Assumes `r`, `g`, and `b` are contained in the set `[0, 255]` and
+ * returns `h`, `s`, and `l` in the set `[0, 1]`.
+ *
+ * @param {number} r The red color value
+ * @param {number} g The green color value
+ * @param {number} b The blue color value
+ * @return {number[]} The HSL representation
+ */
+ rgbToHsl: function ( r, g, b ) {
+ r = r / 255;
+ g = g / 255;
+ b = b / 255;
+
+ var d,
+ max = Math.max( r, g, b ),
+ min = Math.min( r, g, b ),
+ h,
+ s,
+ l = (max + min) / 2;
+
+ if ( max === min ) {
+ // achromatic
+ h = s = 0;
+ } else {
+ d = max - min;
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
+ switch ( max ) {
+ case r:
+ h = (g - b) / d + (g < b ? 6 : 0);
+ break;
+ case g:
+ h = (b - r) / d + 2;
+ break;
+ case b:
+ h = (r - g) / d + 4;
+ break;
+ }
+ h /= 6;
+ }
+
+ return [h, s, l];
+ },
+
+ /**
+ * Convert an HSL color value to RGB.
+ *
+ * Conversion formula based on
+ * <http://mjijackson.com/2008/02/rgb-to-hsl-and-rgb-to-hsv-color-model-conversion-algorithms-in-javascript>
+ *
+ * Adapted from <https://en.wikipedia.org/wiki/HSL_color_space>.
+ *
+ * Assumes `h`, `s`, and `l` are contained in the set `[0, 1]` and
+ * returns `r`, `g`, and `b` in the set `[0, 255]`.
+ *
+ * @param {number} h The hue
+ * @param {number} s The saturation
+ * @param {number} l The lightness
+ * @return {number[]} The RGB representation
+ */
+ hslToRgb: function ( h, s, l ) {
+ var r, g, b, hue2rgb, q, p;
+
+ if ( s === 0 ) {
+ r = g = b = l; // achromatic
+ } else {
+ hue2rgb = function ( p, q, t ) {
+ if ( t < 0 ) {
+ t += 1;
+ }
+ if ( t > 1 ) {
+ t -= 1;
+ }
+ if ( t < 1 / 6 ) {
+ return p + (q - p) * 6 * t;
+ }
+ if ( t < 1 / 2 ) {
+ return q;
+ }
+ if ( t < 2 / 3 ) {
+ return p + (q - p) * (2 / 3 - t) * 6;
+ }
+ return p;
+ };
+
+ q = l < 0.5 ? l * (1 + s) : l + s - l * s;
+ p = 2 * l - q;
+ r = hue2rgb( p, q, h + 1 / 3 );
+ g = hue2rgb( p, q, h );
+ b = hue2rgb( p, q, h - 1 / 3 );
+ }
+
+ return [r * 255, g * 255, b * 255];
+ },
+
+ /**
+ * Get a brighter or darker rgb() value string.
+ *
+ * Usage:
+ *
+ * $.colorUtil.getColorBrightness( 'red', +0.1 );
+ * // > "rgb(255,50,50)"
+ * $.colorUtil.getColorBrightness( 'rgb(200,50,50)', -0.2 );
+ * // > "rgb(118,29,29)"
+ *
+ * @param {Mixed} currentColor Current value in css
+ * @param {number} mod Wanted brightness modification between -1 and 1
+ * @return {string} Like `'rgb(r,g,b)'`
+ */
+ getColorBrightness: function ( currentColor, mod ) {
+ var rgbArr = $.colorUtil.getRGB( currentColor ),
+ hslArr = $.colorUtil.rgbToHsl(rgbArr[0], rgbArr[1], rgbArr[2] );
+ rgbArr = $.colorUtil.hslToRgb(hslArr[0], hslArr[1], hslArr[2] + mod);
+
+ return 'rgb(' +
+ [parseInt( rgbArr[0], 10), parseInt( rgbArr[1], 10 ), parseInt( rgbArr[2], 10 )].join( ',' ) +
+ ')';
+ }
+
+ };
+
+}( jQuery ) );
diff --git a/resources/src/jquery/jquery.confirmable.css b/resources/src/jquery/jquery.confirmable.css
new file mode 100644
index 00000000..de690726
--- /dev/null
+++ b/resources/src/jquery/jquery.confirmable.css
@@ -0,0 +1,28 @@
+.jquery-confirmable-button {
+ /* Automatically flipped */
+ margin-left: 1ex;
+}
+
+.jquery-confirmable-wrapper {
+ /* Line breaks within the interface text are unpleasant */
+ white-space: nowrap;
+ /* Hiding the original text when it slides to the left */
+ overflow: hidden;
+}
+
+.jquery-confirmable-wrapper,
+.jquery-confirmable-element,
+.jquery-confirmable-interface {
+ /* We need inline-block to be able to size the elements and calculate their dimensions */
+ display: inline-block;
+ /* inline-block elements in this context align to baseline by default */
+ vertical-align: bottom;
+}
+
+.jquery-confirmable-element {
+ transition: margin 250ms cubic-bezier(0.2, 0.8, 0.2, 0.8);
+}
+
+.jquery-confirmable-interface {
+ transition: width 250ms cubic-bezier(0.2, 0.8, 0.2, 0.8);
+}
diff --git a/resources/src/jquery/jquery.confirmable.js b/resources/src/jquery/jquery.confirmable.js
new file mode 100644
index 00000000..339e65a4
--- /dev/null
+++ b/resources/src/jquery/jquery.confirmable.js
@@ -0,0 +1,170 @@
+/**
+ * jQuery confirmable plugin
+ *
+ * Released under the MIT License.
+ *
+ * @author Bartosz Dziewoński
+ *
+ * @class jQuery.plugin.confirmable
+ */
+( function ( $ ) {
+ var identity = function ( data ) {
+ return data;
+ };
+
+ /**
+ * Enable inline confirmation for given clickable element (like `<a />` or `<button />`).
+ *
+ * An additional inline confirmation step being shown before the default action is carried out on
+ * click.
+ *
+ * Calling `.confirmable( { handler: function () { … } } )` will fire the handler only after the
+ * confirmation step.
+ *
+ * The element will have the `jquery-confirmable-element` class added to it when it's clicked for
+ * the first time, which has `white-space: nowrap;` and `display: inline-block;` defined in CSS.
+ * If the computed values for the element are different when you make it confirmable, you might
+ * encounter unexpected behavior.
+ *
+ * @param {Object} [options]
+ * @param {string} [options.events='click'] Events to hook to.
+ * @param {Function} [options.wrapperCallback] Callback to fire when preparing confirmable
+ * interface. Receives the interface jQuery object as the only parameter.
+ * @param {Function} [options.buttonCallback] Callback to fire when preparing confirmable buttons.
+ * It is fired separately for the 'Yes' and 'No' button. Receives the button jQuery object as
+ * the first parameter and 'yes' or 'no' as the second.
+ * @param {Function} [options.handler] Callback to fire when the action is confirmed (user clicks
+ * the 'Yes' button).
+ * @param {string} [options.i18n] Text to use for interface elements.
+ * @param {string} [options.i18n.space] Word separator to place between the three text messages.
+ * @param {string} [options.i18n.confirm] Text to use for the confirmation question.
+ * @param {string} [options.i18n.yes] Text to use for the 'Yes' button.
+ * @param {string} [options.i18n.no] Text to use for the 'No' button.
+ *
+ * @chainable
+ */
+ $.fn.confirmable = function ( options ) {
+ options = $.extend( true, {}, $.fn.confirmable.defaultOptions, options || {} );
+
+ return this.on( options.events, function ( e ) {
+ var $element, $text, $buttonYes, $buttonNo, $wrapper, $interface, $elementClone,
+ interfaceWidth, elementWidth, rtl, positionOffscreen, positionRestore, sideMargin;
+
+ $element = $( this );
+
+ if ( $element.data( 'jquery-confirmable-button' ) ) {
+ // We're running on a clone of this element that represents the 'Yes' or 'No' button.
+ // (This should never happen for the 'No' case unless calling code does bad things.)
+ return;
+ }
+
+ // Only prevent native event handling. Stopping other JavaScript event handlers
+ // is impossible because they might have already run (we have no control over the order).
+ e.preventDefault();
+
+ rtl = $element.css( 'direction' ) === 'rtl';
+ if ( rtl ) {
+ positionOffscreen = { position: 'absolute', right: '-9999px' };
+ positionRestore = { position: '', right: '' };
+ sideMargin = 'marginRight';
+ } else {
+ positionOffscreen = { position: 'absolute', left: '-9999px' };
+ positionRestore = { position: '', left: '' };
+ sideMargin = 'marginLeft';
+ }
+
+ if ( $element.hasClass( 'jquery-confirmable-element' ) ) {
+ $wrapper = $element.closest( '.jquery-confirmable-wrapper' );
+ $interface = $wrapper.find( '.jquery-confirmable-interface' );
+ $text = $interface.find( '.jquery-confirmable-text' );
+ $buttonYes = $interface.find( '.jquery-confirmable-button-yes' );
+ $buttonNo = $interface.find( '.jquery-confirmable-button-no' );
+
+ interfaceWidth = $interface.data( 'jquery-confirmable-width' );
+ elementWidth = $element.data( 'jquery-confirmable-width' );
+ } else {
+ $elementClone = $element.clone( true );
+ $element.addClass( 'jquery-confirmable-element' );
+
+ elementWidth = $element.width();
+ $element.data( 'jquery-confirmable-width', elementWidth );
+
+ $wrapper = $( '<span>' )
+ .addClass( 'jquery-confirmable-wrapper' );
+ $element.wrap( $wrapper );
+
+ // Build the mini-dialog
+ $text = $( '<span>' )
+ .addClass( 'jquery-confirmable-text' )
+ .text( options.i18n.confirm );
+
+ // Clone original element along with event handlers to easily replicate its behavior.
+ // We could fiddle with .trigger() etc., but that is troublesome especially since
+ // Safari doesn't implement .click() on <a> links and jQuery follows suit.
+ $buttonYes = $elementClone.clone( true )
+ .addClass( 'jquery-confirmable-button jquery-confirmable-button-yes' )
+ .data( 'jquery-confirmable-button', true )
+ .text( options.i18n.yes );
+ if ( options.handler ) {
+ $buttonYes.on( options.events, options.handler );
+ }
+ $buttonYes = options.buttonCallback( $buttonYes, 'yes' );
+
+ // Clone it without any events and prevent default action to represent the 'No' button.
+ $buttonNo = $elementClone.clone( false )
+ .addClass( 'jquery-confirmable-button jquery-confirmable-button-no' )
+ .data( 'jquery-confirmable-button', true )
+ .text( options.i18n.no )
+ .on( options.events, function ( e ) {
+ $element.css( sideMargin, 0 );
+ $interface.css( 'width', 0 );
+ e.preventDefault();
+ } );
+ $buttonNo = options.buttonCallback( $buttonNo, 'no' );
+
+ // Prevent memory leaks
+ $elementClone.remove();
+
+ $interface = $( '<span>' )
+ .addClass( 'jquery-confirmable-interface' )
+ .append( $text, options.i18n.space, $buttonYes, options.i18n.space, $buttonNo );
+ $interface = options.wrapperCallback( $interface );
+
+ // Render offscreen to measure real width
+ $interface.css( positionOffscreen );
+ // Insert it in the correct place while we're at it
+ $element.after( $interface );
+ interfaceWidth = $interface.width();
+ $interface.data( 'jquery-confirmable-width', interfaceWidth );
+ $interface.css( positionRestore );
+
+ // Hide to animate the transition later
+ $interface.css( 'width', 0 );
+ }
+
+ // Hide element, show interface. This triggers both transitions.
+ // In a timeout to trigger the 'width' transition.
+ setTimeout( function () {
+ $element.css( sideMargin, -elementWidth );
+ $interface.css( 'width', interfaceWidth );
+ }, 1 );
+ } );
+ };
+
+ /**
+ * Default options. Overridable primarily for internationalisation handling.
+ * @property {Object} defaultOptions
+ */
+ $.fn.confirmable.defaultOptions = {
+ events: 'click',
+ wrapperCallback: identity,
+ buttonCallback: identity,
+ handler: null,
+ i18n: {
+ space: ' ',
+ confirm: 'Are you sure?',
+ yes: 'Yes',
+ no: 'No'
+ }
+ };
+}( jQuery ) );
diff --git a/resources/src/jquery/jquery.confirmable.mediawiki.js b/resources/src/jquery/jquery.confirmable.mediawiki.js
new file mode 100644
index 00000000..d4a106e3
--- /dev/null
+++ b/resources/src/jquery/jquery.confirmable.mediawiki.js
@@ -0,0 +1,14 @@
+/*!
+ * jQuery confirmable plugin customization for MediaWiki
+ *
+ * This file serves to inject our localised messages into it.
+ */
+
+( function ( mw, $ ) {
+ $.fn.confirmable.defaultOptions.i18n = {
+ space: mw.message( 'word-separator' ).text(),
+ confirm: mw.message( 'confirmable-confirm', mw.user ).text(),
+ yes: mw.message( 'confirmable-yes' ).text(),
+ no: mw.message( 'confirmable-no' ).text()
+ };
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/jquery/jquery.expandableField.js b/resources/src/jquery/jquery.expandableField.js
new file mode 100644
index 00000000..732cc6ec
--- /dev/null
+++ b/resources/src/jquery/jquery.expandableField.js
@@ -0,0 +1,140 @@
+/**
+ * This plugin provides functionality to expand a text box on focus to double it's current width
+ *
+ * Usage:
+ *
+ * Set options:
+ * $('#textbox').expandableField( { option1: value1, option2: value2 } );
+ * $('#textbox').expandableField( option, value );
+ * Get option:
+ * value = $('#textbox').expandableField( option );
+ * Initialize:
+ * $('#textbox').expandableField();
+ *
+ * Options:
+ *
+ */
+( function ( $ ) {
+
+ $.expandableField = {
+ /**
+ * Expand the field, make the callback
+ */
+ expandField: function ( e, context ) {
+ context.config.beforeExpand.call( context.data.$field, context );
+ context.data.$field
+ .animate( { 'width': context.data.expandedWidth }, 'fast', function () {
+ context.config.afterExpand.call( this, context );
+ } );
+ },
+ /**
+ * Condense the field, make the callback
+ */
+ condenseField: function ( e, context ) {
+ context.config.beforeCondense.call( context.data.$field, context );
+ context.data.$field
+ .animate( { 'width': context.data.condensedWidth }, 'fast', function () {
+ context.config.afterCondense.call( this, context );
+ } );
+ },
+ /**
+ * Sets the value of a property, and updates the widget accordingly
+ * @param property String Name of property
+ * @param value Mixed Value to set property with
+ */
+ configure: function ( context, property, value ) {
+ // TODO: Validate creation using fallback values
+ context.config[property] = value;
+ }
+
+ };
+
+ $.fn.expandableField = function () {
+
+ // Multi-context fields
+ var returnValue,
+ args = arguments;
+
+ $( this ).each( function () {
+ var key, context, timeout;
+
+ /* Construction / Loading */
+
+ context = $( this ).data( 'expandableField-context' );
+
+ // TODO: Do we need to check both null and undefined?
+ if ( context === undefined || context === null ) {
+ context = {
+ config: {
+ // callback function for before collapse
+ beforeCondense: function () {},
+
+ // callback function for before expand
+ beforeExpand: function () {},
+
+ // callback function for after collapse
+ afterCondense: function () {},
+
+ // callback function for after expand
+ afterExpand: function () {},
+
+ // Whether the field should expand to the left or the right -- defaults to left
+ expandToLeft: true
+ }
+ };
+ }
+
+ /* API */
+ // Handle various calling styles
+ if ( args.length > 0 ) {
+ if ( typeof args[0] === 'object' ) {
+ // Apply set of properties
+ for ( key in args[0] ) {
+ $.expandableField.configure( context, key, args[0][key] );
+ }
+ } else if ( typeof args[0] === 'string' ) {
+ if ( args.length > 1 ) {
+ // Set property values
+ $.expandableField.configure( context, args[0], args[1] );
+
+ // TODO: Do we need to check both null and undefined?
+ } else if ( returnValue === null || returnValue === undefined ) {
+ // Get property values, but don't give access to internal data - returns only the first
+ returnValue = ( args[0] in context.config ? undefined : context.config[args[0]] );
+ }
+ }
+ }
+
+ /* Initialization */
+
+ if ( context.data === undefined ) {
+ context.data = {
+ // The width of the field in it's condensed state
+ condensedWidth: $( this ).width(),
+
+ // The width of the field in it's expanded state
+ expandedWidth: $( this ).width() * 2,
+
+ // Reference to the field
+ $field: $( this )
+ };
+
+ $( this )
+ .addClass( 'expandableField' )
+ .focus( function ( e ) {
+ clearTimeout( timeout );
+ $.expandableField.expandField( e, context );
+ } )
+ .blur( function ( e ) {
+ timeout = setTimeout( function () {
+ $.expandableField.condenseField( e, context );
+ }, 250 );
+ } );
+ }
+ // Store the context for next time
+ $( this ).data( 'expandableField-context', context );
+ } );
+ return returnValue !== undefined ? returnValue : $(this);
+ };
+
+}( jQuery ) );
diff --git a/resources/src/jquery/jquery.farbtastic.css b/resources/src/jquery/jquery.farbtastic.css
new file mode 100644
index 00000000..1c6428f8
--- /dev/null
+++ b/resources/src/jquery/jquery.farbtastic.css
@@ -0,0 +1,54 @@
+/**
+ * Farbtastic Color Picker 1.2
+ * © 2008 Steven Wittens
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+.farbtastic {
+ position: relative;
+}
+.farbtastic * {
+ position: absolute;
+ cursor: crosshair;
+}
+.farbtastic, .farbtastic .wheel {
+ width: 195px;
+ height: 195px;
+}
+.farbtastic .color, .farbtastic .overlay {
+ top: 47px;
+ left: 47px;
+ width: 101px;
+ height: 101px;
+}
+.farbtastic .wheel {
+ /* @embed */
+ background: url(images/wheel.png) no-repeat;
+ width: 195px;
+ height: 195px;
+}
+.farbtastic .overlay {
+ /* @embed */
+ background: url(images/mask.png) no-repeat;
+}
+.farbtastic .marker {
+ width: 17px;
+ height: 17px;
+ margin: -8px 0 0 -8px;
+ overflow: hidden;
+ /* @embed */
+ background: url(images/marker.png) no-repeat;
+}
+
diff --git a/resources/src/jquery/jquery.farbtastic.js b/resources/src/jquery/jquery.farbtastic.js
new file mode 100644
index 00000000..d7024cc8
--- /dev/null
+++ b/resources/src/jquery/jquery.farbtastic.js
@@ -0,0 +1,286 @@
+/**
+ * Farbtastic Color Picker 1.2
+ * © 2008 Steven Wittens
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+
+//Adapted to uniform style with jQuery UI widgets and slightly change behavior
+//TODO:
+// - remove duplicated code by replacing it with jquery.colorUtils and modern jQuery
+// - uniform code style
+
+jQuery.fn.farbtastic = function (callback) {
+ $.farbtastic(this, callback);
+ return this;
+};
+
+jQuery.farbtastic = function (container, callback) {
+ var container = $(container).get(0);
+ return container.farbtastic || (container.farbtastic = new jQuery._farbtastic(container, callback));
+}
+
+jQuery._farbtastic = function (container, callback) {
+ // Store farbtastic object
+ var fb = this;
+
+ // Insert markup
+ $(container).html('<div class="farbtastic ui-widget-content"><div class="color"></div><div class="wheel"></div><div class="overlay"></div><div class="h-marker marker"></div><div class="sl-marker marker"></div></div>');
+ $(container).addClass('ui-widget');
+ var e = $('.farbtastic', container);
+ fb.wheel = $('.wheel', container).get(0);
+ // Dimensions
+ fb.radius = 84;
+ fb.square = 100;
+ fb.width = 194;
+
+ // Fix background PNGs in IE6
+ if (navigator.appVersion.match(/MSIE [0-6]\./)) {
+ $('*', e).each(function () {
+ if (this.currentStyle.backgroundImage != 'none') {
+ var image = this.currentStyle.backgroundImage;
+ image = this.currentStyle.backgroundImage.slice(5, image.length - 2);
+ $(this).css({
+ 'backgroundImage': 'none',
+ 'filter': "progid:DXImageTransform.Microsoft.AlphaImageLoader(enabled=true, sizingMethod=crop, src='" + image + "')"
+ });
+ }
+ });
+ }
+
+ /**
+ * Link to the given element(s) or callback.
+ */
+ fb.linkTo = function (callback) {
+ // Unbind previous nodes
+ if (typeof fb.callback == 'object') {
+ $(fb.callback).unbind('keyup', fb.updateValue);
+ }
+
+ // Reset color
+ fb.color = null;
+
+ // Bind callback or elements
+ if (typeof callback == 'function') {
+ fb.callback = callback;
+ }
+ else if (typeof callback == 'object' || typeof callback == 'string') {
+ fb.callback = $(callback);
+ fb.callback.bind('keyup', fb.updateValue);
+ if (fb.callback.get(0).value) {
+ fb.setColor(fb.callback.get(0).value);
+ }
+ }
+ return this;
+ }
+ fb.updateValue = function (event) {
+ if (this.value != fb.color) {
+ fb.setColor(this.value);
+ }
+ }
+
+ /**
+ * Change color with HTML syntax #123456
+ */
+ fb.setColor = function (color) {
+ var rgb = $.colorUtil.getRGB( color );
+ if (fb.color != color && rgb) {
+ rgb = rgb.slice( 0 ); //make a clone
+ //TODO: rewrite code so that this is not needed
+ rgb[0] /= 255;
+ rgb[1] /= 255;
+ rgb[2] /= 255;
+ fb.color = color;
+ fb.rgb = rgb;
+ fb.hsl = fb.RGBToHSL(fb.rgb);
+ fb.updateDisplay();
+ }
+ return this;
+ }
+
+ /**
+ * Change color with HSL triplet [0..1, 0..1, 0..1]
+ */
+ fb.setHSL = function (hsl) {
+ fb.hsl = hsl;
+ fb.rgb = fb.HSLToRGB(hsl);
+ fb.color = fb.pack(fb.rgb);
+ fb.updateDisplay();
+ return this;
+ }
+
+ /////////////////////////////////////////////////////
+
+ /**
+ * Retrieve the coordinates of the given event relative to the center
+ * of the widget.
+ */
+ fb.widgetCoords = function (event) {
+ var ref = $( fb.wheel ).offset();
+ return {
+ x: event.pageX - ref.left - fb.width / 2,
+ y: event.pageY - ref.top - fb.width / 2
+ };
+ }
+
+ /**
+ * Mousedown handler
+ */
+ fb.mousedown = function (event) {
+ // Capture mouse
+ if (!document.dragging) {
+ $(document).bind('mousemove', fb.mousemove).bind('mouseup', fb.mouseup);
+ document.dragging = true;
+ }
+
+ // Check which area is being dragged
+ var pos = fb.widgetCoords(event);
+ fb.circleDrag = Math.max(Math.abs(pos.x), Math.abs(pos.y)) * 2 > fb.square;
+
+ // Process
+ fb.mousemove(event);
+ return false;
+ }
+
+ /**
+ * Mousemove handler
+ */
+ fb.mousemove = function (event) {
+ // Get coordinates relative to color picker center
+ var pos = fb.widgetCoords(event);
+
+ // Set new HSL parameters
+ if (fb.circleDrag) {
+ var hue = Math.atan2(pos.x, -pos.y) / 6.28;
+ if (hue < 0) hue += 1;
+ fb.setHSL([hue, fb.hsl[1], fb.hsl[2]]);
+ }
+ else {
+ var sat = Math.max(0, Math.min(1, -(pos.x / fb.square) + .5));
+ var lum = Math.max(0, Math.min(1, -(pos.y / fb.square) + .5));
+ fb.setHSL([fb.hsl[0], sat, lum]);
+ }
+ return false;
+ }
+
+ /**
+ * Mouseup handler
+ */
+ fb.mouseup = function () {
+ // Uncapture mouse
+ $(document).unbind('mousemove', fb.mousemove);
+ $(document).unbind('mouseup', fb.mouseup);
+ document.dragging = false;
+ }
+
+ /**
+ * Update the markers and styles
+ */
+ fb.updateDisplay = function () {
+ // Markers
+ var angle = fb.hsl[0] * 6.28;
+ $('.h-marker', e).css({
+ left: Math.round(Math.sin(angle) * fb.radius + fb.width / 2) + 'px',
+ top: Math.round(-Math.cos(angle) * fb.radius + fb.width / 2) + 'px'
+ });
+
+ $('.sl-marker', e).css({
+ left: Math.round(fb.square * (.5 - fb.hsl[1]) + fb.width / 2) + 'px',
+ top: Math.round(fb.square * (.5 - fb.hsl[2]) + fb.width / 2) + 'px'
+ });
+
+ // Saturation/Luminance gradient
+ $('.color', e).css('backgroundColor', fb.pack(fb.HSLToRGB([fb.hsl[0], 1, 0.5])));
+
+ // Linked elements or callback
+ if (typeof fb.callback == 'object') {
+ // Set background/foreground color
+ $(fb.callback).css({
+ backgroundColor: fb.color,
+ color: fb.hsl[2] > 0.5 ? '#000' : '#fff'
+ });
+
+ // Change linked value
+ $(fb.callback).each(function() {
+ if ( $( this ).val() != fb.color) {
+ $( this ).val( fb.color ).change();
+ }
+ });
+ }
+ else if (typeof fb.callback == 'function') {
+ fb.callback.call(fb, fb.color);
+ }
+ }
+
+ /* Various color utility functions */
+ fb.pack = function (rgb) {
+ var r = Math.round(rgb[0] * 255);
+ var g = Math.round(rgb[1] * 255);
+ var b = Math.round(rgb[2] * 255);
+ return '#' + (r < 16 ? '0' : '') + r.toString(16) +
+ (g < 16 ? '0' : '') + g.toString(16) +
+ (b < 16 ? '0' : '') + b.toString(16);
+ }
+
+ fb.HSLToRGB = function (hsl) {
+ var m1, m2, r, g, b;
+ var h = hsl[0], s = hsl[1], l = hsl[2];
+ m2 = (l <= 0.5) ? l * (s + 1) : l + s - l*s;
+ m1 = l * 2 - m2;
+ return [this.hueToRGB(m1, m2, h+0.33333),
+ this.hueToRGB(m1, m2, h),
+ this.hueToRGB(m1, m2, h-0.33333)];
+ }
+
+ fb.hueToRGB = function (m1, m2, h) {
+ h = (h < 0) ? h + 1 : ((h > 1) ? h - 1 : h);
+ if (h * 6 < 1) return m1 + (m2 - m1) * h * 6;
+ if (h * 2 < 1) return m2;
+ if (h * 3 < 2) return m1 + (m2 - m1) * (0.66666 - h) * 6;
+ return m1;
+ }
+
+ fb.RGBToHSL = function (rgb) {
+ var min, max, delta, h, s, l;
+ var r = rgb[0], g = rgb[1], b = rgb[2];
+ min = Math.min(r, Math.min(g, b));
+ max = Math.max(r, Math.max(g, b));
+ delta = max - min;
+ l = (min + max) / 2;
+ s = 0;
+ if (l > 0 && l < 1) {
+ s = delta / (l < 0.5 ? (2 * l) : (2 - 2 * l));
+ }
+ h = 0;
+ if (delta > 0) {
+ if (max == r && max != g) h += (g - b) / delta;
+ if (max == g && max != b) h += (2 + (b - r) / delta);
+ if (max == b && max != r) h += (4 + (r - g) / delta);
+ h /= 6;
+ }
+ return [h, s, l];
+ }
+
+ // Install mousedown handler (the others are set on the document on-demand)
+ $('*', e).mousedown(fb.mousedown);
+
+ // Init color
+ fb.setColor('#000000');
+
+ // Set linked elements/callback
+ if (callback) {
+ fb.linkTo(callback);
+ }
+}
diff --git a/resources/src/jquery/jquery.footHovzer.css b/resources/src/jquery/jquery.footHovzer.css
new file mode 100644
index 00000000..77d9514c
--- /dev/null
+++ b/resources/src/jquery/jquery.footHovzer.css
@@ -0,0 +1,6 @@
+#jquery-foot-hovzer {
+ position: fixed;
+ bottom: 0;
+ width: 100%;
+ z-index: 1000;
+}
diff --git a/resources/src/jquery/jquery.footHovzer.js b/resources/src/jquery/jquery.footHovzer.js
new file mode 100644
index 00000000..de745c33
--- /dev/null
+++ b/resources/src/jquery/jquery.footHovzer.js
@@ -0,0 +1,66 @@
+/**
+ * @class jQuery.plugin.footHovzer
+ */
+( function ( $ ) {
+ var $hovzer, footHovzer, prevHeight, newHeight;
+
+ function getHovzer() {
+ if ( $hovzer === undefined ) {
+ $hovzer = $( '<div id="jquery-foot-hovzer"></div>' ).appendTo( 'body' );
+ }
+ return $hovzer;
+ }
+
+ /**
+ * Utility to stack stuff in an overlay fixed on the bottom of the page.
+ *
+ * Usage:
+ *
+ * var hovzer = $.getFootHovzer();
+ * hovzer.$.append( $myCollection );
+ * hovzer.update();
+ *
+ * @static
+ * @inheritable
+ * @return {jQuery.footHovzer}
+ */
+ $.getFootHovzer = function () {
+ footHovzer.$ = getHovzer();
+ return footHovzer;
+ };
+
+ /**
+ * @private
+ * @class jQuery.footHovzer
+ */
+ footHovzer = {
+
+ /**
+ * @property {jQuery} $ The stack container
+ */
+
+ /**
+ * Update dimensions of stack to account for changes in the subtree.
+ */
+ update: function () {
+ var $body;
+
+ $body = $( 'body' );
+ if ( prevHeight === undefined ) {
+ prevHeight = getHovzer().outerHeight( /* includeMargin = */ true );
+ $body.css( 'paddingBottom', '+=' + prevHeight + 'px' );
+ } else {
+ newHeight = getHovzer().outerHeight( true );
+ $body.css( 'paddingBottom', ( parseFloat( $body.css( 'paddingBottom' ) ) - prevHeight ) + newHeight );
+
+ prevHeight = newHeight;
+ }
+ }
+ };
+
+ /**
+ * @class jQuery
+ * @mixins jQuery.plugin.footHovzer
+ */
+
+}( jQuery ) );
diff --git a/resources/src/jquery/jquery.getAttrs.js b/resources/src/jquery/jquery.getAttrs.js
new file mode 100644
index 00000000..c44831c4
--- /dev/null
+++ b/resources/src/jquery/jquery.getAttrs.js
@@ -0,0 +1,42 @@
+/**
+ * @class jQuery.plugin.getAttrs
+ */
+
+/**
+ * Get the attributes of an element directy as a plain object.
+ *
+ * If there are more elements in the collection, like most jQuery get/read methods,
+ * this method will use the first element in the collection.
+ *
+ * In IE6, the `attributes` map of a node includes *all* allowed attributes
+ * for an element (including those not set). Those will have values like
+ * `undefined`, `null`, `0`, `false`, `""` or `"inherit"`.
+ *
+ * However there may be attributes genuinely set to one of those values, and there
+ * is no way to distinguish between attributes set to that and those not set and
+ * it being the default. If you need them, set `all` to `true`. They are filtered out
+ * by default.
+ *
+ * @param {boolean} [all=false]
+ * @return {Object}
+ */
+jQuery.fn.getAttrs = function ( all ) {
+ var map = this[0].attributes,
+ attrs = {},
+ len = map.length,
+ i, v;
+
+ for ( i = 0; i < len; i++ ) {
+ v = map[i].nodeValue;
+ if ( all || ( v && v !== 'inherit' ) ) {
+ attrs[ map[i].nodeName ] = v;
+ }
+ }
+
+ return attrs;
+};
+
+/**
+ * @class jQuery
+ * @mixins jQuery.plugin.getAttrs
+ */
diff --git a/resources/src/jquery/jquery.hidpi.js b/resources/src/jquery/jquery.hidpi.js
new file mode 100644
index 00000000..4ecfeb88
--- /dev/null
+++ b/resources/src/jquery/jquery.hidpi.js
@@ -0,0 +1,129 @@
+/**
+ * Responsive images based on `srcset` and `window.devicePixelRatio` emulation where needed.
+ *
+ * Call `.hidpi()` on a document or part of a document to proces image srcsets within that section.
+ *
+ * `$.devicePixelRatio()` can be used as a substitute for `window.devicePixelRatio`.
+ * It provides a familiar interface to retrieve the pixel ratio for browsers that don't
+ * implement `window.devicePixelRatio` but do have a different way of getting it.
+ *
+ * @class jQuery.plugin.hidpi
+ */
+( function ( $ ) {
+
+/**
+ * Get reported or approximate device pixel ratio.
+ *
+ * - 1.0 means 1 CSS pixel is 1 hardware pixel
+ * - 2.0 means 1 CSS pixel is 2 hardware pixels
+ * - etc.
+ *
+ * Uses `window.devicePixelRatio` if available, or CSS media queries on IE.
+ *
+ * @static
+ * @inheritable
+ * @return {number} Device pixel ratio
+ */
+$.devicePixelRatio = function () {
+ if ( window.devicePixelRatio !== undefined ) {
+ // Most web browsers:
+ // * WebKit (Safari, Chrome, Android browser, etc)
+ // * Opera
+ // * Firefox 18+
+ return window.devicePixelRatio;
+ } else if ( window.msMatchMedia !== undefined ) {
+ // Windows 8 desktops / tablets, probably Windows Phone 8
+ //
+ // IE 10 doesn't report pixel ratio directly, but we can get the
+ // screen DPI and divide by 96. We'll bracket to [1, 1.5, 2.0] for
+ // simplicity, but you may get different values depending on zoom
+ // factor, size of screen and orientation in Metro IE.
+ if ( window.msMatchMedia( '(min-resolution: 192dpi)' ).matches ) {
+ return 2;
+ } else if ( window.msMatchMedia( '(min-resolution: 144dpi)' ).matches ) {
+ return 1.5;
+ } else {
+ return 1;
+ }
+ } else {
+ // Legacy browsers...
+ // Assume 1 if unknown.
+ return 1;
+ }
+};
+
+/**
+ * Implement responsive images based on srcset attributes, if browser has no
+ * native srcset support.
+ *
+ * @return {jQuery} This selection
+ * @chainable
+ */
+$.fn.hidpi = function () {
+ var $target = this,
+ // @todo add support for dpi media query checks on Firefox, IE
+ devicePixelRatio = $.devicePixelRatio(),
+ testImage = new Image();
+
+ if ( devicePixelRatio > 1 && testImage.srcset === undefined ) {
+ // No native srcset support.
+ $target.find( 'img' ).each( function () {
+ var $img = $( this ),
+ srcset = $img.attr( 'srcset' ),
+ match;
+ if ( typeof srcset === 'string' && srcset !== '' ) {
+ match = $.matchSrcSet( devicePixelRatio, srcset );
+ if (match !== null ) {
+ $img.attr( 'src', match );
+ }
+ }
+ });
+ }
+
+ return $target;
+};
+
+/**
+ * Match a srcset entry for the given device pixel ratio
+ *
+ * Exposed for testing.
+ *
+ * @private
+ * @static
+ * @param {number} devicePixelRatio
+ * @param {string} srcset
+ * @return {Mixed} null or the matching src string
+ */
+$.matchSrcSet = function ( devicePixelRatio, srcset ) {
+ var candidates,
+ candidate,
+ bits,
+ src,
+ i,
+ ratioStr,
+ ratio,
+ selectedRatio = 1,
+ selectedSrc = null;
+ candidates = srcset.split( / *, */ );
+ for ( i = 0; i < candidates.length; i++ ) {
+ candidate = candidates[i];
+ bits = candidate.split( / +/ );
+ src = bits[0];
+ if ( bits.length > 1 && bits[1].charAt( bits[1].length - 1 ) === 'x' ) {
+ ratioStr = bits[1].slice( 0, -1 );
+ ratio = parseFloat( ratioStr );
+ if ( ratio <= devicePixelRatio && ratio > selectedRatio ) {
+ selectedRatio = ratio;
+ selectedSrc = src;
+ }
+ }
+ }
+ return selectedSrc;
+};
+
+/**
+ * @class jQuery
+ * @mixins jQuery.plugin.hidpi
+ */
+
+}( jQuery ) );
diff --git a/resources/src/jquery/jquery.highlightText.js b/resources/src/jquery/jquery.highlightText.js
new file mode 100644
index 00000000..13382182
--- /dev/null
+++ b/resources/src/jquery/jquery.highlightText.js
@@ -0,0 +1,73 @@
+/**
+ * Plugin that highlights matched word partials in a given element.
+ * TODO: Add a function for restoring the previous text.
+ * TODO: Accept mappings for converting shortcuts like WP: to Wikipedia:.
+ */
+( function ( $ ) {
+
+ $.highlightText = {
+
+ // Split our pattern string at spaces and run our highlight function on the results
+ splitAndHighlight: function ( node, pat ) {
+ var i,
+ patArray = pat.split( ' ' );
+ for ( i = 0; i < patArray.length; i++ ) {
+ if ( patArray[i].length === 0 ) {
+ continue;
+ }
+ $.highlightText.innerHighlight( node, patArray[i] );
+ }
+ return node;
+ },
+
+ // scans a node looking for the pattern and wraps a span around each match
+ innerHighlight: function ( node, pat ) {
+ var i, match, pos, spannode, middlebit, middleclone;
+ // if this is a text node
+ if ( node.nodeType === 3 ) {
+ // TODO - need to be smarter about the character matching here.
+ // non latin characters can make regex think a new word has begun: do not use \b
+ // http://stackoverflow.com/questions/3787072/regex-wordwrap-with-utf8-characters-in-js
+ // look for an occurrence of our pattern and store the starting position
+ match = node.data.match( new RegExp( '(^|\\s)' + $.escapeRE( pat ), 'i' ) );
+ if ( match ) {
+ pos = match.index + match[1].length; // include length of any matched spaces
+ // create the span wrapper for the matched text
+ spannode = document.createElement( 'span' );
+ spannode.className = 'highlight';
+ // shave off the characters preceding the matched text
+ middlebit = node.splitText( pos );
+ // shave off any unmatched text off the end
+ middlebit.splitText( pat.length );
+ // clone for appending to our span
+ middleclone = middlebit.cloneNode( true );
+ // append the matched text node to the span
+ spannode.appendChild( middleclone );
+ // replace the matched node, with our span-wrapped clone of the matched node
+ middlebit.parentNode.replaceChild( spannode, middlebit );
+ }
+ // if this is an element with childnodes, and not a script, style or an element we created
+ } else if ( node.nodeType === 1
+ && node.childNodes
+ && !/(script|style)/i.test( node.tagName )
+ && !( node.tagName.toLowerCase() === 'span'
+ && node.className.match( /\bhighlight/ )
+ )
+ ) {
+ for ( i = 0; i < node.childNodes.length; ++i ) {
+ // call the highlight function for each child node
+ $.highlightText.innerHighlight( node.childNodes[i], pat );
+ }
+ }
+ }
+ };
+
+ $.fn.highlightText = function ( matchString ) {
+ return this.each( function () {
+ var $el = $( this );
+ $el.data( 'highlightText', { originalText: $el.text() } );
+ $.highlightText.splitAndHighlight( this, matchString );
+ } );
+ };
+
+}( jQuery ) );
diff --git a/resources/src/jquery/jquery.localize.js b/resources/src/jquery/jquery.localize.js
new file mode 100644
index 00000000..0b423545
--- /dev/null
+++ b/resources/src/jquery/jquery.localize.js
@@ -0,0 +1,170 @@
+/**
+ * @class jQuery.plugin.localize
+ */
+( function ( $, mw ) {
+
+/**
+ * Gets a localized message, using parameters from options if present.
+ * @ignore
+ *
+ * @param {Object} options
+ * @param {string} key
+ * @return {string} Localized message
+ */
+function msg( options, key ) {
+ var args = options.params[key] || [];
+ // Format: mw.msg( key [, p1, p2, ...] )
+ args.unshift( options.prefix + ( options.keys[key] || key ) );
+ return mw.msg.apply( mw, args );
+}
+
+/**
+ * Localizes a DOM selection by replacing <html:msg /> elements with localized text and adding
+ * localized title and alt attributes to elements with title-msg and alt-msg attributes
+ * respectively.
+ *
+ * Call on a selection of HTML which contains `<html:msg key="message-key" />` elements or elements
+ * with title-msg="message-key", alt-msg="message-key" or placeholder-msg="message-key" attributes.
+ * `<html:msg />` elements will be replaced with localized text, *-msg attributes will be replaced
+ * with attributes that do not have the "-msg" suffix and contain a localized message.
+ *
+ * Example:
+ * // Messages: { 'title': 'Awesome', 'desc': 'Cat doing backflip' 'search' contains 'Search' }
+ * var html = '\
+ * <p>\
+ * <html:msg key="title" />\
+ * <img src="something.jpg" title-msg="title" alt-msg="desc" />\
+ * <input type="text" placeholder-msg="search" />\
+ * </p>';
+ * $( 'body' ).append( $( html ).localize() );
+ *
+ * Appends something like this to the body...
+ * <p>
+ * Awesome
+ * <img src="something.jpg" title="Awesome" alt="Cat doing backflip" />
+ * <input type="text" placeholder="Search" />
+ * </p>
+ *
+ * Arguments can be passed into uses of a message using the params property of the options object
+ * given to .localize(). Multiple messages can be given parameters, because the params property is
+ * an object keyed by the message key to apply the parameters to, each containing an array of
+ * parameters to use. The limitation is that you can not use different parameters to individual uses
+ * of a message in the same selection being localized - they will all recieve the same parameters.
+ *
+ * Example:
+ * // Messages: { 'easy-as': 'Easy as $1 $2 $3.' }
+ * var html = '<p><html:msg key="easy-as" /></p>';
+ * $( 'body' ).append( $( html ).localize( { 'params': { 'easy-as': ['a', 'b', 'c'] } } ) );
+ *
+ * Appends something like this to the body...
+ * <p>Easy as a, b, c</p>
+ *
+ * Raw HTML content can be used, instead of it being escaped as text. To do this, just use the raw
+ * attribute on a msg element.
+ *
+ * Example:
+ * // Messages: { 'hello': '<b><i>Hello</i> $1!</b>' }
+ * var html = '\
+ * <p>\
+ * <!-- escaped: --><html:msg key="hello" />\
+ * <!-- raw: --><html:msg key="hello" raw />\
+ * </p>';
+ * $( 'body' ).append( $( html ).localize( { 'params': { 'hello': ['world'] } } ) );
+ *
+ * Appends something like this to the body...
+ * <p>
+ * <!-- escaped: -->&lt;b&gt;&lt;i&gt;Hello&lt;/i&gt; world!&lt;/b&gt;
+ * <!-- raw: --><b><i>Hello</i> world!</b>
+ * </p>
+ *
+ * Message keys can also be remapped, allowing the same generic template to be used with a variety
+ * of messages. This is important for improving re-usability of templates.
+ *
+ * Example:
+ * // Messages: { 'good-afternoon': 'Good afternoon' }
+ * var html = '<p><html:msg key="greeting" /></p>';
+ * $( 'body' ).append( $( html ).localize( { 'keys': { 'greeting': 'good-afternoon' } } ) );
+ *
+ * Appends something like this to the body...
+ * <p>Good afternoon</p>
+ *
+ * Message keys can also be prefixed globally, which is handy when writing extensions, where by
+ * convention all messages are prefixed with the extension's name.
+ *
+ * Example:
+ * // Messages: { 'teleportation-warning': 'You may not get there all in one piece.' }
+ * var html = '<p><html:msg key="warning" /></p>';
+ * $( 'body' ).append( $( html ).localize( { 'prefix': 'teleportation-' } ) );
+ *
+ * Appends something like this to the body...
+ * <p>You may not get there all in one piece.</p>
+ *
+ * @param {Object} options Map of options to be used while localizing
+ * @param {string} options.prefix String to prepend to all message keys
+ * @param {Object} options.keys Message key aliases, used for remapping keys to a template
+ * @param {Object} options.params Lists of parameters to use with certain message keys
+ * @return {jQuery}
+ * @chainable
+ */
+$.fn.localize = function ( options ) {
+ var $target = this,
+ attributes = ['title', 'alt', 'placeholder'];
+
+ // Extend options
+ options = $.extend( {
+ prefix: '',
+ keys: {},
+ params: {}
+ }, options );
+
+ // Elements
+ // Ok, so here's the story on this selector. In IE 6/7, searching for 'msg' turns up the
+ // 'html:msg', but searching for 'html:msg' doesn't. In later IE and other browsers, searching
+ // for 'html:msg' turns up the 'html:msg', but searching for 'msg' doesn't. So searching for
+ // both 'msg' and 'html:msg' seems to get the job done. This feels pretty icky, though.
+ $target.find( 'msg,html\\:msg' ).each( function () {
+ var $el = $( this );
+ // Escape by default
+ if ( $el.attr( 'raw' ) ) {
+ $el.html( msg( options, $el.attr( 'key' ) ) );
+ } else {
+ $el.text( msg( options, $el.attr( 'key' ) ) );
+ }
+ // Remove wrapper
+ $el.replaceWith( $el.html() );
+ } );
+
+ // Attributes
+ // Note: there's no way to prevent escaping of values being injected into attributes, this is
+ // on purpose, not a design flaw.
+ $.each( attributes, function ( i, attr ) {
+ var msgAttr = attr + '-msg';
+ $target.find( '[' + msgAttr + ']' ).each( function () {
+ var $el = $( this );
+ $el.attr( attr, msg( options, $el.attr( msgAttr ) ) ).removeAttr( msgAttr );
+ } );
+ } );
+
+ // HTML, Text for elements which cannot have children e.g. OPTION
+ $target.find( '[data-msg-text]' ).each( function () {
+ var $el = $( this );
+ $el.text( msg( options, $el.attr( 'data-msg-text' ) ) );
+ } );
+
+ $target.find( '[data-msg-html]' ).each( function () {
+ var $el = $( this );
+ $el.html( msg( options, $el.attr( 'data-msg-html' ) ) );
+ } );
+
+ return $target;
+};
+
+// Let IE know about the msg tag before it's used...
+document.createElement( 'msg' );
+
+/**
+ * @class jQuery
+ * @mixins jQuery.plugin.localize
+ */
+
+}( jQuery, mediaWiki ) );
diff --git a/resources/src/jquery/jquery.makeCollapsible.css b/resources/src/jquery/jquery.makeCollapsible.css
new file mode 100644
index 00000000..0f471509
--- /dev/null
+++ b/resources/src/jquery/jquery.makeCollapsible.css
@@ -0,0 +1,27 @@
+/* See also jquery.makeCollapsible.js */
+.mw-collapsible-toggle {
+ float: right;
+ -moz-user-select: none;
+ -webkit-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+}
+.mw-customtoggle,
+.mw-collapsible-toggle {
+ cursor: pointer;
+}
+
+/* collapse links in captions should be inline */
+caption .mw-collapsible-toggle {
+ float: none;
+}
+
+/* list-items go as wide as their parent element, don't float them inside list items */
+li .mw-collapsible-toggle {
+ float: none;
+}
+
+/* the added list item should have no list-style */
+.mw-collapsible-toggle-li {
+ list-style: none;
+}
diff --git a/resources/src/jquery/jquery.makeCollapsible.js b/resources/src/jquery/jquery.makeCollapsible.js
new file mode 100644
index 00000000..c4e25203
--- /dev/null
+++ b/resources/src/jquery/jquery.makeCollapsible.js
@@ -0,0 +1,404 @@
+/**
+ * jQuery makeCollapsible
+ *
+ * Dual licensed:
+ * - CC BY 3.0 <http://creativecommons.org/licenses/by/3.0>
+ * - GPL2 <http://www.gnu.org/licenses/old-licenses/gpl-2.0.html>
+ *
+ * @class jQuery.plugin.makeCollapsible
+ */
+( function ( $, mw ) {
+
+ /**
+ * Handler for a click on a collapsible toggler.
+ *
+ * @private
+ * @param {jQuery} $collapsible
+ * @param {string} action The action this function will take ('expand' or 'collapse').
+ * @param {jQuery|null} [$defaultToggle]
+ * @param {Object|undefined} [options]
+ */
+ function toggleElement( $collapsible, action, $defaultToggle, options ) {
+ var $collapsibleContent, $containers, hookCallback;
+ options = options || {};
+
+ // Validate parameters
+
+ // $collapsible must be an instance of jQuery
+ if ( !$collapsible.jquery ) {
+ return;
+ }
+ if ( action !== 'expand' && action !== 'collapse' ) {
+ // action must be string with 'expand' or 'collapse'
+ return;
+ }
+ if ( $defaultToggle === undefined ) {
+ $defaultToggle = null;
+ }
+ if ( $defaultToggle !== null && !$defaultToggle.jquery ) {
+ // is optional (may be undefined), but if defined it must be an instance of jQuery.
+ // If it's not, abort right away.
+ // After this $defaultToggle is either null or a valid jQuery instance.
+ return;
+ }
+
+ // Trigger a custom event to allow callers to hook to the collapsing/expanding,
+ // allowing the module to be testable, and making it possible to
+ // e.g. implement persistence via cookies
+ $collapsible.trigger( action === 'expand' ? 'beforeExpand.mw-collapsible' : 'beforeCollapse.mw-collapsible' );
+ hookCallback = function () {
+ $collapsible.trigger( action === 'expand' ? 'afterExpand.mw-collapsible' : 'afterCollapse.mw-collapsible' );
+ };
+
+ // Handle different kinds of elements
+
+ if ( !options.plainMode && $collapsible.is( 'table' ) ) {
+ // Tables
+ // If there is a caption, hide all rows; otherwise, only hide body rows
+ if ( $collapsible.find( '> caption' ).length ) {
+ $containers = $collapsible.find( '> * > tr' );
+ } else {
+ $containers = $collapsible.find( '> tbody > tr' );
+ }
+ if ( $defaultToggle ) {
+ // Exclude table row containing togglelink
+ $containers = $containers.not( $defaultToggle.closest( 'tr' ) );
+ }
+
+ if ( action === 'collapse' ) {
+ // Hide all table rows of this table
+ // Slide doesn't work with tables, but fade does as of jQuery 1.1.3
+ // http://stackoverflow.com/questions/467336#920480
+ if ( options.instantHide ) {
+ $containers.hide();
+ hookCallback();
+ } else {
+ $containers.stop( true, true ).fadeOut().promise().done( hookCallback );
+ }
+ } else {
+ $containers.stop( true, true ).fadeIn().promise().done( hookCallback );
+ }
+
+ } else if ( !options.plainMode && ( $collapsible.is( 'ul' ) || $collapsible.is( 'ol' ) ) ) {
+ // Lists
+ $containers = $collapsible.find( '> li' );
+ if ( $defaultToggle ) {
+ // Exclude list-item containing togglelink
+ $containers = $containers.not( $defaultToggle.parent() );
+ }
+
+ if ( action === 'collapse' ) {
+ if ( options.instantHide ) {
+ $containers.hide();
+ hookCallback();
+ } else {
+ $containers.stop( true, true ).slideUp().promise().done( hookCallback );
+ }
+ } else {
+ $containers.stop( true, true ).slideDown().promise().done( hookCallback );
+ }
+
+ } else {
+ // Everything else: <div>, <p> etc.
+ $collapsibleContent = $collapsible.find( '> .mw-collapsible-content' );
+
+ // If a collapsible-content is defined, act on it
+ if ( !options.plainMode && $collapsibleContent.length ) {
+ if ( action === 'collapse' ) {
+ if ( options.instantHide ) {
+ $collapsibleContent.hide();
+ hookCallback();
+ } else {
+ $collapsibleContent.slideUp().promise().done( hookCallback );
+ }
+ } else {
+ $collapsibleContent.slideDown().promise().done( hookCallback );
+ }
+
+ // Otherwise assume this is a customcollapse with a remote toggle
+ // .. and there is no collapsible-content because the entire element should be toggled
+ } else {
+ if ( action === 'collapse' ) {
+ if ( options.instantHide ) {
+ $collapsible.hide();
+ hookCallback();
+ } else {
+ if ( $collapsible.is( 'tr' ) || $collapsible.is( 'td' ) || $collapsible.is( 'th' ) ) {
+ $collapsible.fadeOut().promise().done( hookCallback );
+ } else {
+ $collapsible.slideUp().promise().done( hookCallback );
+ }
+ }
+ } else {
+ if ( $collapsible.is( 'tr' ) || $collapsible.is( 'td' ) || $collapsible.is( 'th' ) ) {
+ $collapsible.fadeIn().promise().done( hookCallback );
+ } else {
+ $collapsible.slideDown().promise().done( hookCallback );
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Handle clicking/keypressing on the collapsible element toggle and other
+ * situations where a collapsible element is toggled (e.g. the initial
+ * toggle for collapsed ones).
+ *
+ * @private
+ * @param {jQuery} $toggle the clickable toggle itself
+ * @param {jQuery} $collapsible the collapsible element
+ * @param {jQuery.Event|null} e either the event or null if unavailable
+ * @param {Object|undefined} options
+ */
+ function togglingHandler( $toggle, $collapsible, e, options ) {
+ var wasCollapsed, $textContainer, collapseText, expandText;
+
+ if ( options === undefined ) {
+ options = {};
+ }
+
+ if ( e ) {
+ if ( e.type === 'click' && options.linksPassthru && $.nodeName( e.target, 'a' ) ) {
+ // Don't fire if a link was clicked, if requested (for premade togglers by default)
+ return;
+ } else if ( e.type === 'keypress' && e.which !== 13 && e.which !== 32 ) {
+ // Only handle keypresses on the "Enter" or "Space" keys
+ return;
+ } else {
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ }
+
+ // This allows the element to be hidden on initial toggle without fiddling with the class
+ if ( options.wasCollapsed !== undefined ) {
+ wasCollapsed = options.wasCollapsed;
+ } else {
+ wasCollapsed = $collapsible.hasClass( 'mw-collapsed' );
+ }
+
+ // Toggle the state of the collapsible element (that is, expand or collapse)
+ $collapsible.toggleClass( 'mw-collapsed', !wasCollapsed );
+
+ // Toggle the mw-collapsible-toggle classes, if requested (for default and premade togglers by default)
+ if ( options.toggleClasses ) {
+ $toggle
+ .toggleClass( 'mw-collapsible-toggle-collapsed', !wasCollapsed )
+ .toggleClass( 'mw-collapsible-toggle-expanded', wasCollapsed );
+ }
+
+ // Toggle the text ("Show"/"Hide"), if requested (for default togglers by default)
+ if ( options.toggleText ) {
+ collapseText = options.toggleText.collapseText;
+ expandText = options.toggleText.expandText;
+
+ $textContainer = $toggle.find( '> a' );
+ if ( !$textContainer.length ) {
+ $textContainer = $toggle;
+ }
+ $textContainer.text( wasCollapsed ? collapseText : expandText );
+ }
+
+ // And finally toggle the element state itself
+ toggleElement( $collapsible, wasCollapsed ? 'expand' : 'collapse', $toggle, options );
+ }
+
+ /**
+ * Enable collapsible-functionality on all elements in the collection.
+ *
+ * - Will prevent binding twice to the same element.
+ * - Initial state is expanded by default, this can be overriden by adding class
+ * "mw-collapsed" to the "mw-collapsible" element.
+ * - Elements made collapsible have jQuery data "mw-made-collapsible" set to true.
+ * - The inner content is wrapped in a "div.mw-collapsible-content" (except for tables and lists).
+ *
+ * @param {Object} [options]
+ * @param {string} [options.collapseText] Text used for the toggler, when clicking it would
+ * collapse the element. Default: the 'data-collapsetext' attribute of the
+ * collapsible element or the content of 'collapsible-collapse' message.
+ * @param {string} [options.expandText] Text used for the toggler, when clicking it would
+ * expand the element. Default: the 'data-expandtext' attribute of the
+ * collapsible element or the content of 'collapsible-expand' message.
+ * @param {boolean} [options.collapsed] Whether to collapse immediately. By default
+ * collapse only if the elements has the 'mw-collapsible' class.
+ * @param {jQuery} [options.$customTogglers] Elements to be used as togglers
+ * for this collapsible element. By default, if the collapsible element
+ * has an id attribute like 'mw-customcollapsible-XXX', elements with a
+ * *class* of 'mw-customtoggle-XXX' are made togglers for it.
+ * @param {boolean} [options.plainMode=false] Whether to use a "plain mode" when making the
+ * element collapsible - that is, hide entire tables and lists (instead
+ * of hiding only all rows but first of tables, and hiding each list
+ * item separately for lists) and don't wrap other elements in
+ * div.mw-collapsible-content. May only be used with custom togglers.
+ * @return {jQuery}
+ * @chainable
+ */
+ $.fn.makeCollapsible = function ( options ) {
+ if ( options === undefined ) {
+ options = {};
+ }
+
+ return this.each( function () {
+ var $collapsible, collapseText, expandText, $caption, $toggle, actionHandler, buildDefaultToggleLink,
+ premadeToggleHandler, $toggleLink, $firstItem, collapsibleId, $customTogglers, firstval;
+
+ // Ensure class "mw-collapsible" is present in case .makeCollapsible()
+ // is called on element(s) that don't have it yet.
+ $collapsible = $( this ).addClass( 'mw-collapsible' );
+
+ // Return if it has been enabled already.
+ if ( $collapsible.data( 'mw-made-collapsible' ) ) {
+ return;
+ } else {
+ $collapsible.data( 'mw-made-collapsible', true );
+ }
+
+ // Use custom text or default?
+ collapseText = options.collapseText || $collapsible.attr( 'data-collapsetext' ) || mw.msg( 'collapsible-collapse' );
+ expandText = options.expandText || $collapsible.attr( 'data-expandtext' ) || mw.msg( 'collapsible-expand' );
+
+ // Default click/keypress handler and toggle link to use when none is present
+ actionHandler = function ( e, opts ) {
+ var defaultOpts = {
+ toggleClasses: true,
+ toggleText: { collapseText: collapseText, expandText: expandText }
+ };
+ opts = $.extend( defaultOpts, options, opts );
+ togglingHandler( $( this ), $collapsible, e, opts );
+ };
+ // Default toggle link. Only build it when needed to avoid jQuery memory leaks (event data).
+ buildDefaultToggleLink = function () {
+ return $( '<a href="#"></a>' )
+ .text( collapseText )
+ .wrap( '<span class="mw-collapsible-toggle"></span>' )
+ .parent()
+ .prepend( '<span class="mw-collapsible-bracket">[</span>' )
+ .append( '<span class="mw-collapsible-bracket">]</span>' )
+ .on( 'click.mw-collapsible keypress.mw-collapsible', actionHandler );
+ };
+
+ // Default handler for clicking on premade toggles
+ premadeToggleHandler = function ( e, opts ) {
+ var defaultOpts = { toggleClasses: true, linksPassthru: true };
+ opts = $.extend( defaultOpts, options, opts );
+ togglingHandler( $( this ), $collapsible, e, opts );
+ };
+
+ // Check if this element has a custom position for the toggle link
+ // (ie. outside the container or deeper inside the tree)
+ if ( options.$customTogglers ) {
+ $customTogglers = $( options.$customTogglers );
+ } else {
+ collapsibleId = $collapsible.attr( 'id' ) || '';
+ if ( collapsibleId.indexOf( 'mw-customcollapsible-' ) === 0 ) {
+ $customTogglers = $( '.' + collapsibleId.replace( 'mw-customcollapsible', 'mw-customtoggle' ) )
+ .addClass( 'mw-customtoggle' );
+ }
+ }
+
+ // Add event handlers to custom togglers or create our own ones
+ if ( $customTogglers && $customTogglers.length ) {
+ actionHandler = function ( e, opts ) {
+ var defaultOpts = {};
+ opts = $.extend( defaultOpts, options, opts );
+ togglingHandler( $( this ), $collapsible, e, opts );
+ };
+
+ $toggleLink = $customTogglers
+ .on( 'click.mw-collapsible keypress.mw-collapsible', actionHandler )
+ .prop( 'tabIndex', 0 );
+
+ } else {
+ // If this is not a custom case, do the default: wrap the
+ // contents and add the toggle link. Different elements are
+ // treated differently.
+ if ( $collapsible.is( 'table' ) ) {
+
+ // If the table has a caption, collapse to the caption
+ // as opposed to the first row
+ $caption = $collapsible.find( '> caption' );
+ if ( $caption.length ) {
+ $toggle = $caption.find( '> .mw-collapsible-toggle' );
+
+ // If there is no toggle link, add it to the end of the caption
+ if ( !$toggle.length ) {
+ $toggleLink = buildDefaultToggleLink().appendTo( $caption );
+ } else {
+ actionHandler = premadeToggleHandler;
+ $toggleLink = $toggle.on( 'click.mw-collapsible keypress.mw-collapsible', actionHandler )
+ .prop( 'tabIndex', 0 );
+ }
+ } else {
+ // The toggle-link will be in one the the cells (td or th) of the first row
+ $firstItem = $collapsible.find( 'tr:first th, tr:first td' );
+ $toggle = $firstItem.find( '> .mw-collapsible-toggle' );
+
+ // If theres no toggle link, add it to the last cell
+ if ( !$toggle.length ) {
+ $toggleLink = buildDefaultToggleLink().prependTo( $firstItem.eq( -1 ) );
+ } else {
+ actionHandler = premadeToggleHandler;
+ $toggleLink = $toggle.on( 'click.mw-collapsible keypress.mw-collapsible', actionHandler )
+ .prop( 'tabIndex', 0 );
+ }
+ }
+
+ } else if ( $collapsible.is( 'ul' ) || $collapsible.is( 'ol' ) ) {
+ // The toggle-link will be in the first list-item
+ $firstItem = $collapsible.find( 'li:first' );
+ $toggle = $firstItem.find( '> .mw-collapsible-toggle' );
+
+ // If theres no toggle link, add it
+ if ( !$toggle.length ) {
+ // Make sure the numeral order doesn't get messed up, force the first (soon to be second) item
+ // to be "1". Except if the value-attribute is already used.
+ // If no value was set WebKit returns "", Mozilla returns '-1', others return 0, null or undefined.
+ firstval = $firstItem.prop( 'value' );
+ if ( firstval === undefined || !firstval || firstval === '-1' || firstval === -1 ) {
+ $firstItem.prop( 'value', '1' );
+ }
+ $toggleLink = buildDefaultToggleLink();
+ $toggleLink.wrap( '<li class="mw-collapsible-toggle-li"></li>' ).parent().prependTo( $collapsible );
+ } else {
+ actionHandler = premadeToggleHandler;
+ $toggleLink = $toggle.on( 'click.mw-collapsible keypress.mw-collapsible', actionHandler )
+ .prop( 'tabIndex', 0 );
+ }
+
+ } else { // <div>, <p> etc.
+
+ // The toggle-link will be the first child of the element
+ $toggle = $collapsible.find( '> .mw-collapsible-toggle' );
+
+ // If a direct child .content-wrapper does not exists, create it
+ if ( !$collapsible.find( '> .mw-collapsible-content' ).length ) {
+ $collapsible.wrapInner( '<div class="mw-collapsible-content"></div>' );
+ }
+
+ // If theres no toggle link, add it
+ if ( !$toggle.length ) {
+ $toggleLink = buildDefaultToggleLink().prependTo( $collapsible );
+ } else {
+ actionHandler = premadeToggleHandler;
+ $toggleLink = $toggle.on( 'click.mw-collapsible keypress.mw-collapsible', actionHandler )
+ .prop( 'tabIndex', 0 );
+ }
+ }
+ }
+
+ // Initial state
+ if ( options.collapsed || $collapsible.hasClass( 'mw-collapsed' ) ) {
+ // One toggler can hook to multiple elements, and one element can have
+ // multiple togglers. This is the sanest way to handle that.
+ actionHandler.call( $toggleLink.get( 0 ), null, { instantHide: true, wasCollapsed: false } );
+ }
+ } );
+ };
+
+ /**
+ * @class jQuery
+ * @mixins jQuery.plugin.makeCollapsible
+ */
+
+}( jQuery, mediaWiki ) );
diff --git a/resources/src/jquery/jquery.mw-jump.js b/resources/src/jquery/jquery.mw-jump.js
new file mode 100644
index 00000000..5eae0bec
--- /dev/null
+++ b/resources/src/jquery/jquery.mw-jump.js
@@ -0,0 +1,15 @@
+/**
+ * JavaScript to show jump links to motor-impaired users when they are focused.
+ */
+jQuery( function ( $ ) {
+
+ $( '.mw-jump' ).on( 'focus blur', 'a', function ( e ) {
+ // Confusingly jQuery leaves e.type as focusout for delegated blur events
+ if ( e.type === 'blur' || e.type === 'focusout' ) {
+ $( this ).closest( '.mw-jump' ).css( { height: 0 } );
+ } else {
+ $( this ).closest( '.mw-jump' ).css( { height: 'auto' } );
+ }
+ } );
+
+} );
diff --git a/resources/src/jquery/jquery.mwExtension.js b/resources/src/jquery/jquery.mwExtension.js
new file mode 100644
index 00000000..dc7aaa45
--- /dev/null
+++ b/resources/src/jquery/jquery.mwExtension.js
@@ -0,0 +1,122 @@
+/*
+ * JavaScript backwards-compatibility alternatives and other convenience functions
+ */
+( function ( $ ) {
+
+ $.extend( {
+ trimLeft: function ( str ) {
+ return str === null ? '' : str.toString().replace( /^\s+/, '' );
+ },
+ trimRight: function ( str ) {
+ return str === null ?
+ '' : str.toString().replace( /\s+$/, '' );
+ },
+ ucFirst: function ( str ) {
+ return str.charAt( 0 ).toUpperCase() + str.slice( 1 );
+ },
+ escapeRE: function ( str ) {
+ return str.replace ( /([\\{}()|.?*+\-\^$\[\]])/g, '\\$1' );
+ },
+ isDomElement: function ( el ) {
+ return !!el && !!el.nodeType;
+ },
+ isEmpty: function ( v ) {
+ var key;
+ if ( v === '' || v === 0 || v === '0' || v === null
+ || v === false || v === undefined )
+ {
+ return true;
+ }
+ // the for-loop could potentially contain prototypes
+ // to avoid that we check it's length first
+ if ( v.length === 0 ) {
+ return true;
+ }
+ if ( typeof v === 'object' ) {
+ for ( key in v ) {
+ return false;
+ }
+ return true;
+ }
+ return false;
+ },
+ compareArray: function ( arrThis, arrAgainst ) {
+ if ( arrThis.length !== arrAgainst.length ) {
+ return false;
+ }
+ for ( var i = 0; i < arrThis.length; i++ ) {
+ if ( $.isArray( arrThis[i] ) ) {
+ if ( !$.compareArray( arrThis[i], arrAgainst[i] ) ) {
+ return false;
+ }
+ } else if ( arrThis[i] !== arrAgainst[i] ) {
+ return false;
+ }
+ }
+ return true;
+ },
+ compareObject: function ( objectA, objectB ) {
+ var prop, type;
+
+ // Do a simple check if the types match
+ if ( typeof objectA === typeof objectB ) {
+
+ // Only loop over the contents if it really is an object
+ if ( typeof objectA === 'object' ) {
+ // If they are aliases of the same object (ie. mw and mediaWiki) return now
+ if ( objectA === objectB ) {
+ return true;
+ } else {
+ // Iterate over each property
+ for ( prop in objectA ) {
+ // Check if this property is also present in the other object
+ if ( prop in objectB ) {
+ // Compare the types of the properties
+ type = typeof objectA[prop];
+ if ( type === typeof objectB[prop] ) {
+ // Recursively check objects inside this one
+ switch ( type ) {
+ case 'object' :
+ if ( !$.compareObject( objectA[prop], objectB[prop] ) ) {
+ return false;
+ }
+ break;
+ case 'function' :
+ // Functions need to be strings to compare them properly
+ if ( objectA[prop].toString() !== objectB[prop].toString() ) {
+ return false;
+ }
+ break;
+ default:
+ // Strings, numbers
+ if ( objectA[prop] !== objectB[prop] ) {
+ return false;
+ }
+ break;
+ }
+ } else {
+ return false;
+ }
+ } else {
+ return false;
+ }
+ }
+ // Check for properties in B but not in A
+ // This is about 15% faster (tested in Safari 5 and Firefox 3.6)
+ // ...than incrementing a count variable in the above and below loops
+ // See also: https://www.mediawiki.org/wiki/ResourceLoader/Default_modules/compareObject_test#Results
+ for ( prop in objectB ) {
+ if ( !( prop in objectA ) ) {
+ return false;
+ }
+ }
+ }
+ }
+ } else {
+ return false;
+ }
+ return true;
+ }
+ } );
+
+}( jQuery ) );
diff --git a/resources/src/jquery/jquery.placeholder.js b/resources/src/jquery/jquery.placeholder.js
new file mode 100644
index 00000000..d4580190
--- /dev/null
+++ b/resources/src/jquery/jquery.placeholder.js
@@ -0,0 +1,229 @@
+/**
+ * HTML5 placeholder emulation for jQuery plugin
+ *
+ * This will automatically use the HTML5 placeholder attribute if supported, or emulate this behavior if not.
+ *
+ * This is a fork from Mathias Bynens' jquery.placeholder as of this commit
+ * https://github.com/mathiasbynens/jquery-placeholder/blob/47f05d400e2dd16b59d144141a2cf54a9a77c502/jquery.placeholder.js
+ *
+ * @author Mathias Bynens <http://mathiasbynens.be/>
+ * @author Trevor Parscal <tparscal@wikimedia.org>, 2012
+ * @author Krinkle <krinklemail@gmail.com>, 2012
+ * @author Alex Ivanov <alexivanov97@gmail.com>, 2013
+ * @version 2.1.0
+ * @license MIT
+ */
+(function ($) {
+
+ var isInputSupported = 'placeholder' in document.createElement('input'),
+ isTextareaSupported = 'placeholder' in document.createElement('textarea'),
+ prototype = $.fn,
+ valHooks = $.valHooks,
+ propHooks = $.propHooks,
+ hooks,
+ placeholder;
+
+ if (isInputSupported && isTextareaSupported) {
+
+ placeholder = prototype.placeholder = function (text) {
+ var hasArgs = arguments.length;
+
+ if (hasArgs) {
+ changePlaceholder.call(this, text);
+ }
+
+ return this;
+ };
+
+ placeholder.input = placeholder.textarea = true;
+
+ } else {
+
+ placeholder = prototype.placeholder = function (text) {
+ var $this = this,
+ hasArgs = arguments.length;
+
+ if (hasArgs) {
+ changePlaceholder.call(this, text);
+ }
+
+ $this
+ .filter((isInputSupported ? 'textarea' : ':input') + '[placeholder]')
+ .filter(function () {
+ return !$(this).data('placeholder-enabled');
+ })
+ .bind({
+ 'focus.placeholder drop.placeholder': clearPlaceholder,
+ 'blur.placeholder': setPlaceholder
+ })
+ .data('placeholder-enabled', true)
+ .trigger('blur.placeholder');
+ return $this;
+ };
+
+ placeholder.input = isInputSupported;
+ placeholder.textarea = isTextareaSupported;
+
+ hooks = {
+ 'get': function (element) {
+ var $element = $(element),
+ $passwordInput = $element.data('placeholder-password');
+ if ($passwordInput) {
+ return $passwordInput[0].value;
+ }
+
+ return $element.data('placeholder-enabled') && $element.hasClass('placeholder') ? '' : element.value;
+ },
+ 'set': function (element, value) {
+ var $element = $(element),
+ $passwordInput = $element.data('placeholder-password');
+ if ($passwordInput) {
+ $passwordInput[0].value = value;
+ return value;
+ }
+
+ if (!$element.data('placeholder-enabled')) {
+ element.value = value;
+ return value;
+ }
+ if (!value) {
+ element.value = value;
+ // Issue #56: Setting the placeholder causes problems if the element continues to have focus.
+ if (element !== safeActiveElement()) {
+ // We can't use `triggerHandler` here because of dummy text/password inputs :(
+ setPlaceholder.call(element);
+ }
+ } else if ($element.hasClass('placeholder')) {
+ if (!clearPlaceholder.call(element, true, value)) {
+ element.value = value;
+ }
+ } else {
+ element.value = value;
+ }
+ // `set` can not return `undefined`; see http://jsapi.info/jquery/1.7.1/val#L2363
+ return $element;
+ }
+ };
+
+ if (!isInputSupported) {
+ valHooks.input = hooks;
+ propHooks.value = hooks;
+ }
+ if (!isTextareaSupported) {
+ valHooks.textarea = hooks;
+ propHooks.value = hooks;
+ }
+
+ $(function () {
+ // Look for forms
+ $(document).delegate('form', 'submit.placeholder', function () {
+ // Clear the placeholder values so they don't get submitted
+ var $inputs = $('.placeholder', this).each(clearPlaceholder);
+ setTimeout(function () {
+ $inputs.each(setPlaceholder);
+ }, 10);
+ });
+ });
+
+ // Clear placeholder values upon page reload
+ $(window).bind('beforeunload.placeholder', function () {
+ $('.placeholder').each(function () {
+ this.value = '';
+ });
+ });
+
+ }
+
+ function args(elem) {
+ // Return an object of element attributes
+ var newAttrs = {},
+ rinlinejQuery = /^jQuery\d+$/;
+ $.each(elem.attributes, function (i, attr) {
+ if (attr.specified && !rinlinejQuery.test(attr.name)) {
+ newAttrs[attr.name] = attr.value;
+ }
+ });
+ return newAttrs;
+ }
+
+ function clearPlaceholder(event, value) {
+ var input = this,
+ $input = $(input);
+ if (input.value === $input.attr('placeholder') && $input.hasClass('placeholder')) {
+ if ($input.data('placeholder-password')) {
+ $input = $input.hide().next().show().attr('id', $input.removeAttr('id').data('placeholder-id'));
+ // If `clearPlaceholder` was called from `$.valHooks.input.set`
+ if (event === true) {
+ $input[0].value = value;
+ return value;
+ }
+ $input.focus();
+ } else {
+ input.value = '';
+ $input.removeClass('placeholder');
+ if (input === safeActiveElement()) {
+ input.select();
+ }
+ }
+ }
+ }
+
+ function setPlaceholder() {
+ var $replacement,
+ input = this,
+ $input = $(input),
+ id = this.id;
+ if (!input.value) {
+ if (input.type === 'password') {
+ if (!$input.data('placeholder-textinput')) {
+ try {
+ $replacement = $input.clone().attr({ 'type': 'text' });
+ } catch (e) {
+ $replacement = $('<input>').attr($.extend(args(this), { 'type': 'text' }));
+ }
+ $replacement
+ .removeAttr('name')
+ .data({
+ 'placeholder-password': $input,
+ 'placeholder-id': id
+ })
+ .bind('focus.placeholder drop.placeholder', clearPlaceholder);
+ $input
+ .data({
+ 'placeholder-textinput': $replacement,
+ 'placeholder-id': id
+ })
+ .before($replacement);
+ }
+ $input = $input.removeAttr('id').hide().prev().attr('id', id).show();
+ // Note: `$input[0] != input` now!
+ }
+ $input.addClass('placeholder');
+ $input[0].value = $input.attr('placeholder');
+ } else {
+ $input.removeClass('placeholder');
+ }
+ }
+
+ function safeActiveElement() {
+ // Avoid IE9 `document.activeElement` of death
+ // https://github.com/mathiasbynens/jquery-placeholder/pull/99
+ try {
+ return document.activeElement;
+ } catch (err) {}
+ }
+
+ function changePlaceholder(text) {
+ var hasArgs = arguments.length,
+ $input = this;
+ if (hasArgs) {
+ if ($input.attr('placeholder') !== text) {
+ $input.prop('placeholder', text);
+ if ($input.hasClass('placeholder')) {
+ $input[0].value = text;
+ }
+ }
+ }
+ }
+
+}(jQuery));
diff --git a/resources/src/jquery/jquery.qunit.completenessTest.js b/resources/src/jquery/jquery.qunit.completenessTest.js
new file mode 100644
index 00000000..8d38401e
--- /dev/null
+++ b/resources/src/jquery/jquery.qunit.completenessTest.js
@@ -0,0 +1,305 @@
+/**
+ * jQuery QUnit CompletenessTest 0.4
+ *
+ * Tests the completeness of test suites for object oriented javascript
+ * libraries. Written to be used in environments with jQuery and QUnit.
+ * Requires jQuery 1.7.2 or higher.
+ *
+ * Built for and tested with:
+ * - Chrome 19
+ * - Firefox 4
+ * - Safari 5
+ *
+ * @author Timo Tijhof, 2011-2012
+ */
+( function ( mw, $ ) {
+ 'use strict';
+
+ var util,
+ hasOwn = Object.prototype.hasOwnProperty,
+ log = (window.console && window.console.log)
+ ? function () { return window.console.log.apply(window.console, arguments); }
+ : function () {};
+
+ // Simplified version of a few jQuery methods, except that they don't
+ // call other jQuery methods. Required to be able to run the CompletenessTest
+ // on jQuery itself as well.
+ util = {
+ keys: Object.keys || function ( object ) {
+ var key, keys = [];
+ for ( key in object ) {
+ if ( hasOwn.call( object, key ) ) {
+ keys.push( key );
+ }
+ }
+ return keys;
+ },
+ each: function ( object, callback ) {
+ var name;
+ for ( name in object ) {
+ if ( callback.call( object[ name ], name, object[ name ] ) === false ) {
+ break;
+ }
+ }
+ },
+ // $.type and $.isEmptyObject are safe as is, they don't call
+ // other $.* methods. Still need to be derefenced into `util`
+ // since the CompletenessTest will overload them with spies.
+ type: $.type,
+ isEmptyObject: $.isEmptyObject
+ };
+
+ /**
+ * CompletenessTest
+ * @constructor
+ *
+ * @example
+ * var myTester = new CompletenessTest( myLib );
+ * @param masterVariable {Object} The root variable that contains all object
+ * members. CompletenessTest will recursively traverse objects and keep track
+ * of all methods.
+ * @param ignoreFn {Function} Optionally pass a function to filter out certain
+ * methods. Example: You may want to filter out instances of jQuery or some
+ * other constructor. Otherwise "missingTests" will include all methods that
+ * were not called from that instance.
+ */
+ function CompletenessTest( masterVariable, ignoreFn ) {
+ var warn,
+ that = this;
+
+ // Keep track in these objects. Keyed by strings with the
+ // method names (ie. 'my.foo', 'my.bar', etc.) values are boolean true.
+ this.injectionTracker = {};
+ this.methodCallTracker = {};
+ this.missingTests = {};
+
+ this.ignoreFn = ignoreFn === undefined ? function () { return false; } : ignoreFn;
+
+ // Lazy limit in case something weird happends (like recurse (part of) ourself).
+ this.lazyLimit = 2000;
+ this.lazyCounter = 0;
+
+ // Bind begin and end to QUnit.
+ QUnit.begin( function () {
+ // Suppress warnings (e.g. deprecation notices for accessing the properties)
+ warn = mw.log.warn;
+ mw.log.warn = $.noop;
+
+ that.walkTheObject( masterVariable, null, masterVariable, [] );
+ log( 'CompletenessTest/walkTheObject', that );
+
+ // Restore warnings
+ mw.log.warn = warn;
+ warn = undefined;
+ });
+
+ QUnit.done( function () {
+ that.populateMissingTests();
+ log( 'CompletenessTest/populateMissingTests', that );
+
+ var toolbar, testResults, cntTotal, cntCalled, cntMissing;
+
+ cntTotal = util.keys( that.injectionTracker ).length;
+ cntCalled = util.keys( that.methodCallTracker ).length;
+ cntMissing = util.keys( that.missingTests ).length;
+
+ function makeTestResults( blob, title, style ) {
+ var elOutputWrapper, elTitle, elContainer, elList, elFoot;
+
+ elTitle = document.createElement( 'strong' );
+ elTitle.textContent = title || 'Values';
+
+ elList = document.createElement( 'ul' );
+ util.each( blob, function ( key ) {
+ var elItem = document.createElement( 'li' );
+ elItem.textContent = key;
+ elList.appendChild( elItem );
+ });
+
+ elFoot = document.createElement( 'p' );
+ elFoot.innerHTML = '<em>&mdash; CompletenessTest</em>';
+
+ elContainer = document.createElement( 'div' );
+ elContainer.appendChild( elTitle );
+ elContainer.appendChild( elList );
+ elContainer.appendChild( elFoot );
+
+ elOutputWrapper = document.getElementById( 'qunit-completenesstest' );
+ if ( !elOutputWrapper ) {
+ elOutputWrapper = document.createElement( 'div' );
+ elOutputWrapper.id = 'qunit-completenesstest';
+ }
+ elOutputWrapper.appendChild( elContainer );
+
+ util.each( style, function ( key, value ) {
+ elOutputWrapper.style[key] = value;
+ });
+ return elOutputWrapper;
+ }
+
+ if ( cntMissing === 0 ) {
+ // Good
+ testResults = makeTestResults(
+ {},
+ 'Detected calls to ' + cntCalled + '/' + cntTotal + ' methods. No missing tests!',
+ {
+ backgroundColor: '#D2E0E6',
+ color: '#366097',
+ paddingTop: '1em',
+ paddingRight: '1em',
+ paddingBottom: '1em',
+ paddingLeft: '1em'
+ }
+ );
+ } else {
+ // Bad
+ testResults = makeTestResults(
+ that.missingTests,
+ 'Detected calls to ' + cntCalled + '/' + cntTotal + ' methods. ' + cntMissing + ' methods not covered:',
+ {
+ backgroundColor: '#EE5757',
+ color: 'black',
+ paddingTop: '1em',
+ paddingRight: '1em',
+ paddingBottom: '1em',
+ paddingLeft: '1em'
+ }
+ );
+ }
+
+ toolbar = document.getElementById( 'qunit-testrunner-toolbar' );
+ if ( toolbar ) {
+ toolbar.insertBefore( testResults, toolbar.firstChild );
+ }
+ });
+
+ return this;
+ }
+
+ /* Public methods */
+ CompletenessTest.fn = CompletenessTest.prototype = {
+
+ /**
+ * CompletenessTest.fn.walkTheObject
+ *
+ * This function recursively walks through the given object, calling itself as it goes.
+ * Depending on the action it either injects our listener into the methods, or
+ * reads from our tracker and records which methods have not been called by the test suite.
+ *
+ * @param currName {String|Null} Name of the given object member (Initially this is null).
+ * @param currVar {mixed} The variable to check (initially an object,
+ * further down it could be anything).
+ * @param masterVariable {Object} Throughout our interation, always keep track of the master/root.
+ * Initially this is the same as currVar.
+ * @param parentPathArray {Array} Array of names that indicate our breadcrumb path starting at
+ * masterVariable. Not including currName.
+ */
+ walkTheObject: function ( currObj, currName, masterVariable, parentPathArray ) {
+ var key, currVal, type,
+ ct = this,
+ currPathArray = parentPathArray;
+
+ if ( currName ) {
+ currPathArray.push( currName );
+ currVal = currObj[currName];
+ } else {
+ currName = '(root)';
+ currVal = currObj;
+ }
+
+ type = util.type( currVal );
+
+ // Hard ignores
+ if ( this.ignoreFn( currVal, this, currPathArray ) ) {
+ return null;
+ }
+
+ // Handle the lazy limit
+ this.lazyCounter++;
+ if ( this.lazyCounter > this.lazyLimit ) {
+ log( 'CompletenessTest.fn.walkTheObject> Limit reached: ' + this.lazyCounter, currPathArray );
+ return null;
+ }
+
+ // Functions
+ if ( type === 'function' ) {
+ // Don't put a spy in constructor functions as it messes with
+ // instanceof etc.
+ if ( !currVal.prototype || util.isEmptyObject( currVal.prototype ) ) {
+ this.injectionTracker[ currPathArray.join( '.' ) ] = true;
+ this.injectCheck( currObj, currName, function () {
+ ct.methodCallTracker[ currPathArray.join( '.' ) ] = true;
+ } );
+ }
+ }
+
+ // Recursively. After all, this is the *completeness* test
+ // This also traverses static properties and the prototype of a constructor
+ if ( type === 'object' || type === 'function' ) {
+ for ( key in currVal ) {
+ if ( hasOwn.call( currVal, key ) ) {
+ this.walkTheObject( currVal, key, masterVariable, currPathArray.slice() );
+ }
+ }
+ }
+ },
+
+ populateMissingTests: function () {
+ var ct = this;
+ util.each( ct.injectionTracker, function ( key ) {
+ ct.hasTest( key );
+ });
+ },
+
+ /**
+ * CompletenessTest.fn.hasTest
+ *
+ * Checks if the given method name (ie. 'my.foo.bar')
+ * was called during the test suite (as far as the tracker knows).
+ * If not it adds it to missingTests.
+ *
+ * @param fnName {String}
+ * @return {Boolean}
+ */
+ hasTest: function ( fnName ) {
+ if ( !( fnName in this.methodCallTracker ) ) {
+ this.missingTests[fnName] = true;
+ return false;
+ }
+ return true;
+ },
+
+ /**
+ * CompletenessTest.fn.injectCheck
+ *
+ * Injects a function (such as a spy that updates methodCallTracker when
+ * it's called) inside another function.
+ *
+ * @param masterVariable {Object}
+ * @param objectPathArray {Array}
+ * @param injectFn {Function}
+ */
+ injectCheck: function ( obj, key, injectFn ) {
+ var spy,
+ val = obj[ key ];
+
+ spy = function () {
+ injectFn();
+ return val.apply( this, arguments );
+ };
+
+ // Make the spy inherit from the original so that its static methods are also
+ // visible in the spy (e.g. when we inject a check into mw.log, mw.log.warn
+ // must remain accessible).
+ /*jshint proto:true */
+ spy.__proto__ = val;
+
+ // Objects are by reference, members (unless objects) are not.
+ obj[ key ] = spy;
+ }
+ };
+
+ /* Expose */
+ window.CompletenessTest = CompletenessTest;
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/jquery/jquery.spinner.css b/resources/src/jquery/jquery.spinner.css
new file mode 100644
index 00000000..a9e06dbe
--- /dev/null
+++ b/resources/src/jquery/jquery.spinner.css
@@ -0,0 +1,40 @@
+.mw-spinner {
+ background-color: transparent;
+ background-position: center center;
+ background-repeat: no-repeat;
+}
+
+.mw-spinner-small {
+ /* @embed */
+ background-image: url(images/spinner.gif);
+ height: 20px;
+ width: 20px;
+ /* Avoid issues with .mw-spinner-block when floated without width. */
+ min-width: 20px;
+}
+
+.mw-spinner-large {
+ /* @embed */
+ background-image: url(images/spinner-large.gif);
+ height: 32px;
+ width: 32px;
+ /* Avoid issues with .mw-spinner-block when floated without width. */
+ min-width: 32px;
+}
+
+.mw-spinner-block {
+ display: block;
+ /* This overrides width from .mw-spinner-large / .mw-spinner-small,
+ * This is where the min-width kicks in.
+ */
+ width: 100%;
+}
+
+.mw-spinner-inline {
+ display: inline-block;
+ vertical-align: middle;
+
+ /* IE < 8 */
+ zoom: 1;
+ *display: inline;
+}
diff --git a/resources/src/jquery/jquery.spinner.js b/resources/src/jquery/jquery.spinner.js
new file mode 100644
index 00000000..361d3e08
--- /dev/null
+++ b/resources/src/jquery/jquery.spinner.js
@@ -0,0 +1,112 @@
+/**
+ * jQuery Spinner
+ *
+ * Simple jQuery plugin to create, inject and remove spinners.
+ *
+ * @class jQuery.plugin.spinner
+ */
+( function ( $ ) {
+
+ // Default options for new spinners,
+ // stored outside the function to share between calls.
+ var defaults = {
+ id: undefined,
+ size: 'small',
+ type: 'inline'
+ };
+
+ $.extend( {
+ /**
+ * Create a spinner element
+ *
+ * The argument is an object with options used to construct the spinner (see below).
+ *
+ * It is a good practice to keep a reference to the created spinner to be able to remove it
+ * later. Alternatively, one can use the 'id' option and #removeSpinner (but make sure to choose
+ * an id that's unlikely to cause conflicts, e.g. with extensions, gadgets or user scripts).
+ *
+ * CSS classes used:
+ *
+ * - .mw-spinner for every spinner
+ * - .mw-spinner-small / .mw-spinner-large for size
+ * - .mw-spinner-block / .mw-spinner-inline for display types
+ *
+ * Example:
+ *
+ * // Create a large spinner reserving all available horizontal space.
+ * var $spinner = $.createSpinner( { size: 'large', type: 'block' } );
+ * // Insert above page content.
+ * $( '#mw-content-text' ).prepend( $spinner );
+ *
+ * // Place a small inline spinner next to the "Save" button
+ * var $spinner = $.createSpinner( { size: 'small', type: 'inline' } );
+ * // Alternatively, just `$.createSpinner();` as these are the default options.
+ * $( '#wpSave' ).after( $spinner );
+ *
+ * // The following two are equivalent:
+ * $.createSpinner( 'magic' );
+ * $.createSpinner( { id: 'magic' } );
+ *
+ * @static
+ * @inheritable
+ * @param {Object|string} [opts] Options. If a string is given, it will be treated as the value
+ * of the `id` option. If an object is given, the possible option keys are:
+ * @param {string} [opts.id] If given, spinner will be given an id of "mw-spinner-{id}".
+ * @param {string} [opts.size='small'] 'small' or 'large' for a 20-pixel or 32-pixel spinner.
+ * @param {string} [opts.type='inline'] 'inline' or 'block'. Inline creates an inline-block with
+ * width and height equal to spinner size. Block is a block-level element with width 100%,
+ * height equal to spinner size.
+ * @return {jQuery}
+ */
+ createSpinner: function ( opts ) {
+ if ( opts !== undefined && $.type( opts ) !== 'object' ) {
+ opts = {
+ id: opts
+ };
+ }
+
+ opts = $.extend( {}, defaults, opts );
+
+ var $spinner = $( '<div>', { 'class': 'mw-spinner', 'title': '...' } );
+ if ( opts.id !== undefined ) {
+ $spinner.attr( 'id', 'mw-spinner-' + opts.id );
+ }
+
+ $spinner.addClass( opts.size === 'large' ? 'mw-spinner-large' : 'mw-spinner-small' );
+ $spinner.addClass( opts.type === 'block' ? 'mw-spinner-block' : 'mw-spinner-inline' );
+
+ return $spinner;
+ },
+
+ /**
+ * Remove a spinner element
+ *
+ * @static
+ * @inheritable
+ * @param {string} id Id of the spinner, as passed to #createSpinner
+ * @return {jQuery} The (now detached) spinner element
+ */
+ removeSpinner: function ( id ) {
+ return $( '#mw-spinner-' + id ).remove();
+ }
+ } );
+
+ /**
+ * Inject a spinner after each element in the collection
+ *
+ * Inserts spinner as siblings (not children) of the target elements.
+ * Collection contents remain unchanged.
+ *
+ * @param {Object|string} [opts] See #createSpinner
+ * @return {jQuery}
+ */
+ $.fn.injectSpinner = function ( opts ) {
+ return this.after( $.createSpinner( opts ) );
+ };
+
+ /**
+ * @class jQuery
+ * @mixins jQuery.plugin.spinner
+ */
+
+}( jQuery ) );
diff --git a/resources/src/jquery/jquery.suggestions.css b/resources/src/jquery/jquery.suggestions.css
new file mode 100644
index 00000000..15cd9264
--- /dev/null
+++ b/resources/src/jquery/jquery.suggestions.css
@@ -0,0 +1,76 @@
+/* suggestions plugin */
+
+.suggestions {
+ overflow: hidden;
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 0;
+ border: none;
+ z-index: 1099;
+ padding: 0;
+ margin: -1px 0 0 0;
+}
+
+.suggestions-special {
+ position: relative;
+ background-color: white;
+ cursor: pointer;
+ border: solid 1px #aaaaaa;
+ padding: 0;
+ margin: 0;
+ margin-top: -2px;
+ display: none;
+ padding: 0.25em 0.25em;
+ line-height: 1.25em;
+}
+
+.suggestions-results {
+ background-color: white;
+ cursor: pointer;
+ border: solid 1px #aaaaaa;
+ padding: 0;
+ margin: 0;
+}
+
+.suggestions-result {
+ color: black;
+ margin: 0;
+ line-height: 1.5em;
+ padding: 0.01em 0.25em;
+ text-align: left;
+ /* Apply ellipsis to suggestions */
+ overflow: hidden;
+ -o-text-overflow: ellipsis; /* Opera 9 to 10 */
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.suggestions-result-current {
+ background-color: #4C59A6;
+ color: white;
+}
+
+.suggestions-special .special-label {
+ color: gray;
+ text-align: left;
+}
+
+.suggestions-special .special-query {
+ color: black;
+ font-style: italic;
+ text-align: left;
+}
+
+.suggestions-special .special-hover {
+ background-color: silver;
+}
+
+.suggestions-result-current .special-label,
+.suggestions-result-current .special-query {
+ color: white;
+}
+
+.highlight {
+ font-weight: bold;
+}
diff --git a/resources/src/jquery/jquery.suggestions.js b/resources/src/jquery/jquery.suggestions.js
new file mode 100644
index 00000000..3369cde2
--- /dev/null
+++ b/resources/src/jquery/jquery.suggestions.js
@@ -0,0 +1,684 @@
+/**
+ * This plugin provides a generic way to add suggestions to a text box.
+ *
+ * Usage:
+ *
+ * Set options:
+ * $( '#textbox' ).suggestions( { option1: value1, option2: value2 } );
+ * $( '#textbox' ).suggestions( option, value );
+ * Get option:
+ * value = $( '#textbox' ).suggestions( option );
+ * Initialize:
+ * $( '#textbox' ).suggestions();
+ *
+ * Options:
+ *
+ * fetch(query): Callback that should fetch suggestions and set the suggestions property.
+ * Executed in the context of the textbox
+ * Type: Function
+ * cancel: Callback function to call when any pending asynchronous suggestions fetches
+ * should be canceled. Executed in the context of the textbox
+ * Type: Function
+ * special: Set of callbacks for rendering and selecting
+ * Type: Object of Functions 'render' and 'select'
+ * result: Set of callbacks for rendering and selecting
+ * Type: Object of Functions 'render' and 'select'
+ * $region: jQuery selection of element to place the suggestions below and match width of
+ * Type: jQuery Object, Default: $( this )
+ * suggestions: Suggestions to display
+ * Type: Array of strings
+ * maxRows: Maximum number of suggestions to display at one time
+ * Type: Number, Range: 1 - 100, Default: 7
+ * delay: Number of ms to wait for the user to stop typing
+ * Type: Number, Range: 0 - 1200, Default: 120
+ * cache: Whether to cache results from a fetch
+ * Type: Boolean, Default: false
+ * cacheMaxAge: Number of ms to cache results from a fetch
+ * Type: Number, Range: 1 - Infinity, Default: 60000 (1 minute)
+ * submitOnClick: Whether to submit the form containing the textbox when a suggestion is clicked
+ * Type: Boolean, Default: false
+ * maxExpandFactor: Maximum suggestions box width relative to the textbox width. If set
+ * to e.g. 2, the suggestions box will never be grown beyond 2 times the width of the textbox.
+ * Type: Number, Range: 1 - infinity, Default: 3
+ * expandFrom: Which direction to offset the suggestion box from.
+ * Values 'start' and 'end' translate to left and right respectively depending on the
+ * directionality of the current document, according to $( 'html' ).css( 'direction' ).
+ * Type: String, default: 'auto', options: 'left', 'right', 'start', 'end', 'auto'.
+ * positionFromLeft: Sets expandFrom=left, for backwards compatibility
+ * Type: Boolean, Default: true
+ * highlightInput: Whether to hightlight matched portions of the input or not
+ * Type: Boolean, Default: false
+ */
+( function ( $ ) {
+
+var hasOwn = Object.hasOwnProperty;
+
+$.suggestions = {
+ /**
+ * Cancel any delayed maybeFetch() call and callback the context so
+ * they can cancel any async fetching if they use AJAX or something.
+ */
+ cancel: function ( context ) {
+ if ( context.data.timerID !== null ) {
+ clearTimeout( context.data.timerID );
+ }
+ if ( $.isFunction( context.config.cancel ) ) {
+ context.config.cancel.call( context.data.$textbox );
+ }
+ },
+
+ /**
+ * Hide the element with suggestions and clean up some state.
+ */
+ hide: function ( context ) {
+ // Remove any highlights, including on "special" items
+ context.data.$container.find( '.suggestions-result-current' ).removeClass( 'suggestions-result-current' );
+ // Hide the container
+ context.data.$container.hide();
+ },
+
+ /**
+ * Restore the text the user originally typed in the textbox, before it
+ * was overwritten by highlight(). This restores the value the currently
+ * displayed suggestions are based on, rather than the value just before
+ * highlight() overwrote it; the former is arguably slightly more sensible.
+ */
+ restore: function ( context ) {
+ context.data.$textbox.val( context.data.prevText );
+ },
+
+ /**
+ * Ask the user-specified callback for new suggestions. Any previous delayed
+ * call to this function still pending will be canceled. If the value in the
+ * textbox is empty or hasn't changed since the last time suggestions were fetched,
+ * this function does nothing.
+ * @param {Boolean} delayed Whether or not to delay this by the currently configured amount of time
+ */
+ update: function ( context, delayed ) {
+ function maybeFetch() {
+ var val = context.data.$textbox.val(),
+ cache = context.data.cache,
+ cacheHit;
+
+ // Only fetch if the value in the textbox changed and is not empty, or if the results were hidden
+ // if the textbox is empty then clear the result div, but leave other settings intouched
+ if ( val.length === 0 ) {
+ $.suggestions.hide( context );
+ context.data.prevText = '';
+ } else if (
+ val !== context.data.prevText ||
+ !context.data.$container.is( ':visible' )
+ ) {
+ context.data.prevText = val;
+ // Try cache first
+ if ( context.config.cache && hasOwn.call( cache, val ) ) {
+ if ( +new Date() - cache[ val ].timestamp < context.config.cacheMaxAge ) {
+ context.data.$textbox.suggestions( 'suggestions', cache[ val ].suggestions );
+ cacheHit = true;
+ } else {
+ // Cache expired
+ delete cache[ val ];
+ }
+ }
+ if ( !cacheHit && typeof context.config.fetch === 'function' ) {
+ context.config.fetch.call(
+ context.data.$textbox,
+ val,
+ function ( suggestions ) {
+ context.data.$textbox.suggestions( 'suggestions', suggestions );
+ if ( context.config.cache ) {
+ cache[ val ] = {
+ suggestions: suggestions,
+ timestamp: +new Date()
+ };
+ }
+ }
+ );
+ }
+ }
+
+ // Always update special rendering
+ $.suggestions.special( context );
+ }
+
+ // Cancels any delayed maybeFetch call, and invokes context.config.cancel.
+ $.suggestions.cancel( context );
+
+ if ( delayed ) {
+ // To avoid many started/aborted requests while typing, we're gonna take a short
+ // break before trying to fetch data.
+ context.data.timerID = setTimeout( maybeFetch, context.config.delay );
+ } else {
+ maybeFetch();
+ }
+ },
+
+ special: function ( context ) {
+ // Allow custom rendering - but otherwise don't do any rendering
+ if ( typeof context.config.special.render === 'function' ) {
+ // Wait for the browser to update the value
+ setTimeout( function () {
+ // Render special
+ var $special = context.data.$container.find( '.suggestions-special' );
+ context.config.special.render.call( $special, context.data.$textbox.val(), context );
+ }, 1 );
+ }
+ },
+
+ /**
+ * Sets the value of a property, and updates the widget accordingly
+ * @param property String Name of property
+ * @param value Mixed Value to set property with
+ */
+ configure: function ( context, property, value ) {
+ var newCSS,
+ $result, $results, $spanForWidth, childrenWidth,
+ i, expWidth, maxWidth, text;
+
+ // Validate creation using fallback values
+ switch ( property ) {
+ case 'fetch':
+ case 'cancel':
+ case 'special':
+ case 'result':
+ case '$region':
+ case 'expandFrom':
+ context.config[property] = value;
+ break;
+ case 'suggestions':
+ context.config[property] = value;
+ // Update suggestions
+ if ( context.data !== undefined ) {
+ if ( context.data.$textbox.val().length === 0 ) {
+ // Hide the div when no suggestion exist
+ $.suggestions.hide( context );
+ } else {
+ // Rebuild the suggestions list
+ context.data.$container.show();
+ // Update the size and position of the list
+ newCSS = {
+ top: context.config.$region.offset().top + context.config.$region.outerHeight(),
+ bottom: 'auto',
+ width: context.config.$region.outerWidth(),
+ height: 'auto'
+ };
+
+ // Process expandFrom, after this it is set to left or right.
+ context.config.expandFrom = ( function ( expandFrom ) {
+ var regionWidth, docWidth, regionCenter, docCenter,
+ docDir = $( document.documentElement ).css( 'direction' ),
+ $region = context.config.$region;
+
+ // Backwards compatible
+ if ( context.config.positionFromLeft ) {
+ expandFrom = 'left';
+
+ // Catch invalid values, default to 'auto'
+ } else if ( $.inArray( expandFrom, ['left', 'right', 'start', 'end', 'auto'] ) === -1 ) {
+ expandFrom = 'auto';
+ }
+
+ if ( expandFrom === 'auto' ) {
+ if ( $region.data( 'searchsuggest-expand-dir' ) ) {
+ // If the markup explicitly contains a direction, use it.
+ expandFrom = $region.data( 'searchsuggest-expand-dir' );
+ } else {
+ regionWidth = $region.outerWidth();
+ docWidth = $( document ).width();
+ if ( regionWidth > ( 0.85 * docWidth ) ) {
+ // If the input size takes up more than 85% of the document horizontally
+ // expand the suggestions to the writing direction's native end.
+ expandFrom = 'start';
+ } else {
+ // Calculate the center points of the input and document
+ regionCenter = $region.offset().left + regionWidth / 2;
+ docCenter = docWidth / 2;
+ if ( Math.abs( regionCenter - docCenter ) < ( 0.10 * docCenter ) ) {
+ // If the input's center is within 10% of the document center
+ // use the writing direction's native end.
+ expandFrom = 'start';
+ } else {
+ // Otherwise expand the input from the closest side of the page,
+ // towards the side of the page with the most free open space
+ expandFrom = regionCenter > docCenter ? 'right' : 'left';
+ }
+ }
+ }
+ }
+
+ if ( expandFrom === 'start' ) {
+ expandFrom = docDir === 'rtl' ? 'right' : 'left';
+
+ } else if ( expandFrom === 'end' ) {
+ expandFrom = docDir === 'rtl' ? 'left' : 'right';
+ }
+
+ return expandFrom;
+
+ }( context.config.expandFrom ) );
+
+ if ( context.config.expandFrom === 'left' ) {
+ // Expand from left
+ newCSS.left = context.config.$region.offset().left;
+ newCSS.right = 'auto';
+ } else {
+ // Expand from right
+ newCSS.left = 'auto';
+ newCSS.right = $( 'body' ).width() - ( context.config.$region.offset().left + context.config.$region.outerWidth() );
+ }
+
+ context.data.$container.css( newCSS );
+ $results = context.data.$container.children( '.suggestions-results' );
+ $results.empty();
+ expWidth = -1;
+ for ( i = 0; i < context.config.suggestions.length; i++ ) {
+ /*jshint loopfunc:true */
+ text = context.config.suggestions[i];
+ $result = $( '<div>' )
+ .addClass( 'suggestions-result' )
+ .attr( 'rel', i )
+ .data( 'text', context.config.suggestions[i] )
+ .mousemove( function () {
+ context.data.selectedWithMouse = true;
+ $.suggestions.highlight(
+ context,
+ $( this ).closest( '.suggestions-results .suggestions-result' ),
+ false
+ );
+ } )
+ .appendTo( $results );
+ // Allow custom rendering
+ if ( typeof context.config.result.render === 'function' ) {
+ context.config.result.render.call( $result, context.config.suggestions[i], context );
+ } else {
+ $result.text( text );
+ }
+
+ if ( context.config.highlightInput ) {
+ $result.highlightText( context.data.prevText );
+ }
+
+ // Widen results box if needed (new width is only calculated here, applied later).
+
+ // The monstrosity below accomplishes two things:
+ // * Wraps the text contents in a DOM element, so that we can know its width. There is
+ // no way to directly access the width of a text node, and we can't use the parent
+ // node width as it has text-overflow: ellipsis; and overflow: hidden; applied to
+ // it, which trims it to a smaller width.
+ // * Temporarily applies position: absolute; to the wrapper to pull it out of normal
+ // document flow. Otherwise the CSS text-overflow: ellipsis; and overflow: hidden;
+ // rules would cause some browsers (at least all versions of IE from 6 to 11) to
+ // still report the "trimmed" width. This should not be done in regular CSS
+ // stylesheets as we don't want this rule to apply to other <span> elements, like
+ // the ones generated by jquery.highlightText.
+ $spanForWidth = $result.wrapInner( '<span>' ).children();
+ childrenWidth = $spanForWidth.css( 'position', 'absolute' ).outerWidth();
+ $spanForWidth.contents().unwrap();
+
+ if ( childrenWidth > $result.width() && childrenWidth > expWidth ) {
+ // factor in any padding, margin, or border space on the parent
+ expWidth = childrenWidth + ( context.data.$container.width() - $result.width() );
+ }
+ }
+
+ // Apply new width for results box, if any
+ if ( expWidth > context.data.$container.width() ) {
+ maxWidth = context.config.maxExpandFactor * context.data.$textbox.width();
+ context.data.$container.width( Math.min( expWidth, maxWidth ) );
+ }
+ }
+ }
+ break;
+ case 'maxRows':
+ context.config[property] = Math.max( 1, Math.min( 100, value ) );
+ break;
+ case 'delay':
+ context.config[property] = Math.max( 0, Math.min( 1200, value ) );
+ break;
+ case 'cacheMaxAge':
+ context.config[property] = Math.max( 1, value );
+ break;
+ case 'maxExpandFactor':
+ context.config[property] = Math.max( 1, value );
+ break;
+ case 'cache':
+ case 'submitOnClick':
+ case 'positionFromLeft':
+ case 'highlightInput':
+ context.config[property] = !!value;
+ break;
+ }
+ },
+
+ /**
+ * Highlight a result in the results table
+ * @param result <tr> to highlight: jQuery object, or 'prev' or 'next'
+ * @param updateTextbox If true, put the suggestion in the textbox
+ */
+ highlight: function ( context, result, updateTextbox ) {
+ var selected = context.data.$container.find( '.suggestions-result-current' );
+ if ( !result.get || selected.get( 0 ) !== result.get( 0 ) ) {
+ if ( result === 'prev' ) {
+ if ( selected.hasClass( 'suggestions-special' ) ) {
+ result = context.data.$container.find( '.suggestions-result:last' );
+ } else {
+ result = selected.prev();
+ if ( !( result.length && result.hasClass( 'suggestions-result' ) ) ) {
+ // there is something in the DOM between selected element and the wrapper, bypass it
+ result = selected.parents( '.suggestions-results > *' ).prev().find( '.suggestions-result' ).eq( 0 );
+ }
+
+ if ( selected.length === 0 ) {
+ // we are at the beginning, so lets jump to the last item
+ if ( context.data.$container.find( '.suggestions-special' ).html() !== '' ) {
+ result = context.data.$container.find( '.suggestions-special' );
+ } else {
+ result = context.data.$container.find( '.suggestions-results .suggestions-result:last' );
+ }
+ }
+ }
+ } else if ( result === 'next' ) {
+ if ( selected.length === 0 ) {
+ // No item selected, go to the first one
+ result = context.data.$container.find( '.suggestions-results .suggestions-result:first' );
+ if ( result.length === 0 && context.data.$container.find( '.suggestions-special' ).html() !== '' ) {
+ // No suggestion exists, go to the special one directly
+ result = context.data.$container.find( '.suggestions-special' );
+ }
+ } else {
+ result = selected.next();
+ if ( !( result.length && result.hasClass( 'suggestions-result' ) ) ) {
+ // there is something in the DOM between selected element and the wrapper, bypass it
+ result = selected.parents( '.suggestions-results > *' ).next().find( '.suggestions-result' ).eq( 0 );
+ }
+
+ if ( selected.hasClass( 'suggestions-special' ) ) {
+ result = $( [] );
+ } else if (
+ result.length === 0 &&
+ context.data.$container.find( '.suggestions-special' ).html() !== ''
+ ) {
+ // We were at the last item, jump to the specials!
+ result = context.data.$container.find( '.suggestions-special' );
+ }
+ }
+ }
+ selected.removeClass( 'suggestions-result-current' );
+ result.addClass( 'suggestions-result-current' );
+ }
+ if ( updateTextbox ) {
+ if ( result.length === 0 || result.is( '.suggestions-special' ) ) {
+ $.suggestions.restore( context );
+ } else {
+ context.data.$textbox.val( result.data( 'text' ) );
+ // .val() doesn't call any event handlers, so
+ // let the world know what happened
+ context.data.$textbox.change();
+ }
+ context.data.$textbox.trigger( 'change' );
+ }
+ },
+
+ /**
+ * Respond to keypress event
+ * @param key Integer Code of key pressed
+ */
+ keypress: function ( e, context, key ) {
+ var selected,
+ wasVisible = context.data.$container.is( ':visible' ),
+ preventDefault = false;
+
+ switch ( key ) {
+ // Arrow down
+ case 40:
+ if ( wasVisible ) {
+ $.suggestions.highlight( context, 'next', true );
+ context.data.selectedWithMouse = false;
+ } else {
+ $.suggestions.update( context, false );
+ }
+ preventDefault = true;
+ break;
+ // Arrow up
+ case 38:
+ if ( wasVisible ) {
+ $.suggestions.highlight( context, 'prev', true );
+ context.data.selectedWithMouse = false;
+ }
+ preventDefault = wasVisible;
+ break;
+ // Escape
+ case 27:
+ $.suggestions.hide( context );
+ $.suggestions.restore( context );
+ $.suggestions.cancel( context );
+ context.data.$textbox.trigger( 'change' );
+ preventDefault = wasVisible;
+ break;
+ // Enter
+ case 13:
+ preventDefault = wasVisible;
+ selected = context.data.$container.find( '.suggestions-result-current' );
+ $.suggestions.hide( context );
+ if ( selected.length === 0 || context.data.selectedWithMouse ) {
+ // If nothing is selected or if something was selected with the mouse
+ // cancel any current requests and allow the form to be submitted
+ // (simply don't prevent default behavior).
+ $.suggestions.cancel( context );
+ preventDefault = false;
+ } else if ( selected.is( '.suggestions-special' ) ) {
+ if ( typeof context.config.special.select === 'function' ) {
+ // Allow the callback to decide whether to prevent default or not
+ if ( context.config.special.select.call( selected, context.data.$textbox ) === true ) {
+ preventDefault = false;
+ }
+ }
+ } else {
+ $.suggestions.highlight( context, selected, true );
+
+ if ( typeof context.config.result.select === 'function' ) {
+ // Allow the callback to decide whether to prevent default or not
+ if ( context.config.result.select.call( selected, context.data.$textbox ) === true ) {
+ preventDefault = false;
+ }
+ }
+ }
+ break;
+ default:
+ $.suggestions.update( context, true );
+ break;
+ }
+ if ( preventDefault ) {
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ }
+};
+$.fn.suggestions = function () {
+
+ // Multi-context fields
+ var returnValue,
+ args = arguments;
+
+ $( this ).each( function () {
+ var context, key;
+
+ /* Construction / Loading */
+
+ context = $( this ).data( 'suggestions-context' );
+ if ( context === undefined || context === null ) {
+ context = {
+ config: {
+ fetch: function () {},
+ cancel: function () {},
+ special: {},
+ result: {},
+ $region: $( this ),
+ suggestions: [],
+ maxRows: 7,
+ delay: 120,
+ cache: false,
+ cacheMaxAge: 60000,
+ submitOnClick: false,
+ maxExpandFactor: 3,
+ expandFrom: 'auto',
+ highlightInput: false
+ }
+ };
+ }
+
+ /* API */
+
+ // Handle various calling styles
+ if ( args.length > 0 ) {
+ if ( typeof args[0] === 'object' ) {
+ // Apply set of properties
+ for ( key in args[0] ) {
+ $.suggestions.configure( context, key, args[0][key] );
+ }
+ } else if ( typeof args[0] === 'string' ) {
+ if ( args.length > 1 ) {
+ // Set property values
+ $.suggestions.configure( context, args[0], args[1] );
+ } else if ( returnValue === null || returnValue === undefined ) {
+ // Get property values, but don't give access to internal data - returns only the first
+ returnValue = ( args[0] in context.config ? undefined : context.config[args[0]] );
+ }
+ }
+ }
+
+ /* Initialization */
+
+ if ( context.data === undefined ) {
+ context.data = {
+ // ID of running timer
+ timerID: null,
+
+ // Text in textbox when suggestions were last fetched
+ prevText: null,
+
+ // Cache of fetched suggestions
+ cache: {},
+
+ // Number of results visible without scrolling
+ visibleResults: 0,
+
+ // Suggestion the last mousedown event occurred on
+ mouseDownOn: $( [] ),
+ $textbox: $( this ),
+ selectedWithMouse: false
+ };
+
+ context.data.$container = $( '<div>' )
+ .css( 'display', 'none' )
+ .addClass( 'suggestions' )
+ .append(
+ $( '<div>' ).addClass( 'suggestions-results' )
+ // Can't use click() because the container div is hidden when the
+ // textbox loses focus. Instead, listen for a mousedown followed
+ // by a mouseup on the same div.
+ .mousedown( function ( e ) {
+ context.data.mouseDownOn = $( e.target ).closest( '.suggestions-results .suggestions-result' );
+ } )
+ .mouseup( function ( e ) {
+ var $result = $( e.target ).closest( '.suggestions-results .suggestions-result' ),
+ $other = context.data.mouseDownOn;
+
+ context.data.mouseDownOn = $( [] );
+ if ( $result.get( 0 ) !== $other.get( 0 ) ) {
+ return;
+ }
+ // Do not interfere with non-left clicks or if modifier keys are pressed (e.g. ctrl-click).
+ if ( !( e.which !== 1 || e.altKey || e.ctrlKey || e.shiftKey || e.metaKey ) ) {
+ $.suggestions.highlight( context, $result, true );
+ if ( typeof context.config.result.select === 'function' ) {
+ context.config.result.select.call( $result, context.data.$textbox );
+ }
+ // This will hide the link we're just clicking on, which causes problems
+ // when done synchronously in at least Firefox 3.6 (bug 62858).
+ setTimeout( function () {
+ $.suggestions.hide( context );
+ }, 0 );
+ }
+ // Always bring focus to the textbox, as that's probably where the user expects it
+ // if they were just typing.
+ context.data.$textbox.focus();
+ } )
+ )
+ .append(
+ $( '<div>' ).addClass( 'suggestions-special' )
+ // Can't use click() because the container div is hidden when the
+ // textbox loses focus. Instead, listen for a mousedown followed
+ // by a mouseup on the same div.
+ .mousedown( function ( e ) {
+ context.data.mouseDownOn = $( e.target ).closest( '.suggestions-special' );
+ } )
+ .mouseup( function ( e ) {
+ var $special = $( e.target ).closest( '.suggestions-special' ),
+ $other = context.data.mouseDownOn;
+
+ context.data.mouseDownOn = $( [] );
+ if ( $special.get( 0 ) !== $other.get( 0 ) ) {
+ return;
+ }
+ // Do not interfere with non-left clicks or if modifier keys are pressed (e.g. ctrl-click).
+ if ( !( e.which !== 1 || e.altKey || e.ctrlKey || e.shiftKey || e.metaKey ) ) {
+ if ( typeof context.config.special.select === 'function' ) {
+ context.config.special.select.call( $special, context.data.$textbox );
+ }
+ // This will hide the link we're just clicking on, which causes problems
+ // when done synchronously in at least Firefox 3.6 (bug 62858).
+ setTimeout( function () {
+ $.suggestions.hide( context );
+ }, 0 );
+ }
+ // Always bring focus to the textbox, as that's probably where the user expects it
+ // if they were just typing.
+ context.data.$textbox.focus();
+ } )
+ .mousemove( function ( e ) {
+ context.data.selectedWithMouse = true;
+ $.suggestions.highlight(
+ context, $( e.target ).closest( '.suggestions-special' ), false
+ );
+ } )
+ )
+ .appendTo( $( 'body' ) );
+
+ $( this )
+ // Stop browser autocomplete from interfering
+ .attr( 'autocomplete', 'off' )
+ .keydown( function ( e ) {
+ // Store key pressed to handle later
+ context.data.keypressed = e.which;
+ context.data.keypressedCount = 0;
+ } )
+ .keypress( function ( e ) {
+ context.data.keypressedCount++;
+ $.suggestions.keypress( e, context, context.data.keypressed );
+ } )
+ .keyup( function ( e ) {
+ // Some browsers won't throw keypress() for arrow keys. If we got a keydown and a keyup without a
+ // keypress in between, solve it
+ if ( context.data.keypressedCount === 0 ) {
+ $.suggestions.keypress( e, context, context.data.keypressed );
+ }
+ } )
+ .blur( function () {
+ // When losing focus because of a mousedown
+ // on a suggestion, don't hide the suggestions
+ if ( context.data.mouseDownOn.length > 0 ) {
+ return;
+ }
+ $.suggestions.hide( context );
+ $.suggestions.cancel( context );
+ } );
+ }
+
+ // Store the context for next time
+ $( this ).data( 'suggestions-context', context );
+ } );
+ return returnValue !== undefined ? returnValue : $( this );
+};
+
+}( jQuery ) );
diff --git a/resources/src/jquery/jquery.tabIndex.js b/resources/src/jquery/jquery.tabIndex.js
new file mode 100644
index 00000000..46cc8f2c
--- /dev/null
+++ b/resources/src/jquery/jquery.tabIndex.js
@@ -0,0 +1,57 @@
+/**
+ * @class jQuery.plugin.tabIndex
+ */
+( function ( $ ) {
+
+ /**
+ * Find the lowest tabindex in use within a selection.
+ *
+ * @return {number} Lowest tabindex on the page
+ */
+ $.fn.firstTabIndex = function () {
+ var minTabIndex = null;
+ $(this).find( '[tabindex]' ).each( function () {
+ var tabIndex = parseInt( $(this).prop( 'tabindex' ), 10 );
+ // In IE6/IE7 the above jQuery selector returns all elements,
+ // becuase it has a default value for tabIndex in IE6/IE7 of 0
+ // (rather than null/undefined). Therefore check "> 0" as well.
+ // Under IE7 under Windows NT 5.2 is also capable of returning NaN.
+ if ( tabIndex > 0 && !isNaN( tabIndex ) ) {
+ // Initial value
+ if ( minTabIndex === null ) {
+ minTabIndex = tabIndex;
+ } else if ( tabIndex < minTabIndex ) {
+ minTabIndex = tabIndex;
+ }
+ }
+ } );
+ return minTabIndex;
+ };
+
+ /**
+ * Find the highest tabindex in use within a selection.
+ *
+ * @return {number} Highest tabindex on the page
+ */
+ $.fn.lastTabIndex = function () {
+ var maxTabIndex = null;
+ $(this).find( '[tabindex]' ).each( function () {
+ var tabIndex = parseInt( $(this).prop( 'tabindex' ), 10 );
+ if ( tabIndex > 0 && !isNaN( tabIndex ) ) {
+ // Initial value
+ if ( maxTabIndex === null ) {
+ maxTabIndex = tabIndex;
+ } else if ( tabIndex > maxTabIndex ) {
+ maxTabIndex = tabIndex;
+ }
+ }
+ } );
+ return maxTabIndex;
+ };
+
+ /**
+ * @class jQuery
+ * @mixins jQuery.plugin.tabIndex
+ */
+
+}( jQuery ) );
diff --git a/resources/src/jquery/jquery.tablesorter.css b/resources/src/jquery/jquery.tablesorter.css
new file mode 100644
index 00000000..a88acc09
--- /dev/null
+++ b/resources/src/jquery/jquery.tablesorter.css
@@ -0,0 +1,17 @@
+/* Table Sorting */
+table.jquery-tablesorter th.headerSort {
+ /* @embed */
+ background-image: url(images/sort_both.gif);
+ cursor: pointer;
+ background-repeat: no-repeat;
+ background-position: center right;
+ padding-right: 21px;
+}
+table.jquery-tablesorter th.headerSortUp {
+ /* @embed */
+ background-image: url(images/sort_up.gif);
+}
+table.jquery-tablesorter th.headerSortDown {
+ /* @embed */
+ background-image: url(images/sort_down.gif);
+}
diff --git a/resources/src/jquery/jquery.tablesorter.js b/resources/src/jquery/jquery.tablesorter.js
new file mode 100644
index 00000000..ea2c5f92
--- /dev/null
+++ b/resources/src/jquery/jquery.tablesorter.js
@@ -0,0 +1,1161 @@
+/**
+ * TableSorter for MediaWiki
+ *
+ * Written 2011 Leo Koppelkamm
+ * Based on tablesorter.com plugin, written (c) 2007 Christian Bach.
+ *
+ * Dual licensed under the MIT and GPL licenses:
+ * http://www.opensource.org/licenses/mit-license.php
+ * http://www.gnu.org/licenses/gpl.html
+ *
+ * Depends on mw.config (wgDigitTransformTable, wgDefaultDateFormat, wgContentLanguage)
+ * and mw.language.months.
+ *
+ * Uses 'tableSorterCollation' in mw.config (if available)
+ */
+/**
+ *
+ * @description Create a sortable table with multi-column sorting capabilitys
+ *
+ * @example $( 'table' ).tablesorter();
+ * @desc Create a simple tablesorter interface.
+ *
+ * @example $( 'table' ).tablesorter( { sortList: [ { 0: 'desc' }, { 1: 'asc' } ] } );
+ * @desc Create a tablesorter interface initially sorting on the first and second column.
+ *
+ * @option String cssHeader ( optional ) A string of the class name to be appended
+ * to sortable tr elements in the thead of the table. Default value:
+ * "header"
+ *
+ * @option String cssAsc ( optional ) A string of the class name to be appended to
+ * sortable tr elements in the thead on a ascending sort. Default value:
+ * "headerSortUp"
+ *
+ * @option String cssDesc ( optional ) A string of the class name to be appended
+ * to sortable tr elements in the thead on a descending sort. Default
+ * value: "headerSortDown"
+ *
+ * @option String sortInitialOrder ( optional ) A string of the inital sorting
+ * order can be asc or desc. Default value: "asc"
+ *
+ * @option String sortMultisortKey ( optional ) A string of the multi-column sort
+ * key. Default value: "shiftKey"
+ *
+ * @option Boolean sortLocaleCompare ( optional ) Boolean flag indicating whatever
+ * to use String.localeCampare method or not. Set to false.
+ *
+ * @option Boolean cancelSelection ( optional ) Boolean flag indicating if
+ * tablesorter should cancel selection of the table headers text.
+ * Default value: true
+ *
+ * @option Array sortList ( optional ) An array containing objects specifying sorting.
+ * By passing more than one object, multi-sorting will be applied. Object structure:
+ * { <Integer column index>: <String 'asc' or 'desc'> }
+ * Default value: []
+ *
+ * @option Boolean debug ( optional ) Boolean flag indicating if tablesorter
+ * should display debuging information usefull for development.
+ *
+ * @event sortEnd.tablesorter: Triggered as soon as any sorting has been applied.
+ *
+ * @type jQuery
+ *
+ * @name tablesorter
+ *
+ * @cat Plugins/Tablesorter
+ *
+ * @author Christian Bach/christian.bach@polyester.se
+ */
+
+( function ( $, mw ) {
+ /* Local scope */
+
+ var ts,
+ parsers = [];
+
+ /* Parser utility functions */
+
+ function getParserById( name ) {
+ var i,
+ len = parsers.length;
+ for ( i = 0; i < len; i++ ) {
+ if ( parsers[i].id.toLowerCase() === name.toLowerCase() ) {
+ return parsers[i];
+ }
+ }
+ return false;
+ }
+
+ function getElementSortKey( node ) {
+ var $node = $( node ),
+ // Use data-sort-value attribute.
+ // Use data() instead of attr() so that live value changes
+ // are processed as well (bug 38152).
+ data = $node.data( 'sortValue' );
+
+ if ( data !== null && data !== undefined ) {
+ // Cast any numbers or other stuff to a string, methods
+ // like charAt, toLowerCase and split are expected.
+ return String( data );
+ } else {
+ if ( !node ) {
+ return $node.text();
+ } else if ( node.tagName.toLowerCase() === 'img' ) {
+ return $node.attr( 'alt' ) || ''; // handle undefined alt
+ } else {
+ return $.map( $.makeArray( node.childNodes ), function ( elem ) {
+ // 1 is for document.ELEMENT_NODE (the constant is undefined on old browsers)
+ if ( elem.nodeType === 1 ) {
+ return getElementSortKey( elem );
+ } else {
+ return $.text( elem );
+ }
+ } ).join( '' );
+ }
+ }
+ }
+
+ function detectParserForColumn( table, rows, cellIndex ) {
+ var l = parsers.length,
+ nodeValue,
+ // Start with 1 because 0 is the fallback parser
+ i = 1,
+ rowIndex = 0,
+ concurrent = 0,
+ needed = ( rows.length > 4 ) ? 5 : rows.length;
+
+ while ( i < l ) {
+ if ( rows[rowIndex] && rows[rowIndex].cells[cellIndex] ) {
+ nodeValue = $.trim( getElementSortKey( rows[rowIndex].cells[cellIndex] ) );
+ } else {
+ nodeValue = '';
+ }
+
+ if ( nodeValue !== '' ) {
+ if ( parsers[i].is( nodeValue, table ) ) {
+ concurrent++;
+ rowIndex++;
+ if ( concurrent >= needed ) {
+ // Confirmed the parser for multiple cells, let's return it
+ return parsers[i];
+ }
+ } else {
+ // Check next parser, reset rows
+ i++;
+ rowIndex = 0;
+ concurrent = 0;
+ }
+ } else {
+ // Empty cell
+ rowIndex++;
+ if ( rowIndex > rows.length ) {
+ rowIndex = 0;
+ i++;
+ }
+ }
+ }
+
+ // 0 is always the generic parser (text)
+ return parsers[0];
+ }
+
+ function buildParserCache( table, $headers ) {
+ var sortType, cells, len, i, parser,
+ rows = table.tBodies[0].rows,
+ parsers = [];
+
+ if ( rows[0] ) {
+
+ cells = rows[0].cells;
+ len = cells.length;
+
+ for ( i = 0; i < len; i++ ) {
+ parser = false;
+ sortType = $headers.eq( i ).data( 'sortType' );
+ if ( sortType !== undefined ) {
+ parser = getParserById( sortType );
+ }
+
+ if ( parser === false ) {
+ parser = detectParserForColumn( table, rows, i );
+ }
+
+ parsers.push( parser );
+ }
+ }
+ return parsers;
+ }
+
+ /* Other utility functions */
+
+ function buildCache( table ) {
+ var i, j, $row, cols,
+ totalRows = ( table.tBodies[0] && table.tBodies[0].rows.length ) || 0,
+ totalCells = ( table.tBodies[0].rows[0] && table.tBodies[0].rows[0].cells.length ) || 0,
+ parsers = table.config.parsers,
+ cache = {
+ row: [],
+ normalized: []
+ };
+
+ for ( i = 0; i < totalRows; ++i ) {
+
+ // Add the table data to main data array
+ $row = $( table.tBodies[0].rows[i] );
+ cols = [];
+
+ // if this is a child row, add it to the last row's children and
+ // continue to the next row
+ if ( $row.hasClass( table.config.cssChildRow ) ) {
+ cache.row[cache.row.length - 1] = cache.row[cache.row.length - 1].add( $row );
+ // go to the next for loop
+ continue;
+ }
+
+ cache.row.push( $row );
+
+ for ( j = 0; j < totalCells; ++j ) {
+ cols.push( parsers[j].format( getElementSortKey( $row[0].cells[j] ), table, $row[0].cells[j] ) );
+ }
+
+ cols.push( cache.normalized.length ); // add position for rowCache
+ cache.normalized.push( cols );
+ cols = null;
+ }
+
+ return cache;
+ }
+
+ function appendToTable( table, cache ) {
+ var i, pos, l, j,
+ row = cache.row,
+ normalized = cache.normalized,
+ totalRows = normalized.length,
+ checkCell = ( normalized[0].length - 1 ),
+ fragment = document.createDocumentFragment();
+
+ for ( i = 0; i < totalRows; i++ ) {
+ pos = normalized[i][checkCell];
+
+ l = row[pos].length;
+
+ for ( j = 0; j < l; j++ ) {
+ fragment.appendChild( row[pos][j] );
+ }
+
+ }
+ table.tBodies[0].appendChild( fragment );
+
+ $( table ).trigger( 'sortEnd.tablesorter' );
+ }
+
+ /**
+ * Find all header rows in a thead-less table and put them in a <thead> tag.
+ * This only treats a row as a header row if it contains only <th>s (no <td>s)
+ * and if it is preceded entirely by header rows. The algorithm stops when
+ * it encounters the first non-header row.
+ *
+ * After this, it will look at all rows at the bottom for footer rows
+ * And place these in a tfoot using similar rules.
+ * @param $table jQuery object for a <table>
+ */
+ function emulateTHeadAndFoot( $table ) {
+ var $thead, $tfoot, i, len,
+ $rows = $table.find( '> tbody > tr' );
+ if ( !$table.get( 0 ).tHead ) {
+ $thead = $( '<thead>' );
+ $rows.each( function () {
+ if ( $( this ).children( 'td' ).length ) {
+ // This row contains a <td>, so it's not a header row
+ // Stop here
+ return false;
+ }
+ $thead.append( this );
+ } );
+ $table.find( ' > tbody:first' ).before( $thead );
+ }
+ if ( !$table.get( 0 ).tFoot ) {
+ $tfoot = $( '<tfoot>' );
+ len = $rows.length;
+ for ( i = len - 1; i >= 0; i-- ) {
+ if ( $( $rows[i] ).children( 'td' ).length ) {
+ break;
+ }
+ $tfoot.prepend( $( $rows[i] ) );
+ }
+ $table.append( $tfoot );
+ }
+ }
+
+ function buildHeaders( table, msg ) {
+ var maxSeen = 0,
+ colspanOffset = 0,
+ columns,
+ i,
+ rowspan,
+ colspan,
+ headerCount,
+ longestTR,
+ exploded,
+ $tableHeaders = $( [] ),
+ $tableRows = $( 'thead:eq(0) > tr', table );
+ if ( $tableRows.length <= 1 ) {
+ $tableHeaders = $tableRows.children( 'th' );
+ } else {
+ exploded = [];
+
+ // Loop through all the dom cells of the thead
+ $tableRows.each( function ( rowIndex, row ) {
+ $.each( row.cells, function ( columnIndex, cell ) {
+ var matrixRowIndex,
+ matrixColumnIndex;
+
+ rowspan = Number( cell.rowSpan );
+ colspan = Number( cell.colSpan );
+
+ // Skip the spots in the exploded matrix that are already filled
+ while ( exploded[rowIndex] && exploded[rowIndex][columnIndex] !== undefined ) {
+ ++columnIndex;
+ }
+
+ // Find the actual dimensions of the thead, by placing each cell
+ // in the exploded matrix rowspan times colspan times, with the proper offsets
+ for ( matrixColumnIndex = columnIndex; matrixColumnIndex < columnIndex + colspan; ++matrixColumnIndex ) {
+ for ( matrixRowIndex = rowIndex; matrixRowIndex < rowIndex + rowspan; ++matrixRowIndex ) {
+ if ( !exploded[matrixRowIndex] ) {
+ exploded[matrixRowIndex] = [];
+ }
+ exploded[matrixRowIndex][matrixColumnIndex] = cell;
+ }
+ }
+ } );
+ } );
+ // We want to find the row that has the most columns (ignoring colspan)
+ $.each( exploded, function ( index, cellArray ) {
+ headerCount = $( uniqueElements( cellArray ) ).filter( 'th' ).length;
+ if ( headerCount >= maxSeen ) {
+ maxSeen = headerCount;
+ longestTR = index;
+ }
+ } );
+ // We cannot use $.unique() here because it sorts into dom order, which is undesirable
+ $tableHeaders = $( uniqueElements( exploded[longestTR] ) ).filter( 'th' );
+ }
+
+ // as each header can span over multiple columns (using colspan=N),
+ // we have to bidirectionally map headers to their columns and columns to their headers
+ table.headerToColumns = [];
+ table.columnToHeader = [];
+
+ $tableHeaders.each( function ( headerIndex ) {
+ columns = [];
+ for ( i = 0; i < this.colSpan; i++ ) {
+ table.columnToHeader[ colspanOffset + i ] = headerIndex;
+ columns.push( colspanOffset + i );
+ }
+
+ table.headerToColumns[ headerIndex ] = columns;
+ colspanOffset += this.colSpan;
+
+ this.headerIndex = headerIndex;
+ this.order = 0;
+ this.count = 0;
+
+ if ( $( this ).hasClass( table.config.unsortableClass ) ) {
+ this.sortDisabled = true;
+ }
+
+ if ( !this.sortDisabled ) {
+ $( this )
+ .addClass( table.config.cssHeader )
+ .prop( 'tabIndex', 0 )
+ .attr( {
+ role: 'columnheader button',
+ title: msg[1]
+ } );
+ }
+
+ // add cell to headerList
+ table.config.headerList[headerIndex] = this;
+ } );
+
+ return $tableHeaders;
+
+ }
+
+ /**
+ * Sets the sort count of the columns that are not affected by the sorting to have them sorted
+ * in default (ascending) order when their header cell is clicked the next time.
+ *
+ * @param {jQuery} $headers
+ * @param {Number[][]} sortList
+ * @param {Number[][]} headerToColumns
+ */
+ function setHeadersOrder( $headers, sortList, headerToColumns ) {
+ // Loop through all headers to retrieve the indices of the columns the header spans across:
+ $.each( headerToColumns, function ( headerIndex, columns ) {
+
+ $.each( columns, function ( i, columnIndex ) {
+ var header = $headers[headerIndex];
+
+ if ( !isValueInArray( columnIndex, sortList ) ) {
+ // Column shall not be sorted: Reset header count and order.
+ header.order = 0;
+ header.count = 0;
+ } else {
+ // Column shall be sorted: Apply designated count and order.
+ $.each( sortList, function ( j, sortColumn ) {
+ if ( sortColumn[0] === i ) {
+ header.order = sortColumn[1];
+ header.count = sortColumn[1] + 1;
+ return false;
+ }
+ } );
+ }
+ } );
+
+ } );
+ }
+
+ function isValueInArray( v, a ) {
+ var i,
+ len = a.length;
+ for ( i = 0; i < len; i++ ) {
+ if ( a[i][0] === v ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ function uniqueElements( array ) {
+ var uniques = [];
+ $.each( array, function ( index, elem ) {
+ if ( elem !== undefined && $.inArray( elem, uniques ) === -1 ) {
+ uniques.push( elem );
+ }
+ } );
+ return uniques;
+ }
+
+ function setHeadersCss( table, $headers, list, css, msg, columnToHeader ) {
+ // Remove all header information and reset titles to default message
+ $headers.removeClass( css[0] ).removeClass( css[1] ).attr( 'title', msg[1] );
+
+ for ( var i = 0; i < list.length; i++ ) {
+ $headers.eq( columnToHeader[ list[i][0] ] )
+ .addClass( css[ list[i][1] ] )
+ .attr( 'title', msg[ list[i][1] ] );
+ }
+ }
+
+ function sortText( a, b ) {
+ return ( ( a < b ) ? -1 : ( ( a > b ) ? 1 : 0 ) );
+ }
+
+ function sortTextDesc( a, b ) {
+ return ( ( b < a ) ? -1 : ( ( b > a ) ? 1 : 0 ) );
+ }
+
+ function multisort( table, sortList, cache ) {
+ var i,
+ sortFn = [],
+ len = sortList.length;
+ for ( i = 0; i < len; i++ ) {
+ sortFn[i] = ( sortList[i][1] ) ? sortTextDesc : sortText;
+ }
+ cache.normalized.sort( function ( array1, array2 ) {
+ var i, col, ret;
+ for ( i = 0; i < len; i++ ) {
+ col = sortList[i][0];
+ ret = sortFn[i].call( this, array1[col], array2[col] );
+ if ( ret !== 0 ) {
+ return ret;
+ }
+ }
+ // Fall back to index number column to ensure stable sort
+ return sortText.call( this, array1[array1.length - 1], array2[array2.length - 1] );
+ } );
+ return cache;
+ }
+
+ function buildTransformTable() {
+ var ascii, localised, i, digitClass,
+ digits = '0123456789,.'.split( '' ),
+ separatorTransformTable = mw.config.get( 'wgSeparatorTransformTable' ),
+ digitTransformTable = mw.config.get( 'wgDigitTransformTable' );
+
+ if ( separatorTransformTable === null || ( separatorTransformTable[0] === '' && digitTransformTable[2] === '' ) ) {
+ ts.transformTable = false;
+ } else {
+ ts.transformTable = {};
+
+ // Unpack the transform table
+ ascii = separatorTransformTable[0].split( '\t' ).concat( digitTransformTable[0].split( '\t' ) );
+ localised = separatorTransformTable[1].split( '\t' ).concat( digitTransformTable[1].split( '\t' ) );
+
+ // Construct regex for number identification
+ for ( i = 0; i < ascii.length; i++ ) {
+ ts.transformTable[localised[i]] = ascii[i];
+ digits.push( $.escapeRE( localised[i] ) );
+ }
+ }
+ digitClass = '[' + digits.join( '', digits ) + ']';
+
+ // We allow a trailing percent sign, which we just strip. This works fine
+ // if percents and regular numbers aren't being mixed.
+ ts.numberRegex = new RegExp( '^(' + '[-+\u2212]?[0-9][0-9,]*(\\.[0-9,]*)?(E[-+\u2212]?[0-9][0-9,]*)?' + // Fortran-style scientific
+ '|' + '[-+\u2212]?' + digitClass + '+[\\s\\xa0]*%?' + // Generic localised
+ ')$', 'i' );
+ }
+
+ function buildDateTable() {
+ var i, name,
+ regex = [];
+
+ ts.monthNames = {};
+
+ for ( i = 0; i < 12; i++ ) {
+ name = mw.language.months.names[i].toLowerCase();
+ ts.monthNames[name] = i + 1;
+ regex.push( $.escapeRE( name ) );
+ name = mw.language.months.genitive[i].toLowerCase();
+ ts.monthNames[name] = i + 1;
+ regex.push( $.escapeRE( name ) );
+ name = mw.language.months.abbrev[i].toLowerCase().replace( '.', '' );
+ ts.monthNames[name] = i + 1;
+ regex.push( $.escapeRE( name ) );
+ }
+
+ // Build piped string
+ regex = regex.join( '|' );
+
+ // Build RegEx
+ // Any date formated with . , ' - or /
+ ts.dateRegex[0] = new RegExp( /^\s*(\d{1,2})[\,\.\-\/'\s]{1,2}(\d{1,2})[\,\.\-\/'\s]{1,2}(\d{2,4})\s*?/i );
+
+ // Written Month name, dmy
+ ts.dateRegex[1] = new RegExp( '^\\s*(\\d{1,2})[\\,\\.\\-\\/\'\\s]+(' + regex + ')' + '[\\,\\.\\-\\/\'\\s]+(\\d{2,4})\\s*$', 'i' );
+
+ // Written Month name, mdy
+ ts.dateRegex[2] = new RegExp( '^\\s*(' + regex + ')' + '[\\,\\.\\-\\/\'\\s]+(\\d{1,2})[\\,\\.\\-\\/\'\\s]+(\\d{2,4})\\s*$', 'i' );
+
+ }
+
+ /**
+ * Replace all rowspanned cells in the body with clones in each row, so sorting
+ * need not worry about them.
+ *
+ * @param $table jQuery object for a <table>
+ */
+ function explodeRowspans( $table ) {
+ var spanningRealCellIndex, rowSpan, colSpan,
+ cell, i, $tds, $clone, $nextRows,
+ rowspanCells = $table.find( '> tbody > tr > [rowspan]' ).get();
+
+ // Short circuit
+ if ( !rowspanCells.length ) {
+ return;
+ }
+
+ // First, we need to make a property like cellIndex but taking into
+ // account colspans. We also cache the rowIndex to avoid having to take
+ // cell.parentNode.rowIndex in the sorting function below.
+ $table.find( '> tbody > tr' ).each( function () {
+ var i,
+ col = 0,
+ l = this.cells.length;
+ for ( i = 0; i < l; i++ ) {
+ this.cells[i].realCellIndex = col;
+ this.cells[i].realRowIndex = this.rowIndex;
+ col += this.cells[i].colSpan;
+ }
+ } );
+
+ // Split multi row cells into multiple cells with the same content.
+ // Sort by column then row index to avoid problems with odd table structures.
+ // Re-sort whenever a rowspanned cell's realCellIndex is changed, because it
+ // might change the sort order.
+ function resortCells() {
+ rowspanCells = rowspanCells.sort( function ( a, b ) {
+ var ret = a.realCellIndex - b.realCellIndex;
+ if ( !ret ) {
+ ret = a.realRowIndex - b.realRowIndex;
+ }
+ return ret;
+ } );
+ $.each( rowspanCells, function () {
+ this.needResort = false;
+ } );
+ }
+ resortCells();
+
+ function filterfunc() {
+ return this.realCellIndex >= spanningRealCellIndex;
+ }
+
+ function fixTdCellIndex() {
+ this.realCellIndex += colSpan;
+ if ( this.rowSpan > 1 ) {
+ this.needResort = true;
+ }
+ }
+
+ while ( rowspanCells.length ) {
+ if ( rowspanCells[0].needResort ) {
+ resortCells();
+ }
+
+ cell = rowspanCells.shift();
+ rowSpan = cell.rowSpan;
+ colSpan = cell.colSpan;
+ spanningRealCellIndex = cell.realCellIndex;
+ cell.rowSpan = 1;
+ $nextRows = $( cell ).parent().nextAll();
+ for ( i = 0; i < rowSpan - 1; i++ ) {
+ $tds = $( $nextRows[i].cells ).filter( filterfunc );
+ $clone = $( cell ).clone();
+ $clone[0].realCellIndex = spanningRealCellIndex;
+ if ( $tds.length ) {
+ $tds.each( fixTdCellIndex );
+ $tds.first().before( $clone );
+ } else {
+ $nextRows.eq( i ).append( $clone );
+ }
+ }
+ }
+ }
+
+ function buildCollationTable() {
+ ts.collationTable = mw.config.get( 'tableSorterCollation' );
+ ts.collationRegex = null;
+ if ( ts.collationTable ) {
+ var key,
+ keys = [];
+
+ // Build array of key names
+ for ( key in ts.collationTable ) {
+ // Check hasOwn to be safe
+ if ( ts.collationTable.hasOwnProperty( key ) ) {
+ keys.push( key );
+ }
+ }
+ if ( keys.length ) {
+ ts.collationRegex = new RegExp( '[' + keys.join( '' ) + ']', 'ig' );
+ }
+ }
+ }
+
+ function cacheRegexs() {
+ if ( ts.rgx ) {
+ return;
+ }
+ ts.rgx = {
+ IPAddress: [
+ new RegExp( /^\d{1,3}[\.]\d{1,3}[\.]\d{1,3}[\.]\d{1,3}$/ )
+ ],
+ currency: [
+ new RegExp( /(^[£$€¥]|[£$€¥]$)/ ),
+ new RegExp( /[£$€¥]/g )
+ ],
+ url: [
+ new RegExp( /^(https?|ftp|file):\/\/$/ ),
+ new RegExp( /(https?|ftp|file):\/\// )
+ ],
+ isoDate: [
+ new RegExp( /^\d{4}[\/\-]\d{1,2}[\/\-]\d{1,2}$/ )
+ ],
+ usLongDate: [
+ new RegExp( /^[A-Za-z]{3,10}\.? [0-9]{1,2}, ([0-9]{4}|'?[0-9]{2}) (([0-2]?[0-9]:[0-5][0-9])|([0-1]?[0-9]:[0-5][0-9]\s(AM|PM)))$/ )
+ ],
+ time: [
+ new RegExp( /^(([0-2]?[0-9]:[0-5][0-9])|([0-1]?[0-9]:[0-5][0-9]\s(am|pm)))$/ )
+ ]
+ };
+ }
+
+ /**
+ * Converts sort objects [ { Integer: String }, ... ] to the internally used nested array
+ * structure [ [ Integer , Integer ], ... ]
+ *
+ * @param sortObjects {Array} List of sort objects.
+ * @return {Array} List of internal sort definitions.
+ */
+
+ function convertSortList( sortObjects ) {
+ var sortList = [];
+ $.each( sortObjects, function ( i, sortObject ) {
+ $.each( sortObject, function ( columnIndex, order ) {
+ var orderIndex = ( order === 'desc' ) ? 1 : 0;
+ sortList.push( [parseInt( columnIndex, 10 ), orderIndex] );
+ } );
+ } );
+ return sortList;
+ }
+
+ /* Public scope */
+
+ $.tablesorter = {
+
+ defaultOptions: {
+ cssHeader: 'headerSort',
+ cssAsc: 'headerSortUp',
+ cssDesc: 'headerSortDown',
+ cssChildRow: 'expand-child',
+ sortInitialOrder: 'asc',
+ sortMultiSortKey: 'shiftKey',
+ sortLocaleCompare: false,
+ unsortableClass: 'unsortable',
+ parsers: {},
+ widgets: [],
+ headers: {},
+ cancelSelection: true,
+ sortList: [],
+ headerList: [],
+ selectorHeaders: 'thead tr:eq(0) th',
+ debug: false
+ },
+
+ dateRegex: [],
+ monthNames: {},
+
+ /**
+ * @param $tables {jQuery}
+ * @param settings {Object} (optional)
+ */
+ construct: function ( $tables, settings ) {
+ return $tables.each( function ( i, table ) {
+ // Declare and cache.
+ var $headers, cache, config, sortCSS, sortMsg,
+ $table = $( table ),
+ firstTime = true;
+
+ // Quit if no tbody
+ if ( !table.tBodies ) {
+ return;
+ }
+ if ( !table.tHead ) {
+ // No thead found. Look for rows with <th>s and
+ // move them into a <thead> tag or a <tfoot> tag
+ emulateTHeadAndFoot( $table );
+
+ // Still no thead? Then quit
+ if ( !table.tHead ) {
+ return;
+ }
+ }
+ $table.addClass( 'jquery-tablesorter' );
+
+ // FIXME config should probably not be stored in the plain table node
+ // New config object.
+ table.config = {};
+
+ // Merge and extend.
+ config = $.extend( table.config, $.tablesorter.defaultOptions, settings );
+
+ // Save the settings where they read
+ $.data( table, 'tablesorter', { config: config } );
+
+ // Get the CSS class names, could be done else where.
+ sortCSS = [ config.cssDesc, config.cssAsc ];
+ sortMsg = [ mw.msg( 'sort-descending' ), mw.msg( 'sort-ascending' ) ];
+
+ // Build headers
+ $headers = buildHeaders( table, sortMsg );
+
+ // Grab and process locale settings.
+ buildTransformTable();
+ buildDateTable();
+
+ // Precaching regexps can bring 10 fold
+ // performance improvements in some browsers.
+ cacheRegexs();
+
+ function setupForFirstSort() {
+ firstTime = false;
+
+ // Defer buildCollationTable to first sort. As user and site scripts
+ // may customize tableSorterCollation but load after $.ready(), other
+ // scripts may call .tablesorter() before they have done the
+ // tableSorterCollation customizations.
+ buildCollationTable();
+
+ // Legacy fix of .sortbottoms
+ // Wrap them inside inside a tfoot (because that's what they actually want to be) &
+ // and put the <tfoot> at the end of the <table>
+ var $tfoot,
+ $sortbottoms = $table.find( '> tbody > tr.sortbottom' );
+ if ( $sortbottoms.length ) {
+ $tfoot = $table.children( 'tfoot' );
+ if ( $tfoot.length ) {
+ $tfoot.eq( 0 ).prepend( $sortbottoms );
+ } else {
+ $table.append( $( '<tfoot>' ).append( $sortbottoms ) );
+ }
+ }
+
+ explodeRowspans( $table );
+
+ // try to auto detect column type, and store in tables config
+ table.config.parsers = buildParserCache( table, $headers );
+ }
+
+ // Apply event handling to headers
+ // this is too big, perhaps break it out?
+ $headers.not( '.' + table.config.unsortableClass ).on( 'keypress click', function ( e ) {
+ var cell, columns, newSortList, i,
+ totalRows,
+ j, s, o;
+
+ if ( e.type === 'click' && e.target.nodeName.toLowerCase() === 'a' ) {
+ // The user clicked on a link inside a table header.
+ // Do nothing and let the default link click action continue.
+ return true;
+ }
+
+ if ( e.type === 'keypress' && e.which !== 13 ) {
+ // Only handle keypresses on the "Enter" key.
+ return true;
+ }
+
+ if ( firstTime ) {
+ setupForFirstSort();
+ }
+
+ // Build the cache for the tbody cells
+ // to share between calculations for this sort action.
+ // Re-calculated each time a sort action is performed due to possiblity
+ // that sort values change. Shouldn't be too expensive, but if it becomes
+ // too slow an event based system should be implemented somehow where
+ // cells get event .change() and bubbles up to the <table> here
+ cache = buildCache( table );
+
+ totalRows = ( $table[0].tBodies[0] && $table[0].tBodies[0].rows.length ) || 0;
+ if ( !table.sortDisabled && totalRows > 0 ) {
+ // Get current column sort order
+ this.order = this.count % 2;
+ this.count++;
+
+ cell = this;
+ // Get current column index
+ columns = table.headerToColumns[ this.headerIndex ];
+ newSortList = $.map( columns, function ( c ) {
+ // jQuery "helpfully" flattens the arrays...
+ return [[c, cell.order]];
+ } );
+ // Index of first column belonging to this header
+ i = columns[0];
+
+ if ( !e[config.sortMultiSortKey] ) {
+ // User only wants to sort on one column set
+ // Flush the sort list and add new columns
+ config.sortList = newSortList;
+ } else {
+ // Multi column sorting
+ // It is not possible for one column to belong to multiple headers,
+ // so this is okay - we don't need to check for every value in the columns array
+ if ( isValueInArray( i, config.sortList ) ) {
+ // The user has clicked on an already sorted column.
+ // Reverse the sorting direction for all tables.
+ for ( j = 0; j < config.sortList.length; j++ ) {
+ s = config.sortList[j];
+ o = config.headerList[s[0]];
+ if ( isValueInArray( s[0], newSortList ) ) {
+ o.count = s[1];
+ o.count++;
+ s[1] = o.count % 2;
+ }
+ }
+ } else {
+ // Add columns to sort list array
+ config.sortList = config.sortList.concat( newSortList );
+ }
+ }
+
+ // Reset order/counts of cells not affected by sorting
+ setHeadersOrder( $headers, config.sortList, table.headerToColumns );
+
+ // Set CSS for headers
+ setHeadersCss( $table[0], $headers, config.sortList, sortCSS, sortMsg, table.columnToHeader );
+ appendToTable(
+ $table[0], multisort( $table[0], config.sortList, cache )
+ );
+
+ // Stop normal event by returning false
+ return false;
+ }
+
+ // Cancel selection
+ } ).mousedown( function () {
+ if ( config.cancelSelection ) {
+ this.onselectstart = function () {
+ return false;
+ };
+ return false;
+ }
+ } );
+
+ /**
+ * Sorts the table. If no sorting is specified by passing a list of sort
+ * objects, the table is sorted according to the initial sorting order.
+ * Passing an empty array will reset sorting (basically just reset the headers
+ * making the table appear unsorted).
+ *
+ * @param sortList {Array} (optional) List of sort objects.
+ */
+ $table.data( 'tablesorter' ).sort = function ( sortList ) {
+
+ if ( firstTime ) {
+ setupForFirstSort();
+ }
+
+ if ( sortList === undefined ) {
+ sortList = config.sortList;
+ } else if ( sortList.length > 0 ) {
+ sortList = convertSortList( sortList );
+ }
+
+ // Set each column's sort count to be able to determine the correct sort
+ // order when clicking on a header cell the next time
+ setHeadersOrder( $headers, sortList, table.headerToColumns );
+
+ // re-build the cache for the tbody cells
+ cache = buildCache( table );
+
+ // set css for headers
+ setHeadersCss( table, $headers, sortList, sortCSS, sortMsg, table.columnToHeader );
+
+ // sort the table and append it to the dom
+ appendToTable( table, multisort( table, sortList, cache ) );
+ };
+
+ // sort initially
+ if ( config.sortList.length > 0 ) {
+ setupForFirstSort();
+ config.sortList = convertSortList( config.sortList );
+ $table.data( 'tablesorter' ).sort();
+ }
+
+ } );
+ },
+
+ addParser: function ( parser ) {
+ var i,
+ len = parsers.length,
+ a = true;
+ for ( i = 0; i < len; i++ ) {
+ if ( parsers[i].id.toLowerCase() === parser.id.toLowerCase() ) {
+ a = false;
+ }
+ }
+ if ( a ) {
+ parsers.push( parser );
+ }
+ },
+
+ formatDigit: function ( s ) {
+ var out, c, p, i;
+ if ( ts.transformTable !== false ) {
+ out = '';
+ for ( p = 0; p < s.length; p++ ) {
+ c = s.charAt( p );
+ if ( c in ts.transformTable ) {
+ out += ts.transformTable[c];
+ } else {
+ out += c;
+ }
+ }
+ s = out;
+ }
+ i = parseFloat( s.replace( /[, ]/g, '' ).replace( '\u2212', '-' ) );
+ return isNaN( i ) ? 0 : i;
+ },
+
+ formatFloat: function ( s ) {
+ var i = parseFloat( s );
+ return isNaN( i ) ? 0 : i;
+ },
+
+ formatInt: function ( s ) {
+ var i = parseInt( s, 10 );
+ return isNaN( i ) ? 0 : i;
+ },
+
+ clearTableBody: function ( table ) {
+ $( table.tBodies[0] ).empty();
+ }
+ };
+
+ // Shortcut
+ ts = $.tablesorter;
+
+ // Register as jQuery prototype method
+ $.fn.tablesorter = function ( settings ) {
+ return ts.construct( this, settings );
+ };
+
+ // Add default parsers
+ ts.addParser( {
+ id: 'text',
+ is: function () {
+ return true;
+ },
+ format: function ( s ) {
+ s = $.trim( s.toLowerCase() );
+ if ( ts.collationRegex ) {
+ var tsc = ts.collationTable;
+ s = s.replace( ts.collationRegex, function ( match ) {
+ var r = tsc[match] ? tsc[match] : tsc[match.toUpperCase()];
+ return r.toLowerCase();
+ } );
+ }
+ return s;
+ },
+ type: 'text'
+ } );
+
+ ts.addParser( {
+ id: 'IPAddress',
+ is: function ( s ) {
+ return ts.rgx.IPAddress[0].test( s );
+ },
+ format: function ( s ) {
+ var i, item,
+ a = s.split( '.' ),
+ r = '',
+ len = a.length;
+ for ( i = 0; i < len; i++ ) {
+ item = a[i];
+ if ( item.length === 1 ) {
+ r += '00' + item;
+ } else if ( item.length === 2 ) {
+ r += '0' + item;
+ } else {
+ r += item;
+ }
+ }
+ return $.tablesorter.formatFloat( r );
+ },
+ type: 'numeric'
+ } );
+
+ ts.addParser( {
+ id: 'currency',
+ is: function ( s ) {
+ return ts.rgx.currency[0].test( s );
+ },
+ format: function ( s ) {
+ return $.tablesorter.formatDigit( s.replace( ts.rgx.currency[1], '' ) );
+ },
+ type: 'numeric'
+ } );
+
+ ts.addParser( {
+ id: 'url',
+ is: function ( s ) {
+ return ts.rgx.url[0].test( s );
+ },
+ format: function ( s ) {
+ return $.trim( s.replace( ts.rgx.url[1], '' ) );
+ },
+ type: 'text'
+ } );
+
+ ts.addParser( {
+ id: 'isoDate',
+ is: function ( s ) {
+ return ts.rgx.isoDate[0].test( s );
+ },
+ format: function ( s ) {
+ return $.tablesorter.formatFloat( ( s !== '' ) ? new Date( s.replace(
+ new RegExp( /-/g ), '/' ) ).getTime() : '0' );
+ },
+ type: 'numeric'
+ } );
+
+ ts.addParser( {
+ id: 'usLongDate',
+ is: function ( s ) {
+ return ts.rgx.usLongDate[0].test( s );
+ },
+ format: function ( s ) {
+ return $.tablesorter.formatFloat( new Date( s ).getTime() );
+ },
+ type: 'numeric'
+ } );
+
+ ts.addParser( {
+ id: 'date',
+ is: function ( s ) {
+ return ( ts.dateRegex[0].test( s ) || ts.dateRegex[1].test( s ) || ts.dateRegex[2].test( s ) );
+ },
+ format: function ( s ) {
+ var match, y;
+ s = $.trim( s.toLowerCase() );
+
+ if ( ( match = s.match( ts.dateRegex[0] ) ) !== null ) {
+ if ( mw.config.get( 'wgDefaultDateFormat' ) === 'mdy' || mw.config.get( 'wgContentLanguage' ) === 'en' ) {
+ s = [ match[3], match[1], match[2] ];
+ } else if ( mw.config.get( 'wgDefaultDateFormat' ) === 'dmy' ) {
+ s = [ match[3], match[2], match[1] ];
+ } else {
+ // If we get here, we don't know which order the dd-dd-dddd
+ // date is in. So return something not entirely invalid.
+ return '99999999';
+ }
+ } else if ( ( match = s.match( ts.dateRegex[1] ) ) !== null ) {
+ s = [ match[3], '' + ts.monthNames[match[2]], match[1] ];
+ } else if ( ( match = s.match( ts.dateRegex[2] ) ) !== null ) {
+ s = [ match[3], '' + ts.monthNames[match[1]], match[2] ];
+ } else {
+ // Should never get here
+ return '99999999';
+ }
+
+ // Pad Month and Day
+ if ( s[1].length === 1 ) {
+ s[1] = '0' + s[1];
+ }
+ if ( s[2].length === 1 ) {
+ s[2] = '0' + s[2];
+ }
+
+ if ( ( y = parseInt( s[0], 10 ) ) < 100 ) {
+ // Guestimate years without centuries
+ if ( y < 30 ) {
+ s[0] = 2000 + y;
+ } else {
+ s[0] = 1900 + y;
+ }
+ }
+ while ( s[0].length < 4 ) {
+ s[0] = '0' + s[0];
+ }
+ return parseInt( s.join( '' ), 10 );
+ },
+ type: 'numeric'
+ } );
+
+ ts.addParser( {
+ id: 'time',
+ is: function ( s ) {
+ return ts.rgx.time[0].test( s );
+ },
+ format: function ( s ) {
+ return $.tablesorter.formatFloat( new Date( '2000/01/01 ' + s ).getTime() );
+ },
+ type: 'numeric'
+ } );
+
+ ts.addParser( {
+ id: 'number',
+ is: function ( s ) {
+ return $.tablesorter.numberRegex.test( $.trim( s ) );
+ },
+ format: function ( s ) {
+ return $.tablesorter.formatDigit( s );
+ },
+ type: 'numeric'
+ } );
+
+}( jQuery, mediaWiki ) );
diff --git a/resources/src/jquery/jquery.textSelection.js b/resources/src/jquery/jquery.textSelection.js
new file mode 100644
index 00000000..8d440fdc
--- /dev/null
+++ b/resources/src/jquery/jquery.textSelection.js
@@ -0,0 +1,572 @@
+/**
+ * These plugins provide extra functionality for interaction with textareas.
+ */
+( function ( $ ) {
+ if ( document.selection && document.selection.createRange ) {
+ // On IE, patch the focus() method to restore the windows' scroll position
+ // (bug 32241)
+ $.fn.extend( {
+ focus: ( function ( jqFocus ) {
+ return function () {
+ var $w, state, result;
+ if ( arguments.length === 0 ) {
+ $w = $( window );
+ state = { top: $w.scrollTop(), left: $w.scrollLeft() };
+ result = jqFocus.apply( this, arguments );
+ window.scrollTo( state.top, state.left );
+ return result;
+ }
+ return jqFocus.apply( this, arguments );
+ };
+ }( $.fn.focus ) )
+ } );
+ }
+
+ $.fn.textSelection = function ( command, options ) {
+ var fn,
+ context,
+ hasWikiEditorSurface, // The alt edit surface needs to implement the WikiEditor API
+ needSave,
+ retval;
+
+ /**
+ * Helper function to get an IE TextRange object for an element
+ */
+ function rangeForElementIE( e ) {
+ if ( e.nodeName.toLowerCase() === 'input' ) {
+ return e.createTextRange();
+ } else {
+ var sel = document.body.createTextRange();
+ sel.moveToElementText( e );
+ return sel;
+ }
+ }
+
+ /**
+ * Helper function for IE for activating the textarea. Called only in the
+ * IE-specific code paths below; makes use of IE-specific non-standard
+ * function setActive() if possible to avoid screen flicker.
+ */
+ function activateElementOnIE( element ) {
+ if ( element.setActive ) {
+ element.setActive(); // bug 32241: doesn't scroll
+ } else {
+ $( element ).focus(); // may scroll (but we patched it above)
+ }
+ }
+
+ fn = {
+ /**
+ * Get the contents of the textarea
+ */
+ getContents: function () {
+ return this.val();
+ },
+ /**
+ * Set the contents of the textarea, replacing anything that was there before
+ */
+ setContents: function ( content ) {
+ this.val( content );
+ },
+ /**
+ * Get the currently selected text in this textarea. Will focus the textarea
+ * in some browsers (IE/Opera)
+ */
+ getSelection: function () {
+ var retval, range,
+ el = this.get( 0 );
+
+ if ( !el || $( el ).is( ':hidden' ) ) {
+ retval = '';
+ } else if ( document.selection && document.selection.createRange ) {
+ activateElementOnIE( el );
+ range = document.selection.createRange();
+ retval = range.text;
+ } else if ( el.selectionStart || el.selectionStart === 0 ) {
+ retval = el.value.substring( el.selectionStart, el.selectionEnd );
+ }
+
+ return retval;
+ },
+ /**
+ * Ported from skins/common/edit.js by Trevor Parscal
+ * (c) 2009 Wikimedia Foundation (GPLv2) - http://www.wikimedia.org
+ *
+ * Inserts text at the beginning and end of a text selection, optionally
+ * inserting text at the caret when selection is empty.
+ *
+ * @fixme document the options parameters
+ */
+ encapsulateSelection: function ( options ) {
+ return this.each( function () {
+ var selText, scrollTop, insertText,
+ isSample, range, range2, range3, startPos, endPos,
+ pre = options.pre,
+ post = options.post;
+
+ /**
+ * Check if the selected text is the same as the insert text
+ */
+ function checkSelectedText() {
+ if ( !selText ) {
+ selText = options.peri;
+ isSample = true;
+ } else if ( options.replace ) {
+ selText = options.peri;
+ } else {
+ while ( selText.charAt( selText.length - 1 ) === ' ' ) {
+ // Exclude ending space char
+ selText = selText.slice( 0, -1 );
+ post += ' ';
+ }
+ while ( selText.charAt( 0 ) === ' ' ) {
+ // Exclude prepending space char
+ selText = selText.slice( 1 );
+ pre = ' ' + pre;
+ }
+ }
+ }
+
+ /**
+ * Do the splitlines stuff.
+ *
+ * Wrap each line of the selected text with pre and post
+ */
+ function doSplitLines( selText, pre, post ) {
+ var i,
+ insertText = '',
+ selTextArr = selText.split( '\n' );
+ for ( i = 0; i < selTextArr.length; i++ ) {
+ insertText += pre + selTextArr[i] + post;
+ if ( i !== selTextArr.length - 1 ) {
+ insertText += '\n';
+ }
+ }
+ return insertText;
+ }
+
+ isSample = false;
+ // Do nothing if display none
+ if ( this.style.display !== 'none' ) {
+ if ( document.selection && document.selection.createRange ) {
+ // IE
+
+ // Note that IE9 will trigger the next section unless we check this first.
+ // See bug 35201.
+
+ activateElementOnIE( this );
+ if ( context ) {
+ context.fn.restoreCursorAndScrollTop();
+ }
+ if ( options.selectionStart !== undefined ) {
+ $( this ).textSelection( 'setSelection', { 'start': options.selectionStart, 'end': options.selectionEnd } );
+ }
+
+ selText = $( this ).textSelection( 'getSelection' );
+ scrollTop = this.scrollTop;
+ range = document.selection.createRange();
+
+ checkSelectedText();
+ insertText = pre + selText + post;
+ if ( options.splitlines ) {
+ insertText = doSplitLines( selText, pre, post );
+ }
+ if ( options.ownline && range.moveStart ) {
+ range2 = document.selection.createRange();
+ range2.collapse();
+ range2.moveStart( 'character', -1 );
+ // FIXME: Which check is correct?
+ if ( range2.text !== '\r' && range2.text !== '\n' && range2.text !== '' ) {
+ insertText = '\n' + insertText;
+ pre += '\n';
+ }
+ range3 = document.selection.createRange();
+ range3.collapse( false );
+ range3.moveEnd( 'character', 1 );
+ if ( range3.text !== '\r' && range3.text !== '\n' && range3.text !== '' ) {
+ insertText += '\n';
+ post += '\n';
+ }
+ }
+
+ range.text = insertText;
+ if ( isSample && options.selectPeri && range.moveStart ) {
+ range.moveStart( 'character', -post.length - selText.length );
+ range.moveEnd( 'character', -post.length );
+ }
+ range.select();
+ // Restore the scroll position
+ this.scrollTop = scrollTop;
+ } else if ( this.selectionStart || this.selectionStart === 0 ) {
+ // Mozilla/Opera
+
+ $( this ).focus();
+ if ( options.selectionStart !== undefined ) {
+ $( this ).textSelection( 'setSelection', { 'start': options.selectionStart, 'end': options.selectionEnd } );
+ }
+
+ selText = $( this ).textSelection( 'getSelection' );
+ startPos = this.selectionStart;
+ endPos = this.selectionEnd;
+ scrollTop = this.scrollTop;
+ checkSelectedText();
+ if ( options.selectionStart !== undefined
+ && endPos - startPos !== options.selectionEnd - options.selectionStart )
+ {
+ // This means there is a difference in the selection range returned by browser and what we passed.
+ // This happens for Chrome in the case of composite characters. Ref bug #30130
+ // Set the startPos to the correct position.
+ startPos = options.selectionStart;
+ }
+
+ insertText = pre + selText + post;
+ if ( options.splitlines ) {
+ insertText = doSplitLines( selText, pre, post );
+ }
+ if ( options.ownline ) {
+ if ( startPos !== 0 && this.value.charAt( startPos - 1 ) !== '\n' && this.value.charAt( startPos - 1 ) !== '\r' ) {
+ insertText = '\n' + insertText;
+ pre += '\n';
+ }
+ if ( this.value.charAt( endPos ) !== '\n' && this.value.charAt( endPos ) !== '\r' ) {
+ insertText += '\n';
+ post += '\n';
+ }
+ }
+ this.value = this.value.slice( 0, startPos ) + insertText +
+ this.value.slice( endPos );
+ // Setting this.value scrolls the textarea to the top, restore the scroll position
+ this.scrollTop = scrollTop;
+ if ( window.opera ) {
+ pre = pre.replace( /\r?\n/g, '\r\n' );
+ selText = selText.replace( /\r?\n/g, '\r\n' );
+ post = post.replace( /\r?\n/g, '\r\n' );
+ }
+ if ( isSample && options.selectPeri && !options.splitlines ) {
+ this.selectionStart = startPos + pre.length;
+ this.selectionEnd = startPos + pre.length + selText.length;
+ } else {
+ this.selectionStart = startPos + insertText.length;
+ this.selectionEnd = this.selectionStart;
+ }
+ }
+ }
+ $( this ).trigger( 'encapsulateSelection', [ options.pre, options.peri, options.post, options.ownline,
+ options.replace, options.spitlines ] );
+ } );
+ },
+ /**
+ * Ported from Wikia's LinkSuggest extension
+ * https://svn.wikia-code.com/wikia/trunk/extensions/wikia/LinkSuggest
+ * Some code copied from
+ * http://www.dedestruct.com/2008/03/22/howto-cross-browser-cursor-position-in-textareas/
+ *
+ * Get the position (in resolution of bytes not necessarily characters)
+ * in a textarea
+ *
+ * Will focus the textarea in some browsers (IE/Opera)
+ *
+ * @fixme document the options parameters
+ */
+ getCaretPosition: function ( options ) {
+ function getCaret( e ) {
+ var caretPos = 0,
+ endPos = 0,
+ preText, rawPreText, periText,
+ rawPeriText, postText, rawPostText,
+ // IE Support
+ preFinished,
+ periFinished,
+ postFinished,
+ // Range containing text in the selection
+ periRange,
+ // Range containing text before the selection
+ preRange,
+ // Range containing text after the selection
+ postRange;
+
+ if ( e && document.selection && document.selection.createRange ) {
+ // IE doesn't properly report non-selected caret position through
+ // the selection ranges when textarea isn't focused. This can
+ // lead to saving a bogus empty selection, which then screws up
+ // whatever we do later (bug 31847).
+ activateElementOnIE( e );
+
+ preFinished = false;
+ periFinished = false;
+ postFinished = false;
+ periRange = document.selection.createRange().duplicate();
+
+ preRange = rangeForElementIE( e );
+ // Move the end where we need it
+ preRange.setEndPoint( 'EndToStart', periRange );
+
+ postRange = rangeForElementIE( e );
+ // Move the start where we need it
+ postRange.setEndPoint( 'StartToEnd', periRange );
+
+ // Load the text values we need to compare
+ preText = rawPreText = preRange.text;
+ periText = rawPeriText = periRange.text;
+ postText = rawPostText = postRange.text;
+
+ /*
+ * Check each range for trimmed newlines by shrinking the range by 1
+ * character and seeing if the text property has changed. If it has
+ * not changed then we know that IE has trimmed a \r\n from the end.
+ */
+ do {
+ if ( !preFinished ) {
+ if ( preRange.compareEndPoints( 'StartToEnd', preRange ) === 0 ) {
+ preFinished = true;
+ } else {
+ preRange.moveEnd( 'character', -1 );
+ if ( preRange.text === preText ) {
+ rawPreText += '\r\n';
+ } else {
+ preFinished = true;
+ }
+ }
+ }
+ if ( !periFinished ) {
+ if ( periRange.compareEndPoints( 'StartToEnd', periRange ) === 0 ) {
+ periFinished = true;
+ } else {
+ periRange.moveEnd( 'character', -1 );
+ if ( periRange.text === periText ) {
+ rawPeriText += '\r\n';
+ } else {
+ periFinished = true;
+ }
+ }
+ }
+ if ( !postFinished ) {
+ if ( postRange.compareEndPoints( 'StartToEnd', postRange ) === 0 ) {
+ postFinished = true;
+ } else {
+ postRange.moveEnd( 'character', -1 );
+ if ( postRange.text === postText ) {
+ rawPostText += '\r\n';
+ } else {
+ postFinished = true;
+ }
+ }
+ }
+ } while ( ( !preFinished || !periFinished || !postFinished ) );
+ caretPos = rawPreText.replace( /\r\n/g, '\n' ).length;
+ endPos = caretPos + rawPeriText.replace( /\r\n/g, '\n' ).length;
+ } else if ( e && ( e.selectionStart || e.selectionStart === 0 ) ) {
+ // Firefox support
+ caretPos = e.selectionStart;
+ endPos = e.selectionEnd;
+ }
+ return options.startAndEnd ? [ caretPos, endPos ] : caretPos;
+ }
+ return getCaret( this.get( 0 ) );
+ },
+ /**
+ * @fixme document the options parameters
+ */
+ setSelection: function ( options ) {
+ return this.each( function () {
+ var selection, length, newLines;
+ // Do nothing if hidden
+ if ( !$( this ).is( ':hidden' ) ) {
+ if ( this.selectionStart || this.selectionStart === 0 ) {
+ // Opera 9.0 doesn't allow setting selectionStart past
+ // selectionEnd; any attempts to do that will be ignored
+ // Make sure to set them in the right order
+ if ( options.start > this.selectionEnd ) {
+ this.selectionEnd = options.end;
+ this.selectionStart = options.start;
+ } else {
+ this.selectionStart = options.start;
+ this.selectionEnd = options.end;
+ }
+ } else if ( document.body.createTextRange ) {
+ selection = rangeForElementIE( this );
+ length = this.value.length;
+ // IE doesn't count \n when computing the offset, so we won't either
+ newLines = this.value.match( /\n/g );
+ if ( newLines ) {
+ length = length - newLines.length;
+ }
+ selection.moveStart( 'character', options.start );
+ selection.moveEnd( 'character', -length + options.end );
+
+ // This line can cause an error under certain circumstances (textarea empty, no selection)
+ // Silence that error
+ try {
+ selection.select();
+ } catch ( e ) { }
+ }
+ }
+ } );
+ },
+ /**
+ * Ported from Wikia's LinkSuggest extension
+ * https://svn.wikia-code.com/wikia/trunk/extensions/wikia/LinkSuggest
+ *
+ * Scroll a textarea to the current cursor position. You can set the cursor
+ * position with setSelection()
+ * @param options boolean Whether to force a scroll even if the caret position
+ * is already visible. Defaults to false
+ *
+ * @fixme document the options parameters (function body suggests options.force is a boolean, not options itself)
+ */
+ scrollToCaretPosition: function ( options ) {
+ function getLineLength( e ) {
+ return Math.floor( e.scrollWidth / ( $.client.profile().platform === 'linux' ? 7 : 8 ) );
+ }
+ function getCaretScrollPosition( e ) {
+ // FIXME: This functions sucks and is off by a few lines most
+ // of the time. It should be replaced by something decent.
+ var i, j,
+ nextSpace,
+ text = e.value.replace( /\r/g, '' ),
+ caret = $( e ).textSelection( 'getCaretPosition' ),
+ lineLength = getLineLength( e ),
+ row = 0,
+ charInLine = 0,
+ lastSpaceInLine = 0;
+
+ for ( i = 0; i < caret; i++ ) {
+ charInLine++;
+ if ( text.charAt( i ) === ' ' ) {
+ lastSpaceInLine = charInLine;
+ } else if ( text.charAt( i ) === '\n' ) {
+ lastSpaceInLine = 0;
+ charInLine = 0;
+ row++;
+ }
+ if ( charInLine > lineLength ) {
+ if ( lastSpaceInLine > 0 ) {
+ charInLine = charInLine - lastSpaceInLine;
+ lastSpaceInLine = 0;
+ row++;
+ }
+ }
+ }
+ nextSpace = 0;
+ for ( j = caret; j < caret + lineLength; j++ ) {
+ if (
+ text.charAt( j ) === ' ' ||
+ text.charAt( j ) === '\n' ||
+ caret === text.length
+ ) {
+ nextSpace = j;
+ break;
+ }
+ }
+ if ( nextSpace > lineLength && caret <= lineLength ) {
+ charInLine = caret - lastSpaceInLine;
+ row++;
+ }
+ return ( $.client.profile().platform === 'mac' ? 13 : ( $.client.profile().platform === 'linux' ? 15 : 16 ) ) * row;
+ }
+ return this.each( function () {
+ var scroll, range, savedRange, pos, oldScrollTop;
+ // Do nothing if hidden
+ if ( !$( this ).is( ':hidden' ) ) {
+ if ( this.selectionStart || this.selectionStart === 0 ) {
+ // Mozilla
+ scroll = getCaretScrollPosition( this );
+ if ( options.force || scroll < $( this ).scrollTop() ||
+ scroll > $( this ).scrollTop() + $( this ).height() ) {
+ $( this ).scrollTop( scroll );
+ }
+ } else if ( document.selection && document.selection.createRange ) {
+ // IE / Opera
+ /*
+ * IE automatically scrolls the selected text to the
+ * bottom of the textarea at range.select() time, except
+ * if it was already in view and the cursor position
+ * wasn't changed, in which case it does nothing. To
+ * cover that case, we'll force it to act by moving one
+ * character back and forth.
+ */
+ range = document.body.createTextRange();
+ savedRange = document.selection.createRange();
+ pos = $( this ).textSelection( 'getCaretPosition' );
+ oldScrollTop = this.scrollTop;
+ range.moveToElementText( this );
+ range.collapse();
+ range.move( 'character', pos + 1 );
+ range.select();
+ if ( this.scrollTop !== oldScrollTop ) {
+ this.scrollTop += range.offsetTop;
+ } else if ( options.force ) {
+ range.move( 'character', -1 );
+ range.select();
+ }
+ savedRange.select();
+ }
+ }
+ $( this ).trigger( 'scrollToPosition' );
+ } );
+ }
+ };
+
+ // Apply defaults
+ switch ( command ) {
+ //case 'getContents': // no params
+ //case 'setContents': // no params with defaults
+ //case 'getSelection': // no params
+ case 'encapsulateSelection':
+ options = $.extend( {
+ pre: '', // Text to insert before the cursor/selection
+ peri: '', // Text to insert between pre and post and select afterwards
+ post: '', // Text to insert after the cursor/selection
+ ownline: false, // Put the inserted text on a line of its own
+ replace: false, // If there is a selection, replace it with peri instead of leaving it alone
+ selectPeri: true, // Select the peri text if it was inserted (but not if there was a selection and replace==false, or if splitlines==true)
+ splitlines: false, // If multiple lines are selected, encapsulate each line individually
+ selectionStart: undefined, // Position to start selection at
+ selectionEnd: undefined // Position to end selection at. Defaults to start
+ }, options );
+ break;
+ case 'getCaretPosition':
+ options = $.extend( {
+ // Return [start, end] instead of just start
+ startAndEnd: false
+ }, options );
+ // FIXME: We may not need character position-based functions if we insert markers in the right places
+ break;
+ case 'setSelection':
+ options = $.extend( {
+ // Position to start selection at
+ start: undefined,
+ // Position to end selection at. Defaults to start
+ end: undefined
+ }, options );
+
+ if ( options.end === undefined ) {
+ options.end = options.start;
+ }
+ // FIXME: We may not need character position-based functions if we insert markers in the right places
+ break;
+ case 'scrollToCaretPosition':
+ options = $.extend( {
+ force: false // Force a scroll even if the caret position is already visible
+ }, options );
+ break;
+ }
+
+ context = $( this ).data( 'wikiEditor-context' );
+ hasWikiEditorSurface = ( context !== undefined && context.$iframe !== undefined );
+
+ // IE selection restore voodoo
+ needSave = false;
+ if ( hasWikiEditorSurface && context.savedSelection !== null ) {
+ context.fn.restoreSelection();
+ needSave = true;
+ }
+ retval = ( hasWikiEditorSurface && context.fn[command] !== undefined ? context.fn : fn )[command].call( this, options );
+ if ( hasWikiEditorSurface && needSave ) {
+ context.fn.saveSelection();
+ }
+
+ return retval;
+ };
+
+}( jQuery ) );
diff --git a/resources/src/json-skip.js b/resources/src/json-skip.js
new file mode 100644
index 00000000..0a1e1c26
--- /dev/null
+++ b/resources/src/json-skip.js
@@ -0,0 +1,4 @@
+/*!
+ * Skip function for json2.js.
+ */
+return !!( window.JSON && JSON.stringify && JSON.parse );
diff --git a/resources/src/mediawiki.action/images/green-checkmark.png b/resources/src/mediawiki.action/images/green-checkmark.png
new file mode 100644
index 00000000..8ec604ea
--- /dev/null
+++ b/resources/src/mediawiki.action/images/green-checkmark.png
Binary files differ
diff --git a/resources/src/mediawiki.action/images/nextredirect-ltr.png b/resources/src/mediawiki.action/images/nextredirect-ltr.png
new file mode 100644
index 00000000..cd657c33
--- /dev/null
+++ b/resources/src/mediawiki.action/images/nextredirect-ltr.png
Binary files differ
diff --git a/resources/src/mediawiki.action/images/nextredirect-rtl.png b/resources/src/mediawiki.action/images/nextredirect-rtl.png
new file mode 100644
index 00000000..b788f334
--- /dev/null
+++ b/resources/src/mediawiki.action/images/nextredirect-rtl.png
Binary files differ
diff --git a/resources/src/mediawiki.action/images/redirect-ltr.png b/resources/src/mediawiki.action/images/redirect-ltr.png
new file mode 100644
index 00000000..695f2a13
--- /dev/null
+++ b/resources/src/mediawiki.action/images/redirect-ltr.png
Binary files differ
diff --git a/resources/src/mediawiki.action/images/redirect-rtl.png b/resources/src/mediawiki.action/images/redirect-rtl.png
new file mode 100644
index 00000000..c954a2ad
--- /dev/null
+++ b/resources/src/mediawiki.action/images/redirect-rtl.png
Binary files differ
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.collapsibleFooter.css b/resources/src/mediawiki.action/mediawiki.action.edit.collapsibleFooter.css
new file mode 100644
index 00000000..1af4a7a0
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.collapsibleFooter.css
@@ -0,0 +1,17 @@
+/* Styles for collapsible lists of templates used and hidden categories */
+.mw-editfooter-toggler {
+ cursor: pointer;
+ background-position: left center;
+ padding-left: 16px;
+}
+
+.mw-editfooter-list {
+ margin-bottom: 1em;
+ margin-left: 2.5em;
+}
+
+/* Show/hide animation is incorrect if the table has a margin set. Extra
+ * "table.wikitable" is needed in the selector for CSS specificity. */
+table.wikitable.preview-limit-report {
+ margin: 0;
+}
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.collapsibleFooter.js b/resources/src/mediawiki.action/mediawiki.action.edit.collapsibleFooter.js
new file mode 100644
index 00000000..7ae51aba
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.collapsibleFooter.js
@@ -0,0 +1,54 @@
+jQuery( document ).ready( function ( $ ) {
+ var collapsibleLists, i, handleOne;
+
+ // Collapsible lists of categories and templates
+ collapsibleLists = [
+ {
+ $list: $( '.templatesUsed ul' ),
+ $toggler: $( '.mw-templatesUsedExplanation' ),
+ cookieName: 'templates-used-list'
+ },
+ {
+ $list: $( '.hiddencats ul' ),
+ $toggler: $( '.mw-hiddenCategoriesExplanation' ),
+ cookieName: 'hidden-categories-list'
+ },
+ {
+ $list: $( '.preview-limit-report-wrapper' ),
+ $toggler: $( '.mw-limitReportExplanation' ),
+ cookieName: 'preview-limit-report'
+ }
+ ];
+
+ handleOne = function ( $list, $toggler, cookieName ) {
+ var isCollapsed = $.cookie( cookieName ) !== 'expanded';
+
+ // Style the toggler with an arrow icon and add a tabIndex and a role for accessibility
+ $toggler.addClass( 'mw-editfooter-toggler' ).prop( 'tabIndex', 0 ).attr( 'role', 'button' );
+ $list.addClass( 'mw-editfooter-list' );
+
+ $list.makeCollapsible( {
+ $customTogglers: $toggler,
+ linksPassthru: true,
+ plainMode: true,
+ collapsed: isCollapsed
+ } );
+
+ $toggler.addClass( isCollapsed ? 'mw-icon-arrow-collapsed' : 'mw-icon-arrow-expanded' );
+
+ $list.on( 'beforeExpand.mw-collapsible', function () {
+ $toggler.removeClass( 'mw-icon-arrow-collapsed' ).addClass( 'mw-icon-arrow-expanded' );
+ $.cookie( cookieName, 'expanded' );
+ } );
+
+ $list.on( 'beforeCollapse.mw-collapsible', function () {
+ $toggler.removeClass( 'mw-icon-arrow-expanded' ).addClass( 'mw-icon-arrow-collapsed' );
+ $.cookie( cookieName, 'collapsed' );
+ } );
+ };
+
+ for ( i = 0; i < collapsibleLists.length; i++ ) {
+ // Pass to a function for iteration-local variables
+ handleOne( collapsibleLists[i].$list, collapsibleLists[i].$toggler, collapsibleLists[i].cookieName );
+ }
+} );
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.css b/resources/src/mediawiki.action/mediawiki.action.edit.css
new file mode 100644
index 00000000..45ba5437
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.css
@@ -0,0 +1,18 @@
+/*!
+ * Styles for elements of the editing form, loaded only when JavaScript is enabled.
+ */
+
+.mw-toolbar-editbutton {
+ width: 23px;
+ height: 22px;
+ cursor: pointer;
+ vertical-align: middle;
+ /* Cross-browser inline-block */
+ /* Firefox 2 */
+ display: -moz-inline-block;
+ /* Modern browsers */
+ display: inline-block;
+ /* IE7 */
+ zoom: 1;
+ *display: inline;
+}
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.editWarning.js b/resources/src/mediawiki.action/mediawiki.action.edit.editWarning.js
new file mode 100644
index 00000000..b5654400
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.editWarning.js
@@ -0,0 +1,59 @@
+/*
+ * Javascript for module editWarning
+ */
+( function ( mw, $ ) {
+ 'use strict';
+
+ $( function () {
+ var savedWindowOnBeforeUnload,
+ $wpTextbox1 = $( '#wpTextbox1' ),
+ $wpSummary = $( '#wpSummary' );
+ // Check if EditWarning is enabled and if we need it
+ if ( $wpTextbox1.length === 0 ) {
+ return true;
+ }
+ // Get the original values of some form elements
+ $wpTextbox1.add( $wpSummary ).each( function () {
+ $( this ).data( 'origtext', $( this ).val() );
+ } );
+ $( window )
+ .on( 'beforeunload.editwarning', function () {
+ var retval;
+
+ // Check if the current values of some form elements are the same as
+ // the original values
+ if (
+ mw.config.get( 'wgAction' ) === 'submit' ||
+ $wpTextbox1.data( 'origtext' ) !== $wpTextbox1.textSelection( 'getContents' ) ||
+ $wpSummary.data( 'origtext' ) !== $wpSummary.textSelection( 'getContents' )
+ ) {
+ // Return our message
+ retval = mw.msg( 'editwarning-warning' );
+ }
+
+ // Unset the onbeforeunload handler so we don't break page caching in Firefox
+ savedWindowOnBeforeUnload = window.onbeforeunload;
+ window.onbeforeunload = null;
+ if ( retval !== undefined ) {
+ // ...but if the user chooses not to leave the page, we need to rebind it
+ setTimeout( function () {
+ window.onbeforeunload = savedWindowOnBeforeUnload;
+ }, 1 );
+ return retval;
+ }
+ } )
+ .on( 'pageshow.editwarning', function () {
+ // Re-add onbeforeunload handler
+ if ( !window.onbeforeunload ) {
+ window.onbeforeunload = savedWindowOnBeforeUnload;
+ }
+ } );
+
+ // Add form submission handler
+ $( '#editform' ).submit( function () {
+ // Unbind our handlers
+ $( window ).off( '.editwarning' );
+ } );
+ } );
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.js b/resources/src/mediawiki.action/mediawiki.action.edit.js
new file mode 100644
index 00000000..4519b049
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.js
@@ -0,0 +1,217 @@
+/**
+ * Interface for the classic edit toolbar.
+ *
+ * @class mw.toolbar
+ * @singleton
+ */
+( function ( mw, $ ) {
+ var toolbar, isReady, $toolbar, queue, slice, $currentFocused;
+
+ /**
+ * Internal helper that does the actual insertion of the button into the toolbar.
+ *
+ * See #addButton for parameter documentation.
+ *
+ * @private
+ */
+ function insertButton( b, speedTip, tagOpen, tagClose, sampleText, imageId ) {
+ var $button;
+
+ // Backwards compatibility
+ if ( typeof b !== 'object' ) {
+ b = {
+ imageFile: b,
+ speedTip: speedTip,
+ tagOpen: tagOpen,
+ tagClose: tagClose,
+ sampleText: sampleText,
+ imageId: imageId
+ };
+ }
+
+ if ( b.imageFile ) {
+ $button = $( '<img>' ).attr( {
+ src: b.imageFile,
+ alt: b.speedTip,
+ title: b.speedTip,
+ id: b.imageId || undefined,
+ 'class': 'mw-toolbar-editbutton'
+ } );
+ } else {
+ $button = $( '<div>' ).attr( {
+ title: b.speedTip,
+ id: b.imageId || undefined,
+ 'class': 'mw-toolbar-editbutton'
+ } );
+ }
+
+ $button.click( function ( e ) {
+ if ( b.onClick !== undefined ) {
+ b.onClick( e );
+ } else {
+ toolbar.insertTags( b.tagOpen, b.tagClose, b.sampleText );
+ }
+
+ return false;
+ } );
+
+ $toolbar.append( $button );
+ }
+
+ isReady = false;
+ $toolbar = false;
+
+ /**
+ * @private
+ * @property {Array}
+ * Contains button objects (and for backwards compatibilty, it can
+ * also contains an arguments array for insertButton).
+ */
+ queue = [];
+ slice = queue.slice;
+
+ toolbar = {
+
+ /**
+ * Add buttons to the toolbar.
+ *
+ * Takes care of race conditions and time-based dependencies
+ * by placing buttons in a queue if this method is called before
+ * the toolbar is created.
+ *
+ * For backwards-compatibility, passing `imageFile`, `speedTip`, `tagOpen`, `tagClose`,
+ * `sampleText` and `imageId` as separate arguments (in this order) is also supported.
+ *
+ * @param {Object} button Object with the following properties.
+ * You are required to provide *either* the `onClick` parameter, or the three parameters
+ * `tagOpen`, `tagClose` and `sampleText`, but not both (they're mutually exclusive).
+ * @param {string} [button.imageFile] Image to use for the button.
+ * @param {string} button.speedTip Tooltip displayed when user mouses over the button.
+ * @param {Function} [button.onClick] Function to be executed when the button is clicked.
+ * @param {string} [button.tagOpen]
+ * @param {string} [button.tagClose]
+ * @param {string} [button.sampleText] Alternative to `onClick`. `tagOpen`, `tagClose` and
+ * `sampleText` together provide the markup that should be inserted into page text at
+ * current cursor position.
+ * @param {string} [button.imageId] `id` attribute of the button HTML element. Can be
+ * used to define the image with CSS if it's not provided as `imageFile`.
+ */
+ addButton: function () {
+ if ( isReady ) {
+ insertButton.apply( toolbar, arguments );
+ } else {
+ // Convert arguments list to array
+ queue.push( slice.call( arguments ) );
+ }
+ },
+ /**
+ * Add multiple buttons to the toolbar (see also #addButton).
+ *
+ * Example usage:
+ *
+ * addButtons( [ { .. }, { .. }, { .. } ] );
+ * addButtons( { .. }, { .. } );
+ *
+ * @param {Object|Array...} [buttons] An array of button objects or the first
+ * button object in a list of variadic arguments.
+ */
+ addButtons: function ( buttons ) {
+ if ( !$.isArray( buttons ) ) {
+ buttons = slice.call( arguments );
+ }
+ if ( isReady ) {
+ $.each( buttons, function () {
+ insertButton( this );
+ } );
+ } else {
+ // Push each button into the queue
+ queue.push.apply( queue, buttons );
+ }
+ },
+
+ /**
+ * Apply tagOpen/tagClose to selection in currently focused textarea.
+ *
+ * Uses `sampleText` if selection is empty.
+ *
+ * @param {string} tagOpen
+ * @param {string} tagClose
+ * @param {string} sampleText
+ */
+ insertTags: function ( tagOpen, tagClose, sampleText ) {
+ if ( $currentFocused && $currentFocused.length ) {
+ $currentFocused.textSelection(
+ 'encapsulateSelection', {
+ pre: tagOpen,
+ peri: sampleText,
+ post: tagClose
+ }
+ );
+ }
+ },
+
+ // For backwards compatibility,
+ // Called from EditPage.php, maybe in other places as well.
+ init: function () {}
+ };
+
+ // Legacy (for compatibility with the code previously in skins/common.edit.js)
+ mw.log.deprecate( window, 'addButton', toolbar.addButton, 'Use mw.toolbar.addButton instead.' );
+ mw.log.deprecate( window, 'insertTags', toolbar.insertTags, 'Use mw.toolbar.insertTags instead.' );
+
+ // Expose API publicly
+ mw.toolbar = toolbar;
+
+ $( function () {
+ var i, b, editBox, scrollTop, $editForm;
+
+ // Used to determine where to insert tags
+ $currentFocused = $( '#wpTextbox1' );
+
+ // Populate the selector cache for $toolbar
+ $toolbar = $( '#toolbar' );
+
+ for ( i = 0; i < queue.length; i++ ) {
+ b = queue[i];
+ if ( $.isArray( b ) ) {
+ // Forwarded arguments array from mw.toolbar.addButton
+ insertButton.apply( toolbar, b );
+ } else {
+ // Raw object from mw.toolbar.addButtons
+ insertButton( b );
+ }
+ }
+
+ // Clear queue
+ queue.length = 0;
+
+ // This causes further calls to addButton to go to insertion directly
+ // instead of to the queue.
+ // It is important that this is after the one and only loop through
+ // the the queue
+ isReady = true;
+
+ // Make sure edit summary does not exceed byte limit
+ $( '#wpSummary' ).byteLimit( 255 );
+
+ // Restore the edit box scroll state following a preview operation,
+ // and set up a form submission handler to remember this state.
+ editBox = document.getElementById( 'wpTextbox1' );
+ scrollTop = document.getElementById( 'wpScrolltop' );
+ $editForm = $( '#editform' );
+ if ( $editForm.length && editBox && scrollTop ) {
+ if ( scrollTop.value ) {
+ editBox.scrollTop = scrollTop.value;
+ }
+ $editForm.submit( function () {
+ scrollTop.value = editBox.scrollTop;
+ } );
+ }
+
+ // Apply to dynamically created textboxes as well as normal ones
+ $( document ).on( 'focus', 'textarea, input:text', function () {
+ $currentFocused = $( this );
+ } );
+ } );
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.preview.js b/resources/src/mediawiki.action/mediawiki.action.edit.preview.js
new file mode 100644
index 00000000..6b212c28
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.preview.js
@@ -0,0 +1,165 @@
+/*!
+ * Live edit preview.
+ */
+( function ( mw, $ ) {
+
+ /**
+ * @ignore
+ * @param {jQuery.Event} e
+ */
+ function doLivePreview( e ) {
+ var $wikiPreview, $editform, copySelectors, $copyElements, $spinner,
+ targetUrl, postData, $previewDataHolder;
+
+ e.preventDefault();
+
+ // Deprecated: Use mw.hook instead
+ $( mw ).trigger( 'LivePreviewPrepare' );
+
+ $wikiPreview = $( '#wikiPreview' );
+ $editform = $( '#editform' );
+
+ // Show #wikiPreview if it's hidden to be able to scroll to it
+ // (if it is hidden, it's also empty, so nothing changes in the rendering)
+ $wikiPreview.show();
+
+ // Jump to where the preview will appear
+ $wikiPreview[0].scrollIntoView();
+
+ // List of selectors matching elements that we will
+ // update from from the ajax-loaded preview page.
+ copySelectors = [
+ // Main
+ '#firstHeading',
+ '#wikiPreview',
+ '#wikiDiff',
+ '#catlinks',
+ '.hiddencats',
+ '#p-lang',
+ // Editing-related
+ '.templatesUsed',
+ '.limitreport',
+ '.mw-summary-preview'
+ ];
+ $copyElements = $( copySelectors.join( ',' ) );
+
+ // Not shown during normal preview, to be removed if present
+ $( '.mw-newarticletext' ).remove();
+
+ $spinner = $.createSpinner( {
+ size: 'large',
+ type: 'block'
+ } );
+ $wikiPreview.before( $spinner );
+ $spinner.css( {
+ marginTop: $spinner.height()
+ } );
+
+ // Can't use fadeTo because it calls show(), and we might want to keep some elements hidden
+ // (e.g. empty #catlinks)
+ $copyElements.animate( { opacity: 0.4 }, 'fast' );
+
+ $previewDataHolder = $( '<div>' );
+ targetUrl = $editform.attr( 'action' );
+ targetUrl += targetUrl.indexOf( '?' ) !== -1 ? '&' : '?';
+ targetUrl += $.param( {
+ debug: mw.config.get( 'debug' ),
+ uselang: mw.config.get( 'wgUserLanguage' ),
+ useskin: mw.config.get( 'skin' )
+ } );
+
+ // Gather all the data from the form
+ postData = $editform.formToArray();
+ postData.push( {
+ name: e.target.name,
+ value: ''
+ } );
+
+ // Load new preview data.
+ // TODO: This should use the action=parse API instead of loading the entire page,
+ // although that requires figuring out how to convert that raw data into proper HTML.
+ $previewDataHolder.load( targetUrl + ' ' + copySelectors.join( ',' ), postData, function () {
+ var i, $from, $next, $parent;
+
+ // Copy the contents of the specified elements from the loaded page to the real page.
+ // Also copy their class attributes.
+ for ( i = 0; i < copySelectors.length; i++ ) {
+ $from = $previewDataHolder.find( copySelectors[i] );
+
+ if ( copySelectors[i] === '#wikiPreview' ) {
+ $next = $wikiPreview.next();
+ // If there is no next node, use parent instead.
+ // Only query parent if needed, false otherwise.
+ $parent = !$next.length && $wikiPreview.parent();
+
+ $wikiPreview
+ .detach()
+ .empty()
+ .append( $from.contents() )
+ .attr( 'class', $from.attr( 'class' ) );
+
+ mw.hook( 'wikipage.content' ).fire( $wikiPreview );
+
+ // Reattach
+ if ( $parent ) {
+ $parent.append( $wikiPreview );
+ } else {
+ $next.before( $wikiPreview );
+ }
+
+ } else {
+ $( copySelectors[i] )
+ .empty()
+ .append( $from.contents() )
+ .attr( 'class', $from.attr( 'class' ) );
+ }
+ }
+
+ // Deprecated: Use mw.hook instead
+ $( mw ).trigger( 'LivePreviewDone', [copySelectors] );
+
+ $spinner.remove();
+ $copyElements.animate( {
+ opacity: 1
+ }, 'fast' );
+ } );
+ }
+
+ $( function () {
+ // Do not enable on user .js/.css pages, as there's no sane way of "previewing"
+ // the scripts or styles without reloading the page.
+ if ( $( '#mw-userjsyoucanpreview' ).length || $( '#mw-usercssyoucanpreview' ).length ) {
+ return;
+ }
+
+ // The following elements can change in a preview but are not output
+ // by the server when they're empty until the preview response.
+ // TODO: Make the server output these always (in a hidden state), so we don't
+ // have to fish and (hopefully) put them in the right place (since skins
+ // can change where they are output).
+
+ if ( !document.getElementById( 'p-lang' ) && document.getElementById( 'p-tb' ) ) {
+ $( '#p-tb' ).after(
+ $( '<div>' ).attr( 'id', 'p-lang' )
+ );
+ }
+
+ if ( !$( '.mw-summary-preview' ).length ) {
+ $( '.editCheckboxes' ).before(
+ $( '<div>' ).addClass( 'mw-summary-preview' )
+ );
+ }
+
+ if ( !document.getElementById( 'wikiDiff' ) && document.getElementById( 'wikiPreview' ) ) {
+ $( '#wikiPreview' ).after(
+ $( '<div>' ).attr( 'id', 'wikiDiff' )
+ );
+ }
+
+ // This should be moved down to '#editform', but is kept on the body for now
+ // because the LiquidThreads extension is re-using this module with only half
+ // the EditPage (doesn't include #editform presumably, bug 55463).
+ $( document.body ).on( 'click', '#wpPreview, #wpDiff', doLivePreview );
+ } );
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.styles.css b/resources/src/mediawiki.action/mediawiki.action.edit.styles.css
new file mode 100644
index 00000000..7148b964
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.styles.css
@@ -0,0 +1,44 @@
+/*!
+ * Styles for elements of the editing form.
+ */
+
+/* General layout */
+#wpTextbox1 {
+ margin: 0;
+ display: block;
+}
+
+.editOptions {
+ background-color: #F0F0F0;
+ border: 1px solid silver;
+ border-top: none;
+ padding: 1em 1em 1.5em 1em;
+ margin-bottom: 2em;
+}
+
+/* Adjustments to edit form elements */
+.editCheckboxes {
+ margin-bottom: 1em;
+}
+
+.editCheckboxes input:first-child {
+ margin-left: 0;
+}
+
+.cancelLink {
+ margin-left: 0.5em;
+}
+
+#editpage-copywarn {
+ font-size: 0.9em;
+}
+
+#wpSummary {
+ display: block;
+ margin-top: 0;
+ margin-bottom: 0.5em;
+}
+
+.editButtons input:first-child {
+ margin-left: .1em;
+}
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ar/button_bold.png b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ar/button_bold.png
new file mode 100644
index 00000000..e524f6cb
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ar/button_bold.png
Binary files differ
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ar/button_headline.png b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ar/button_headline.png
new file mode 100644
index 00000000..398e5614
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ar/button_headline.png
Binary files differ
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ar/button_italic.png b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ar/button_italic.png
new file mode 100644
index 00000000..6ec73e9e
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ar/button_italic.png
Binary files differ
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ar/button_link.png b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ar/button_link.png
new file mode 100644
index 00000000..c9c63f6c
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ar/button_link.png
Binary files differ
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ar/button_nowiki.png b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ar/button_nowiki.png
new file mode 100644
index 00000000..743ea61b
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ar/button_nowiki.png
Binary files differ
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/be-tarask/button_bold.png b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/be-tarask/button_bold.png
new file mode 100644
index 00000000..5c10cfe2
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/be-tarask/button_bold.png
Binary files differ
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/be-tarask/button_italic.png b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/be-tarask/button_italic.png
new file mode 100644
index 00000000..72209d74
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/be-tarask/button_italic.png
Binary files differ
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/be-tarask/button_link.png b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/be-tarask/button_link.png
new file mode 100644
index 00000000..09c86fb1
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/be-tarask/button_link.png
Binary files differ
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/de/button_bold.png b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/de/button_bold.png
new file mode 100644
index 00000000..367d5bc1
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/de/button_bold.png
Binary files differ
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/de/button_italic.png b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/de/button_italic.png
new file mode 100644
index 00000000..fdd8c9f9
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/de/button_italic.png
Binary files differ
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_bold.png b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_bold.png
new file mode 100644
index 00000000..75c3f109
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_bold.png
Binary files differ
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_extlink.png b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_extlink.png
new file mode 100644
index 00000000..458943c1
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_extlink.png
Binary files differ
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_headline.png b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_headline.png
new file mode 100644
index 00000000..9cf751d9
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_headline.png
Binary files differ
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_hr.png b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_hr.png
new file mode 100644
index 00000000..47e1ca40
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_hr.png
Binary files differ
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_image.png b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_image.png
new file mode 100644
index 00000000..69192965
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_image.png
Binary files differ
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_italic.png b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_italic.png
new file mode 100644
index 00000000..527fbd14
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_italic.png
Binary files differ
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_link.png b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_link.png
new file mode 100644
index 00000000..eb5634b9
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_link.png
Binary files differ
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_media.png b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_media.png
new file mode 100644
index 00000000..4194ec18
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_media.png
Binary files differ
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_nowiki.png b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_nowiki.png
new file mode 100644
index 00000000..2ba818de
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_nowiki.png
Binary files differ
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_sig.png b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_sig.png
new file mode 100644
index 00000000..fe34b3fb
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/en/button_sig.png
Binary files differ
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/fa/button_bold.png b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/fa/button_bold.png
new file mode 100644
index 00000000..c54d094c
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/fa/button_bold.png
Binary files differ
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/fa/button_headline.png b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/fa/button_headline.png
new file mode 100644
index 00000000..9890d155
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/fa/button_headline.png
Binary files differ
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/fa/button_italic.png b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/fa/button_italic.png
new file mode 100644
index 00000000..33f91ed6
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/fa/button_italic.png
Binary files differ
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/fa/button_link.png b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/fa/button_link.png
new file mode 100644
index 00000000..76b939e6
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/fa/button_link.png
Binary files differ
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/fa/button_nowiki.png b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/fa/button_nowiki.png
new file mode 100644
index 00000000..743ea61b
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/fa/button_nowiki.png
Binary files differ
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ksh/LICENSE b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ksh/LICENSE
new file mode 100644
index 00000000..47ecfe4e
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ksh/LICENSE
@@ -0,0 +1,7 @@
+
+button_italic.png
+-------------------
+Source : http://commons.wikimedia.org/wiki/Image:Button_S_italic.png
+License: Public domain
+Author : Purodha Blissenbach, http://ksh.wikipedia.org/wiki/User:Purodha
+
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ksh/button_italic.png b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ksh/button_italic.png
new file mode 100644
index 00000000..15496c08
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ksh/button_italic.png
Binary files differ
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ru/LICENSE b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ru/LICENSE
new file mode 100644
index 00000000..bedcec66
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ru/LICENSE
@@ -0,0 +1,17 @@
+button_bold.png
+---------------
+Source : http://commons.wikimedia.org/wiki/Image:Button_bold_ukr.png
+License: Public domain
+Author : Alexey Belomoev
+
+button_italic.png
+------------------------
+Source : http://commons.wikimedia.org/wiki/Image:Button_italic_ukr.png
+License: Public domain
+Author : Alexey Belomoev
+
+button_link.png
+-----------------
+Source : http://commons.wikimedia.org/wiki/Image:Button_internal_link_ukr.png
+License: GPL
+Author : Saproj, Erik Möller
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ru/button_bold.png b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ru/button_bold.png
new file mode 100644
index 00000000..eae30d98
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ru/button_bold.png
Binary files differ
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ru/button_italic.png b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ru/button_italic.png
new file mode 100644
index 00000000..b958d220
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ru/button_italic.png
Binary files differ
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ru/button_link.png b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ru/button_link.png
new file mode 100644
index 00000000..12ad3731
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/images/ru/button_link.png
Binary files differ
diff --git a/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/mediawiki.action.edit.toolbar.less b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/mediawiki.action.edit.toolbar.less
new file mode 100644
index 00000000..d65b2842
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.edit.toolbar/mediawiki.action.edit.toolbar.less
@@ -0,0 +1,42 @@
+@import "mediawiki.mixins";
+
+#mw-editbutton-bold {
+ .background-image("images/@{button-bold}");
+}
+
+#mw-editbutton-italic {
+ .background-image("images/@{button-italic}");
+}
+
+#mw-editbutton-link {
+ .background-image("images/@{button-link}");
+}
+
+#mw-editbutton-extlink {
+ .background-image("images/@{button-extlink}");
+}
+
+#mw-editbutton-headline {
+ .background-image("images/@{button-headline}");
+}
+
+#mw-editbutton-image {
+ .background-image("images/@{button-image}");
+}
+
+#mw-editbutton-media {
+ .background-image("images/@{button-media}");
+}
+
+#mw-editbutton-nowiki {
+ .background-image("images/@{button-nowiki}");
+}
+
+// Who decided to make only this single one different than the name of the data item?
+#mw-editbutton-signature {
+ .background-image("images/@{button-sig}");
+}
+
+#mw-editbutton-hr {
+ .background-image("images/@{button-hr}");
+}
diff --git a/resources/src/mediawiki.action/mediawiki.action.history.css b/resources/src/mediawiki.action/mediawiki.action.history.css
new file mode 100644
index 00000000..603a9657
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.history.css
@@ -0,0 +1,4 @@
+#pagehistory li.before input[name="oldid"],
+#pagehistory li.after input[name="diff"] {
+ visibility: hidden;
+}
diff --git a/resources/src/mediawiki.action/mediawiki.action.history.diff.css b/resources/src/mediawiki.action/mediawiki.action.history.diff.css
new file mode 100644
index 00000000..afe92468
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.history.diff.css
@@ -0,0 +1,96 @@
+/*
+** Diff rendering
+*/
+table.diff {
+ background-color: white;
+ border: none;
+ border-spacing: 4px;
+ margin: 0;
+ width: 100%;
+ /* Ensure that colums are of equal width */
+ table-layout: fixed;
+}
+
+table.diff td {
+ padding: 0.33em 0.5em;
+}
+
+table.diff td.diff-marker {
+ /* Compensate padding for increased font-size */
+ padding: 0.25em;
+}
+
+table.diff col.diff-marker {
+ width: 2%;
+}
+
+table.diff col.diff-content {
+ width: 48%;
+}
+
+table.diff td div {
+ /* Force-wrap very long lines such as URLs or page-widening char strings */
+ word-wrap: break-word;
+}
+
+td.diff-otitle,
+td.diff-ntitle {
+ text-align: center;
+}
+
+td.diff-lineno {
+ font-weight: bold;
+}
+
+td.diff-marker {
+ text-align: right;
+ font-weight: bold;
+ font-size: 1.25em;
+ line-height: 1.2;
+}
+
+td.diff-addedline,
+td.diff-deletedline,
+td.diff-context {
+ font-size: 88%;
+ line-height: 1.6;
+ vertical-align: top;
+ white-space: -moz-pre-wrap;
+ white-space: pre-wrap;
+ border-style: solid;
+ border-width: 1px 1px 1px 4px;
+ border-radius: 0.33em;
+}
+
+td.diff-addedline {
+ border-color: #a3d3ff;
+}
+
+td.diff-deletedline {
+ border-color: #ffe49c;
+}
+
+td.diff-context {
+ background: #f9f9f9;
+ border-color: #e6e6e6;
+ color: #333333;
+}
+
+.diffchange {
+ font-weight: bold;
+ text-decoration: none;
+}
+
+td.diff-addedline .diffchange,
+td.diff-deletedline .diffchange {
+ border-radius: 0.33em;
+ padding: 0.25em 0;
+}
+
+td.diff-addedline .diffchange {
+ background: #d8ecff;
+}
+
+td.diff-deletedline .diffchange {
+ background: #feeec8;
+}
diff --git a/resources/src/mediawiki.action/mediawiki.action.history.js b/resources/src/mediawiki.action/mediawiki.action.history.js
new file mode 100644
index 00000000..ac48c596
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.history.js
@@ -0,0 +1,111 @@
+/*!
+ * JavaScript for History action
+ */
+jQuery( function ( $ ) {
+ var $historyCompareForm = $( '#mw-history-compare' ),
+ $historySubmitter,
+ $lis = $( '#pagehistory > li' );
+
+ /**
+ * @ignore
+ * @context {Element} input
+ * @param e {jQuery.Event}
+ */
+ function updateDiffRadios() {
+ var nextState = 'before',
+ $li,
+ $inputs,
+ $oldidRadio,
+ $diffRadio;
+
+ if ( !$lis.length ) {
+ return true;
+ }
+
+ $lis
+ .each( function () {
+ $li = $( this );
+ $inputs = $li.find( 'input[type="radio"]' );
+ $oldidRadio = $inputs.filter( '[name="oldid"]' ).eq( 0 );
+ $diffRadio = $inputs.filter( '[name="diff"]' ).eq( 0 );
+
+ $li.removeClass( 'selected between before after' );
+
+ if ( !$oldidRadio.length || !$diffRadio.length ) {
+ return true;
+ }
+
+ if ( $oldidRadio.prop( 'checked' ) ) {
+ $li.addClass( 'selected after' );
+ nextState = 'after';
+ } else if ( $diffRadio.prop( 'checked' ) ) {
+ $li.addClass( 'selected ' + nextState );
+ nextState = 'between';
+ } else {
+ // This list item has neither checked
+ // apply the appropriate class following the previous item.
+ $li.addClass( nextState );
+ }
+ } );
+
+ return true;
+ }
+
+ $lis.find( 'input[name="diff"], input[name="oldid"]' ).click( updateDiffRadios );
+
+ // Set initial state
+ updateDiffRadios();
+
+ // Prettify url output for HistoryAction submissions,
+ // to cover up action=historysubmit construction.
+
+ // Ideally we'd use e.target instead of $historySubmitter, but e.target points
+ // to the form element for submit actions, so.
+ $historyCompareForm.find( '.historysubmit' ).click( function () {
+ $historySubmitter = $( this );
+ } );
+
+ // On submit we clone the form element, remove unneeded fields in the clone
+ // that pollute the query parameter with stuff from the other "use case",
+ // and then submit the clone.
+ // Without the cloning we'd be changing the real form, which is slower, could make
+ // the page look broken for a second in slow browsers and might show the form broken
+ // again when coming back from a "next" page.
+ $historyCompareForm.submit( function ( e ) {
+ var $copyForm, $copyRadios, $copyAction;
+
+ if ( $historySubmitter ) {
+ $copyForm = $historyCompareForm.clone();
+ $copyRadios = $copyForm.find( '#pagehistory > li' ).find( 'input[name="diff"], input[name="oldid"]' );
+ $copyAction = $copyForm.find( '> [name="action"]' );
+
+ // Remove action=historysubmit and ids[..]=..
+ if ( $historySubmitter.hasClass( 'mw-history-compareselectedversions-button' ) ) {
+ $copyAction.remove();
+ $copyForm.find( 'input[name^="ids["]:checked' ).prop( 'checked', false );
+
+ // Remove diff=&oldid=, change action=historysubmit to revisiondelete, remove revisiondelete
+ } else if ( $historySubmitter.hasClass( 'mw-history-revisiondelete-button' ) ) {
+ $copyRadios.remove();
+ $copyAction.val( $historySubmitter.attr( 'name' ) );
+ $copyForm.find( ':submit' ).remove();
+ }
+
+ // IE7 doesn't do submission from an off-DOM clone, so insert hidden into document first
+ // Also remove potentially conflicting id attributes that we don't need anyway
+ $copyForm
+ .css( 'display', 'none' )
+ .find( '[id]' )
+ .removeAttr( 'id' )
+ .end()
+ .insertAfter( $historyCompareForm )
+ .submit();
+
+ e.preventDefault();
+ return false; // Because the submit is special, return false as well.
+ }
+
+ // Continue natural browser handling other wise
+ return true;
+ } );
+} );
diff --git a/resources/src/mediawiki.action/mediawiki.action.view.dblClickEdit.js b/resources/src/mediawiki.action/mediawiki.action.view.dblClickEdit.js
new file mode 100644
index 00000000..2ded40cf
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.view.dblClickEdit.js
@@ -0,0 +1,12 @@
+/*!
+ * Enables double-click-to-edit functionality.
+ */
+( function ( mw, $ ) {
+ $( function () {
+ mw.util.$content.dblclick( function ( e ) {
+ e.preventDefault();
+ // Trigger native HTMLElement click instead of opening URL (bug 43052)
+ $( '#ca-edit a' ).get( 0 ).click();
+ } );
+ } );
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.action/mediawiki.action.view.metadata.css b/resources/src/mediawiki.action/mediawiki.action.view.metadata.css
new file mode 100644
index 00000000..2c8d2e65
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.view.metadata.css
@@ -0,0 +1,6 @@
+/*!
+ * Hide collapsable rows in a collapsed table.
+ */
+table.collapsed tr.collapsable {
+ display: none;
+}
diff --git a/resources/src/mediawiki.action/mediawiki.action.view.metadata.js b/resources/src/mediawiki.action/mediawiki.action.view.metadata.js
new file mode 100644
index 00000000..25b5acc5
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.view.metadata.js
@@ -0,0 +1,45 @@
+/*!
+ * Exif metadata display for MediaWiki file uploads
+ *
+ * Add an expand/collapse link and collapse by default if set to
+ * (with JS disabled, user will see all items)
+ *
+ * See also ImagePage.php#makeMetadataTable (creates the HTML)
+ */
+( function ( mw, $ ) {
+ $( function () {
+ var $row, $col, $link,
+ showText = mw.msg( 'metadata-expand' ),
+ hideText = mw.msg( 'metadata-collapse' ),
+ $table = $( '#mw_metadata' ),
+ $tbody = $table.find( 'tbody' );
+
+ if ( !$tbody.length || !$tbody.find( '.collapsable' ).length ) {
+ return;
+ }
+
+ $row = $( '<tr class="mw-metadata-show-hide-extended"></tr>' );
+ $col = $( '<td colspan="2"></td>' );
+
+ $link = $( '<a>', {
+ text: showText,
+ href: '#'
+ } ).click( function () {
+ if ( $table.hasClass( 'collapsed' ) ) {
+ $( this ).text( hideText );
+ } else {
+ $( this ).text( showText );
+ }
+ $table.toggleClass( 'expanded collapsed' );
+ return false;
+ } );
+
+ $col.append( $link );
+ $row.append( $col );
+ $tbody.append( $row );
+
+ // And collapse!
+ $table.addClass( 'collapsed' );
+ } );
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.action/mediawiki.action.view.postEdit.css b/resources/src/mediawiki.action/mediawiki.action.view.postEdit.css
new file mode 100644
index 00000000..db89990d
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.view.postEdit.css
@@ -0,0 +1,76 @@
+.postedit-container {
+ margin: 0 auto;
+ position: fixed;
+ top: 0;
+ height: 0;
+ left: 50%;
+ z-index: 1000;
+ font-size: 13px;
+}
+
+.postedit-container:hover {
+ cursor: pointer;
+}
+
+.postedit {
+ position: relative;
+ top: 0.6em;
+ left: -50%;
+ padding: .6em 3.6em .6em 1.1em;
+ line-height: 1.5625em;
+ color: #626465;
+ background-color: #f4f4f4;
+ border: 1px solid #dcd9d9;
+ text-shadow: 0 0.0625em 0 rgba(255, 255, 255, 0.5);
+ border-radius: 5px;
+ box-shadow: 0 2px 5px 0 #ccc;
+ -webkit-transition: all 0.25s ease-in-out;
+ -moz-transition: all 0.25s ease-in-out;
+ -ms-transition: all 0.25s ease-in-out;
+ -o-transition: all 0.25s ease-in-out;
+ transition: all 0.25s ease-in-out;
+}
+
+.skin-monobook .postedit {
+ top: 6em !important;
+}
+
+.postedit-faded {
+ opacity: 0;
+}
+
+.postedit-icon {
+ padding-left: 41px; /* 25 + 8 + 8 */
+ /* like min-height, but old IE compatible and keeps text vertically aligned, too */
+ line-height: 25px;
+ background-repeat: no-repeat;
+ background-position: 8px 50%;
+}
+
+.postedit-icon-checkmark {
+ /* @embed */
+ background-image: url(images/green-checkmark.png);
+ background-position: left;
+}
+
+.postedit-close {
+ position: absolute;
+ padding: 0 .8em;
+ right: 0;
+ top: 0;
+ font-size: 1.25em;
+ font-weight: bold;
+ line-height: 2.3em;
+ color: black;
+ text-shadow: 0 0.0625em 0 white;
+ text-decoration: none;
+ opacity: 0.2;
+ filter: alpha(opacity=20);
+}
+
+.postedit-close:hover {
+ color: black;
+ text-decoration: none;
+ opacity: 0.4;
+ filter: alpha(opacity=40);
+}
diff --git a/resources/src/mediawiki.action/mediawiki.action.view.postEdit.js b/resources/src/mediawiki.action/mediawiki.action.view.postEdit.js
new file mode 100644
index 00000000..4d2c47a5
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.view.postEdit.js
@@ -0,0 +1,86 @@
+( function ( mw, $ ) {
+ 'use strict';
+
+ /**
+ * @event postEdit
+ * @member mw.hook
+ * @param {Object} [data] Optional data
+ * @param {string|jQuery|Array} [data.message] Message that listeners
+ * should use when displaying notifications. String for plain text,
+ * use array or jQuery object to pass actual nodes.
+ * @param {string|mw.user} [data.user=mw.user] User that made the edit.
+ */
+
+ /**
+ * After the listener for #postEdit removes the notification.
+ *
+ * @event postEdit_afterRemoval
+ * @member mw.hook
+ */
+
+ var config = mw.config.get( [ 'wgAction', 'wgCurRevisionId' ] ),
+ // This should match EditPage::POST_EDIT_COOKIE_KEY_PREFIX:
+ cookieKey = 'PostEditRevision' + config.wgCurRevisionId,
+ cookieVal = mw.cookie.get( cookieKey ),
+ $div, id;
+
+ function showConfirmation( data ) {
+ data = data || {};
+ if ( data.message === undefined ) {
+ data.message = $.parseHTML( mw.message( 'postedit-confirmation-saved', data.user || mw.user ).escaped() );
+ }
+
+ $div = $(
+ '<div class="postedit-container">' +
+ '<div class="postedit">' +
+ '<div class="postedit-icon postedit-icon-checkmark postedit-content"></div>' +
+ '<a href="#" class="postedit-close">&times;</a>' +
+ '</div>' +
+ '</div>'
+ );
+
+ if ( typeof data.message === 'string' ) {
+ $div.find( '.postedit-content' ).text( data.message );
+ } else if ( typeof data.message === 'object' ) {
+ $div.find( '.postedit-content' ).append( data.message );
+ }
+
+ $div
+ .click( fadeOutConfirmation )
+ .prependTo( 'body' );
+
+ id = setTimeout( fadeOutConfirmation, 3000 );
+ }
+
+ function fadeOutConfirmation() {
+ clearTimeout( id );
+ $div.find( '.postedit' ).addClass( 'postedit postedit-faded' );
+ setTimeout( removeConfirmation, 500 );
+
+ return false;
+ }
+
+ function removeConfirmation() {
+ $div.remove();
+ mw.hook( 'postEdit.afterRemoval' ).fire();
+ }
+
+ mw.hook( 'postEdit' ).add( showConfirmation );
+
+ if ( config.wgAction === 'view' && cookieVal ) {
+ mw.config.set( 'wgPostEdit', true );
+
+ mw.hook( 'postEdit' ).fire( {
+ // The following messages can be used here:
+ // postedit-confirmation-saved
+ // postedit-confirmation-created
+ // postedit-confirmation-restored
+ 'message': mw.msg(
+ 'postedit-confirmation-' + cookieVal,
+ mw.user
+ )
+ } );
+ mw.cookie.set( cookieKey, null );
+ }
+
+} ( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.action/mediawiki.action.view.redirect.js b/resources/src/mediawiki.action/mediawiki.action.view.redirect.js
new file mode 100644
index 00000000..52e0d4e3
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.view.redirect.js
@@ -0,0 +1,65 @@
+/*!
+ * JavaScript to update page URL when a redirect is viewed, ensuring that the
+ * page is scrolled to the id when it's a redirect with fragment.
+ *
+ * This is loaded in the top queue, so avoid unnecessary dependencies
+ * like mediawiki.Title or mediawiki.Uri.
+ */
+( function ( mw, $ ) {
+ var profile = $.client.profile(),
+ canonical = mw.config.get( 'wgInternalRedirectTargetUrl' ),
+ fragment = null,
+ shouldChangeFragment, index;
+
+ // Clear internal mw.config entries, so that no one tries to depend on them
+ mw.config.set( 'wgInternalRedirectTargetUrl', null );
+
+ index = canonical.indexOf( '#' );
+ if ( index !== -1 ) {
+ fragment = canonical.slice( index );
+ }
+
+ // Never override the fragment if the user intended to look at a different section
+ shouldChangeFragment = fragment && !location.hash;
+
+ // Replace the whole URL if possible, otherwise just change the fragment
+ if ( canonical && history.replaceState ) {
+ if ( !shouldChangeFragment ) {
+ // If the current page view has a fragment already, don't override it
+ canonical = canonical.replace( /#.*$/, '' );
+ canonical += location.hash;
+ }
+
+ // This will also cause the browser to scroll to given fragment
+ history.replaceState( /*data=*/ history.state, /*title=*/ document.title, /*url=*/ canonical );
+
+ // …except for IE 10 and 11. Prod it with a location.hash change.
+ if ( shouldChangeFragment && profile.name === 'msie' && profile.versionNumber >= 10 ) {
+ location.hash = fragment;
+ }
+
+ } else if ( shouldChangeFragment ) {
+ if ( profile.layout === 'webkit' && profile.layoutVersion < 420 ) {
+ // Released Safari w/ WebKit 418.9.1 messes up horribly
+ // Nightlies of 420+ are ok
+ return;
+ }
+
+ location.hash = fragment;
+ }
+
+ if ( shouldChangeFragment && profile.layout === 'gecko' ) {
+ // Mozilla needs to wait until after load, otherwise the window doesn't
+ // scroll. See <https://bugzilla.mozilla.org/show_bug.cgi?id=516293>.
+ // There's no obvious way to detect this programmatically, so we use
+ // version-testing. If Firefox fixes the bug, they'll jump twice, but
+ // better twice than not at all, so make the fix hit future versions as
+ // well.
+ $( function () {
+ if ( location.hash === fragment ) {
+ location.hash = fragment;
+ }
+ } );
+ }
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.action/mediawiki.action.view.redirectPage.css b/resources/src/mediawiki.action/mediawiki.action.view.redirectPage.css
new file mode 100644
index 00000000..fdbb655f
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.view.redirectPage.css
@@ -0,0 +1,53 @@
+/*!
+ * Display neat icons on redirect pages.
+ */
+
+/* Hide, but keep accessible for screen-readers. */
+.redirectMsg p {
+ overflow: hidden;
+ height: 0;
+ zoom: 1;
+}
+
+.redirectText {
+ list-style: none none;
+ display: inline;
+ /* shared.css has some very weird directionality-specific rules for 'ul' we need to override,
+ search for "Correct directionality when page dir is different from site/user dir" */
+ margin: 0 !important;
+ padding: 0 !important;
+}
+
+/* @noflip */
+.mw-content-ltr .redirectText li {
+ display: inline;
+ margin: 0;
+ padding: 0;
+ padding-left: 42px;
+ /* @embed */
+ background: url(images/nextredirect-ltr.png) bottom left no-repeat;
+}
+
+/* @noflip */
+.mw-content-ltr .redirectText li:first-child {
+ padding-left: 47px;
+ /* @embed */
+ background: url(images/redirect-ltr.png) bottom left no-repeat;
+}
+
+/* @noflip */
+.mw-content-rtl .redirectText li {
+ display: inline;
+ margin: 0;
+ padding: 0;
+ padding-right: 42px;
+ /* @embed */
+ background: url(images/nextredirect-rtl.png) bottom right no-repeat;
+}
+
+/* @noflip */
+.mw-content-rtl .redirectText li:first-child {
+ padding-right: 47px;
+ /* @embed */
+ background: url(images/redirect-rtl.png) bottom right no-repeat;
+}
diff --git a/resources/src/mediawiki.action/mediawiki.action.view.rightClickEdit.js b/resources/src/mediawiki.action/mediawiki.action.view.rightClickEdit.js
new file mode 100644
index 00000000..ada101eb
--- /dev/null
+++ b/resources/src/mediawiki.action/mediawiki.action.view.rightClickEdit.js
@@ -0,0 +1,26 @@
+/*!
+ * JavaScript to enable right click edit functionality.
+ * When the user right-clicks in a heading, it will open the
+ * edit screen.
+ */
+jQuery( function ( $ ) {
+ // Select all h1-h6 elements that contain editsection links
+ // Don't use the ":has:(.mw-editsection a)" selector because it performs very bad.
+ // http://jsperf.com/jq-1-7-2-vs-jq-1-8-1-performance-of-mw-has/2
+ $( document ).on( 'contextmenu', 'h1, h2, h3, h4, h5, h6', function ( e ) {
+ var $edit = $( this ).find( '.mw-editsection a' );
+ if ( !$edit.length ) {
+ return;
+ }
+
+ // Headings can contain rich text.
+ // Make sure to not block contextmenu events on (other) anchor tags
+ // inside the heading (e.g. to do things like copy URL, open in new tab, ..).
+ // e.target can be the heading, but it can also be anything inside the heading.
+ if ( e.target.nodeName.toLowerCase() !== 'a' ) {
+ // Trigger native HTMLElement click instead of opening URL (bug 43052)
+ e.preventDefault();
+ $edit.get( 0 ).click();
+ }
+ } );
+} );
diff --git a/resources/src/mediawiki.api/mediawiki.api.category.js b/resources/src/mediawiki.api/mediawiki.api.category.js
new file mode 100644
index 00000000..7dd9730f
--- /dev/null
+++ b/resources/src/mediawiki.api/mediawiki.api.category.js
@@ -0,0 +1,146 @@
+/**
+ * @class mw.Api.plugin.category
+ */
+( function ( mw, $ ) {
+
+ var msg = 'Use of mediawiki.api callback params is deprecated. Use the Promise instead.';
+ $.extend( mw.Api.prototype, {
+ /**
+ * Determine if a category exists.
+ *
+ * @param {mw.Title|string} title
+ * @param {Function} [ok] Success callback (deprecated)
+ * @param {Function} [err] Error callback (deprecated)
+ * @return {jQuery.Promise}
+ * @return {Function} return.done
+ * @return {boolean} return.done.isCategory Whether the category exists.
+ */
+ isCategory: function ( title, ok, err ) {
+ var apiPromise = this.get( {
+ prop: 'categoryinfo',
+ titles: String( title )
+ } );
+
+ if ( ok || err ) {
+ mw.track( 'mw.deprecate', 'api.cbParam' );
+ mw.log.warn( msg );
+ }
+
+ return apiPromise
+ .then( function ( data ) {
+ var exists = false;
+ if ( data.query && data.query.pages ) {
+ $.each( data.query.pages, function ( id, page ) {
+ if ( page.categoryinfo ) {
+ exists = true;
+ }
+ } );
+ }
+ return exists;
+ } )
+ .done( ok )
+ .fail( err )
+ .promise( { abort: apiPromise.abort } );
+ },
+
+ /**
+ * Get a list of categories that match a certain prefix.
+ *
+ * E.g. given "Foo", return "Food", "Foolish people", "Foosball tables"...
+ *
+ * @param {string} prefix Prefix to match.
+ * @param {Function} [ok] Success callback (deprecated)
+ * @param {Function} [err] Error callback (deprecated)
+ * @return {jQuery.Promise}
+ * @return {Function} return.done
+ * @return {string[]} return.done.categories Matched categories
+ */
+ getCategoriesByPrefix: function ( prefix, ok, err ) {
+ // Fetch with allpages to only get categories that have a corresponding description page.
+ var apiPromise = this.get( {
+ list: 'allpages',
+ apprefix: prefix,
+ apnamespace: mw.config.get( 'wgNamespaceIds' ).category
+ } );
+
+ if ( ok || err ) {
+ mw.track( 'mw.deprecate', 'api.cbParam' );
+ mw.log.warn( msg );
+ }
+
+ return apiPromise
+ .then( function ( data ) {
+ var texts = [];
+ if ( data.query && data.query.allpages ) {
+ $.each( data.query.allpages, function ( i, category ) {
+ texts.push( new mw.Title( category.title ).getMainText() );
+ } );
+ }
+ return texts;
+ } )
+ .done( ok )
+ .fail( err )
+ .promise( { abort: apiPromise.abort } );
+ },
+
+ /**
+ * Get the categories that a particular page on the wiki belongs to.
+ *
+ * @param {mw.Title|string} title
+ * @param {Function} [ok] Success callback (deprecated)
+ * @param {Function} [err] Error callback (deprecated)
+ * @param {boolean} [async=true] Asynchronousness (deprecated)
+ * @return {jQuery.Promise}
+ * @return {Function} return.done
+ * @return {boolean|mw.Title[]} return.done.categories List of category titles or false
+ * if title was not found.
+ */
+ getCategories: function ( title, ok, err, async ) {
+ var apiPromise = this.get( {
+ prop: 'categories',
+ titles: String( title )
+ }, {
+ async: async === undefined ? true : async
+ } );
+
+ if ( ok || err ) {
+ mw.track( 'mw.deprecate', 'api.cbParam' );
+ mw.log.warn( msg );
+ }
+ if ( async !== undefined ) {
+ mw.track( 'mw.deprecate', 'api.async' );
+ mw.log.warn(
+ 'Use of mediawiki.api async=false param is deprecated. ' +
+ 'The sychronous mode will be removed in the future.'
+ );
+ }
+
+ return apiPromise
+ .then( function ( data ) {
+ var titles = false;
+ if ( data.query && data.query.pages ) {
+ $.each( data.query.pages, function ( id, page ) {
+ if ( page.categories ) {
+ if ( titles === false ) {
+ titles = [];
+ }
+ $.each( page.categories, function ( i, cat ) {
+ titles.push( new mw.Title( cat.title ) );
+ } );
+ }
+ } );
+ }
+ return titles;
+ } )
+ .done( ok )
+ .fail( err )
+ .promise( { abort: apiPromise.abort } );
+ }
+ } );
+
+ /**
+ * @class mw.Api
+ * @mixins mw.Api.plugin.category
+ */
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.api/mediawiki.api.edit.js b/resources/src/mediawiki.api/mediawiki.api.edit.js
new file mode 100644
index 00000000..e88ae5e2
--- /dev/null
+++ b/resources/src/mediawiki.api/mediawiki.api.edit.js
@@ -0,0 +1,87 @@
+/**
+ * @class mw.Api.plugin.edit
+ */
+( function ( mw, $ ) {
+
+ var msg = 'Use of mediawiki.api callback params is deprecated. Use the Promise instead.';
+ $.extend( mw.Api.prototype, {
+
+ /**
+ * Post to API with edit token. If we have no token, get one and try to post.
+ * If we have a cached token try using that, and if it fails, blank out the
+ * cached token and start over.
+ *
+ * @param {Object} params API parameters
+ * @param {Function} [ok] Success callback (deprecated)
+ * @param {Function} [err] Error callback (deprecated)
+ * @return {jQuery.Promise} See #post
+ */
+ postWithEditToken: function ( params, ok, err ) {
+ if ( ok || err ) {
+ mw.track( 'mw.deprecate', 'api.cbParam' );
+ mw.log.warn( msg );
+ }
+
+ return this.postWithToken( 'edit', params ).done( ok ).fail( err );
+ },
+
+ /**
+ * API helper to grab an edit token.
+ *
+ * @param {Function} [ok] Success callback (deprecated)
+ * @param {Function} [err] Error callback (deprecated)
+ * @return {jQuery.Promise}
+ * @return {Function} return.done
+ * @return {string} return.done.token Received token.
+ */
+ getEditToken: function ( ok, err ) {
+ if ( ok || err ) {
+ mw.track( 'mw.deprecate', 'api.cbParam' );
+ mw.log.warn( msg );
+ }
+
+ return this.getToken( 'edit' ).done( ok ).fail( err );
+ },
+
+ /**
+ * Post a new section to the page.
+ * @see #postWithEditToken
+ * @param {mw.Title|String} title Target page
+ * @param {string} header
+ * @param {string} message wikitext message
+ * @param {Object} [additionalParams] Additional API parameters, e.g. `{ redirect: true }`
+ * @param {Function} [ok] Success handler (deprecated)
+ * @param {Function} [err] Error handler (deprecated)
+ * @return {jQuery.Promise}
+ */
+ newSection: function ( title, header, message, additionalParams, ok, err ) {
+ // Until we remove 'ok' and 'err' parameters, we have to support code that passes them,
+ // but not additionalParams...
+ if ( $.isFunction( additionalParams ) ) {
+ err = ok;
+ ok = additionalParams;
+ additionalParams = undefined;
+ }
+
+ if ( ok || err ) {
+ mw.track( 'mw.deprecate', 'api.cbParam' );
+ mw.log.warn( msg );
+ }
+
+ return this.postWithEditToken( $.extend( {
+ action: 'edit',
+ section: 'new',
+ format: 'json',
+ title: String( title ),
+ summary: header,
+ text: message
+ }, additionalParams ) ).done( ok ).fail( err );
+ }
+ } );
+
+ /**
+ * @class mw.Api
+ * @mixins mw.Api.plugin.edit
+ */
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.api/mediawiki.api.js b/resources/src/mediawiki.api/mediawiki.api.js
new file mode 100644
index 00000000..51b3238c
--- /dev/null
+++ b/resources/src/mediawiki.api/mediawiki.api.js
@@ -0,0 +1,397 @@
+( function ( mw, $ ) {
+
+ // We allow people to omit these default parameters from API requests
+ // there is very customizable error handling here, on a per-call basis
+ // wondering, would it be simpler to make it easy to clone the api object,
+ // change error handling, and use that instead?
+ var defaultOptions = {
+
+ // Query parameters for API requests
+ parameters: {
+ action: 'query',
+ format: 'json'
+ },
+
+ // Ajax options for jQuery.ajax()
+ ajax: {
+ url: mw.util.wikiScript( 'api' ),
+
+ timeout: 30 * 1000, // 30 seconds
+
+ dataType: 'json'
+ }
+ },
+ // Keyed by ajax url and symbolic name for the individual request
+ promises = {};
+
+ // Pre-populate with fake ajax promises to save http requests for tokens
+ // we already have on the page via the user.tokens module (bug 34733).
+ promises[ defaultOptions.ajax.url ] = {};
+ $.each( mw.user.tokens.get(), function ( key, value ) {
+ // This requires #getToken to use the same key as user.tokens.
+ // Format: token-type + "Token" (eg. editToken, patrolToken, watchToken).
+ promises[ defaultOptions.ajax.url ][ key ] = $.Deferred()
+ .resolve( value )
+ .promise( { abort: function () {} } );
+ } );
+
+ /**
+ * Constructor to create an object to interact with the API of a particular MediaWiki server.
+ * mw.Api objects represent the API of a particular MediaWiki server.
+ *
+ * TODO: Share API objects with exact same config.
+ *
+ * var api = new mw.Api();
+ * api.get( {
+ * action: 'query',
+ * meta: 'userinfo'
+ * } ).done ( function ( data ) {
+ * console.log( data );
+ * } );
+ *
+ * @class
+ *
+ * @constructor
+ * @param {Object} options See defaultOptions documentation above. Ajax options can also be
+ * overridden for each individual request to {@link jQuery#ajax} later on.
+ */
+ mw.Api = function ( options ) {
+
+ if ( options === undefined ) {
+ options = {};
+ }
+
+ // Force a string if we got a mw.Uri object
+ if ( options.ajax && options.ajax.url !== undefined ) {
+ options.ajax.url = String( options.ajax.url );
+ }
+
+ options.parameters = $.extend( {}, defaultOptions.parameters, options.parameters );
+ options.ajax = $.extend( {}, defaultOptions.ajax, options.ajax );
+
+ this.defaults = options;
+ };
+
+ mw.Api.prototype = {
+
+ /**
+ * Normalize the ajax options for compatibility and/or convenience methods.
+ *
+ * @param {Object} [arg] An object contaning one or more of options.ajax.
+ * @return {Object} Normalized ajax options.
+ */
+ normalizeAjaxOptions: function ( arg ) {
+ // Arg argument is usually empty
+ // (before MW 1.20 it was used to pass ok callbacks)
+ var opts = arg || {};
+ // Options can also be a success callback handler
+ if ( typeof arg === 'function' ) {
+ opts = { ok: arg };
+ }
+ return opts;
+ },
+
+ /**
+ * Perform API get request
+ *
+ * @param {Object} parameters
+ * @param {Object|Function} [ajaxOptions]
+ * @return {jQuery.Promise}
+ */
+ get: function ( parameters, ajaxOptions ) {
+ ajaxOptions = this.normalizeAjaxOptions( ajaxOptions );
+ ajaxOptions.type = 'GET';
+ return this.ajax( parameters, ajaxOptions );
+ },
+
+ /**
+ * Perform API post request
+ *
+ * TODO: Post actions for non-local hostnames will need proxy.
+ *
+ * @param {Object} parameters
+ * @param {Object|Function} [ajaxOptions]
+ * @return {jQuery.Promise}
+ */
+ post: function ( parameters, ajaxOptions ) {
+ ajaxOptions = this.normalizeAjaxOptions( ajaxOptions );
+ ajaxOptions.type = 'POST';
+ return this.ajax( parameters, ajaxOptions );
+ },
+
+ /**
+ * Perform the API call.
+ *
+ * @param {Object} parameters
+ * @param {Object} [ajaxOptions]
+ * @return {jQuery.Promise} Done: API response data and the jqXHR object.
+ * Fail: Error code
+ */
+ ajax: function ( parameters, ajaxOptions ) {
+ var token,
+ apiDeferred = $.Deferred(),
+ msg = 'Use of mediawiki.api callback params is deprecated. Use the Promise instead.',
+ xhr, key, formData;
+
+ parameters = $.extend( {}, this.defaults.parameters, parameters );
+ ajaxOptions = $.extend( {}, this.defaults.ajax, ajaxOptions );
+
+ // Ensure that token parameter is last (per [[mw:API:Edit#Token]]).
+ if ( parameters.token ) {
+ token = parameters.token;
+ delete parameters.token;
+ }
+
+ // If multipart/form-data has been requested and emulation is possible, emulate it
+ if (
+ ajaxOptions.type === 'POST' &&
+ window.FormData &&
+ ajaxOptions.contentType === 'multipart/form-data'
+ ) {
+
+ formData = new FormData();
+
+ for ( key in parameters ) {
+ formData.append( key, parameters[key] );
+ }
+ // If we extracted a token parameter, add it back in.
+ if ( token ) {
+ formData.append( 'token', token );
+ }
+
+ ajaxOptions.data = formData;
+
+ // Prevent jQuery from mangling our FormData object
+ ajaxOptions.processData = false;
+ // Prevent jQuery from overriding the Content-Type header
+ ajaxOptions.contentType = false;
+ } else {
+ // Some deployed MediaWiki >= 1.17 forbid periods in URLs, due to an IE XSS bug
+ // So let's escape them here. See bug #28235
+ // This works because jQuery accepts data as a query string or as an Object
+ ajaxOptions.data = $.param( parameters ).replace( /\./g, '%2E' );
+
+ // If we extracted a token parameter, add it back in.
+ if ( token ) {
+ ajaxOptions.data += '&token=' + encodeURIComponent( token );
+ }
+
+ if ( ajaxOptions.contentType === 'multipart/form-data' ) {
+ // We were asked to emulate but can't, so drop the Content-Type header, otherwise
+ // it'll be wrong and the server will fail to decode the POST body
+ delete ajaxOptions.contentType;
+ }
+ }
+
+ // Backwards compatibility: Before MediaWiki 1.20,
+ // callbacks were done with the 'ok' and 'err' property in ajaxOptions.
+ if ( ajaxOptions.ok ) {
+ mw.track( 'mw.deprecate', 'api.cbParam' );
+ mw.log.warn( msg );
+ apiDeferred.done( ajaxOptions.ok );
+ delete ajaxOptions.ok;
+ }
+ if ( ajaxOptions.err ) {
+ mw.track( 'mw.deprecate', 'api.cbParam' );
+ mw.log.warn( msg );
+ apiDeferred.fail( ajaxOptions.err );
+ delete ajaxOptions.err;
+ }
+
+ // Make the AJAX request
+ xhr = $.ajax( ajaxOptions )
+ // If AJAX fails, reject API call with error code 'http'
+ // and details in second argument.
+ .fail( function ( xhr, textStatus, exception ) {
+ apiDeferred.reject( 'http', {
+ xhr: xhr,
+ textStatus: textStatus,
+ exception: exception
+ } );
+ } )
+ // AJAX success just means "200 OK" response, also check API error codes
+ .done( function ( result, textStatus, jqXHR ) {
+ if ( result === undefined || result === null || result === '' ) {
+ apiDeferred.reject( 'ok-but-empty',
+ 'OK response but empty result (check HTTP headers?)'
+ );
+ } else if ( result.error ) {
+ var code = result.error.code === undefined ? 'unknown' : result.error.code;
+ apiDeferred.reject( code, result );
+ } else {
+ apiDeferred.resolve( result, jqXHR );
+ }
+ } );
+
+ // Return the Promise
+ return apiDeferred.promise( { abort: xhr.abort } ).fail( function ( code, details ) {
+ if ( !( code === 'http' && details && details.textStatus === 'abort' ) ) {
+ mw.log( 'mw.Api error: ', code, details );
+ }
+ } );
+ },
+
+ /**
+ * Post to API with specified type of token. If we have no token, get one and try to post.
+ * If we have a cached token try using that, and if it fails, blank out the
+ * cached token and start over. For example to change an user option you could do:
+ *
+ * new mw.Api().postWithToken( 'options', {
+ * action: 'options',
+ * optionname: 'gender',
+ * optionvalue: 'female'
+ * } );
+ *
+ * @param {string} tokenType The name of the token, like options or edit.
+ * @param {Object} params API parameters
+ * @param {Object} [ajaxOptions]
+ * @return {jQuery.Promise} See #post
+ * @since 1.22
+ */
+ postWithToken: function ( tokenType, params, ajaxOptions ) {
+ var api = this;
+
+ // Do not allow deprecated ok-callback
+ // FIXME: Remove this check when the deprecated ok-callback is removed in #post
+ if ( $.isFunction( ajaxOptions ) ) {
+ ajaxOptions = undefined;
+ }
+
+ return api.getToken( tokenType ).then( function ( token ) {
+ params.token = token;
+ return api.post( params, ajaxOptions ).then(
+ // If no error, return to caller as-is
+ null,
+ // Error handler
+ function ( code ) {
+ if ( code === 'badtoken' ) {
+ // Clear from cache
+ promises[ api.defaults.ajax.url ][ tokenType + 'Token' ] =
+ params.token = undefined;
+
+ // Try again, once
+ return api.getToken( tokenType ).then( function ( token ) {
+ params.token = token;
+ return api.post( params, ajaxOptions );
+ } );
+ }
+
+ // Different error, pass on to let caller handle the error code
+ return this;
+ }
+ );
+ } );
+ },
+
+ /**
+ * Get a token for a certain action from the API.
+ *
+ * @param {string} type Token type
+ * @return {jQuery.Promise}
+ * @return {Function} return.done
+ * @return {string} return.done.token Received token.
+ * @since 1.22
+ */
+ getToken: function ( type ) {
+ var apiPromise,
+ promiseGroup = promises[ this.defaults.ajax.url ],
+ d = promiseGroup && promiseGroup[ type + 'Token' ];
+
+ if ( !d ) {
+ apiPromise = this.get( { action: 'tokens', type: type } );
+
+ d = apiPromise
+ .then( function ( data ) {
+ // If token type is not available for this user,
+ // key '...token' is either missing or set to boolean false
+ if ( data.tokens && data.tokens[type + 'token'] ) {
+ return data.tokens[type + 'token'];
+ }
+
+ return $.Deferred().reject( 'token-missing', data );
+ }, function () {
+ // Clear promise. Do not cache errors.
+ delete promiseGroup[ type + 'Token' ];
+
+ // Pass on to allow the caller to handle the error
+ return this;
+ } )
+ // Attach abort handler
+ .promise( { abort: apiPromise.abort } );
+
+ // Store deferred now so that we can use it again even if it isn't ready yet
+ if ( !promiseGroup ) {
+ promiseGroup = promises[ this.defaults.ajax.url ] = {};
+ }
+ promiseGroup[ type + 'Token' ] = d;
+ }
+
+ return d;
+ }
+ };
+
+ /**
+ * @static
+ * @property {Array}
+ * List of errors we might receive from the API.
+ * For now, this just documents our expectation that there should be similar messages
+ * available.
+ */
+ mw.Api.errors = [
+ // occurs when POST aborted
+ // jQuery 1.4 can't distinguish abort or lost connection from 200 OK + empty result
+ 'ok-but-empty',
+
+ // timeout
+ 'timeout',
+
+ // really a warning, but we treat it like an error
+ 'duplicate',
+ 'duplicate-archive',
+
+ // upload succeeded, but no image info.
+ // this is probably impossible, but might as well check for it
+ 'noimageinfo',
+ // remote errors, defined in API
+ 'uploaddisabled',
+ 'nomodule',
+ 'mustbeposted',
+ 'badaccess-groups',
+ 'stashfailed',
+ 'missingresult',
+ 'missingparam',
+ 'invalid-file-key',
+ 'copyuploaddisabled',
+ 'mustbeloggedin',
+ 'empty-file',
+ 'file-too-large',
+ 'filetype-missing',
+ 'filetype-banned',
+ 'filetype-banned-type',
+ 'filename-tooshort',
+ 'illegal-filename',
+ 'verification-error',
+ 'hookaborted',
+ 'unknown-error',
+ 'internal-error',
+ 'overwrite',
+ 'badtoken',
+ 'fetchfileerror',
+ 'fileexists-shared-forbidden',
+ 'invalidtitle',
+ 'notloggedin'
+ ];
+
+ /**
+ * @static
+ * @property {Array}
+ * List of warnings we might receive from the API.
+ * For now, this just documents our expectation that there should be similar messages
+ * available.
+ */
+ mw.Api.warnings = [
+ 'duplicate',
+ 'exists'
+ ];
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.api/mediawiki.api.login.js b/resources/src/mediawiki.api/mediawiki.api.login.js
new file mode 100644
index 00000000..ccbae06c
--- /dev/null
+++ b/resources/src/mediawiki.api/mediawiki.api.login.js
@@ -0,0 +1,54 @@
+/**
+ * Make the two-step login easier.
+ * @author Niklas Laxström
+ * @class mw.Api.plugin.login
+ * @since 1.22
+ */
+( function ( mw, $ ) {
+ 'use strict';
+
+ $.extend( mw.Api.prototype, {
+ /**
+ * @param {string} username
+ * @param {string} password
+ * @return {jQuery.Promise} See mw.Api#post
+ */
+ login: function ( username, password ) {
+ var params, request,
+ deferred = $.Deferred(),
+ api = this;
+
+ params = {
+ action: 'login',
+ lgname: username,
+ lgpassword: password
+ };
+
+ request = api.post( params );
+ request.fail( deferred.reject );
+ request.done( function ( data ) {
+ params.lgtoken = data.login.token;
+ api.post( params )
+ .fail( deferred.reject )
+ .done( function ( data ) {
+ var code;
+ if ( data.login && data.login.result === 'Success' ) {
+ deferred.resolve( data );
+ } else {
+ // Set proper error code whenever possible
+ code = data.error && data.error.code || 'unknown';
+ deferred.reject( code, data );
+ }
+ } );
+ } );
+
+ return deferred.promise( { abort: request.abort } );
+ }
+ } );
+
+ /**
+ * @class mw.Api
+ * @mixins mw.Api.plugin.login
+ */
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.api/mediawiki.api.parse.js b/resources/src/mediawiki.api/mediawiki.api.parse.js
new file mode 100644
index 00000000..b1f1d2b0
--- /dev/null
+++ b/resources/src/mediawiki.api/mediawiki.api.parse.js
@@ -0,0 +1,45 @@
+/**
+ * @class mw.Api.plugin.parse
+ */
+( function ( mw, $ ) {
+
+ $.extend( mw.Api.prototype, {
+ /**
+ * Convenience method for 'action=parse'.
+ *
+ * @param {string} wikitext
+ * @param {Function} [ok] Success callback (deprecated)
+ * @param {Function} [err] Error callback (deprecated)
+ * @return {jQuery.Promise}
+ * @return {Function} return.done
+ * @return {string} return.done.data Parsed HTML of `wikitext`.
+ */
+ parse: function ( wikitext, ok, err ) {
+ var apiPromise = this.get( {
+ action: 'parse',
+ contentmodel: 'wikitext',
+ text: wikitext
+ } );
+
+ // Backwards compatibility (< MW 1.20)
+ if ( ok || err ) {
+ mw.track( 'mw.deprecate', 'api.cbParam' );
+ mw.log.warn( 'Use of mediawiki.api callback params is deprecated. Use the Promise instead.' );
+ }
+
+ return apiPromise
+ .then( function ( data ) {
+ return data.parse.text['*'];
+ } )
+ .done( ok )
+ .fail( err )
+ .promise( { abort: apiPromise.abort } );
+ }
+ } );
+
+ /**
+ * @class mw.Api
+ * @mixins mw.Api.plugin.parse
+ */
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.api/mediawiki.api.watch.js b/resources/src/mediawiki.api/mediawiki.api.watch.js
new file mode 100644
index 00000000..af2dee10
--- /dev/null
+++ b/resources/src/mediawiki.api/mediawiki.api.watch.js
@@ -0,0 +1,79 @@
+/**
+ * @class mw.Api.plugin.watch
+ * @since 1.19
+ */
+( function ( mw, $ ) {
+
+ /**
+ * @private
+ * @static
+ * @context mw.Api
+ *
+ * @param {string|mw.Title|string[]|mw.Title[]} pages Full page name or instance of mw.Title, or an
+ * array thereof. If an array is passed, the return value passed to the promise will also be an
+ * array of appropriate objects.
+ * @param {Function} [ok] Success callback (deprecated)
+ * @param {Function} [err] Error callback (deprecated)
+ * @return {jQuery.Promise}
+ * @return {Function} return.done
+ * @return {Object|Object[]} return.done.watch Object or list of objects (depends on the `pages`
+ * parameter)
+ * @return {string} return.done.watch.title Full pagename
+ * @return {boolean} return.done.watch.watched Whether the page is now watched or unwatched
+ * @return {string} return.done.watch.message Parsed HTML of the confirmational interface message
+ */
+ function doWatchInternal( pages, ok, err, addParams ) {
+ // XXX: Parameter addParams is undocumented because we inherit this
+ // documentation in the public method...
+ var apiPromise = this.postWithToken( 'watch',
+ $.extend(
+ {
+ action: 'watch',
+ titles: $.isArray( pages ) ? pages.join( '|' ) : String( pages ),
+ uselang: mw.config.get( 'wgUserLanguage' )
+ },
+ addParams
+ )
+ );
+
+ // Backwards compatibility (< MW 1.20)
+ if ( ok || err ) {
+ mw.track( 'mw.deprecate', 'api.cbParam' );
+ mw.log.warn( 'Use of mediawiki.api callback params is deprecated. Use the Promise instead.' );
+ }
+
+ return apiPromise
+ .then( function ( data ) {
+ // If a single page was given (not an array) respond with a single item as well.
+ return $.isArray( pages ) ? data.watch : data.watch[0];
+ } )
+ .done( ok )
+ .fail( err )
+ .promise( { abort: apiPromise.abort } );
+ }
+
+ $.extend( mw.Api.prototype, {
+ /**
+ * Convenience method for `action=watch`.
+ *
+ * @inheritdoc #doWatchInternal
+ */
+ watch: function ( pages, ok, err ) {
+ return doWatchInternal.call( this, pages, ok, err );
+ },
+ /**
+ * Convenience method for `action=watch&unwatch=1`.
+ *
+ * @inheritdoc #doWatchInternal
+ */
+ unwatch: function ( pages, ok, err ) {
+ return doWatchInternal.call( this, pages, ok, err, { unwatch: 1 } );
+ }
+ } );
+
+ /**
+ * @class mw.Api
+ * @mixins mw.Api.plugin.watch
+ */
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.hidpi-skip.js b/resources/src/mediawiki.hidpi-skip.js
new file mode 100644
index 00000000..26b63c7b
--- /dev/null
+++ b/resources/src/mediawiki.hidpi-skip.js
@@ -0,0 +1,4 @@
+/*!
+ * Skip function for mediawiki.hdpi.js.
+ */
+return 'srcset' in new Image();
diff --git a/resources/src/mediawiki.language/languages/bs.js b/resources/src/mediawiki.language/languages/bs.js
new file mode 100644
index 00000000..b56e4b29
--- /dev/null
+++ b/resources/src/mediawiki.language/languages/bs.js
@@ -0,0 +1,19 @@
+/*!
+ * Bosnian (bosanski) language functions
+ */
+
+mediaWiki.language.convertGrammar = function ( word, form ) {
+ var grammarForms = mediaWiki.language.getData( 'bs', 'grammarForms' );
+ if ( grammarForms && grammarForms[form] ) {
+ return grammarForms[form][word];
+ }
+ switch ( form ) {
+ case 'instrumental': // instrumental
+ word = 's ' + word;
+ break;
+ case 'lokativ': // locative
+ word = 'o ' + word;
+ break;
+ }
+ return word;
+};
diff --git a/resources/src/mediawiki.language/languages/dsb.js b/resources/src/mediawiki.language/languages/dsb.js
new file mode 100644
index 00000000..69c36cc0
--- /dev/null
+++ b/resources/src/mediawiki.language/languages/dsb.js
@@ -0,0 +1,19 @@
+/*!
+ * Lower Sorbian (Dolnoserbski) language functions
+ */
+
+mediaWiki.language.convertGrammar = function ( word, form ) {
+ var grammarForms = mediaWiki.language.getData( 'dsb', 'grammarForms' );
+ if ( grammarForms && grammarForms[form] ) {
+ return grammarForms[form][word];
+ }
+ switch ( form ) {
+ case 'instrumental': // instrumental
+ word = 'z ' + word;
+ break;
+ case 'lokatiw': // lokatiw
+ word = 'wo ' + word;
+ break;
+ }
+ return word;
+};
diff --git a/resources/src/mediawiki.language/languages/fi.js b/resources/src/mediawiki.language/languages/fi.js
new file mode 100644
index 00000000..453a675d
--- /dev/null
+++ b/resources/src/mediawiki.language/languages/fi.js
@@ -0,0 +1,47 @@
+/*!
+ * Finnish (Suomi) language functions
+ * @author Santhosh Thottingal
+ */
+
+mediaWiki.language.convertGrammar = function ( word, form ) {
+ var grammarForms, aou, origWord;
+
+ grammarForms = mediaWiki.language.getData( 'fi', 'grammarForms' );
+ if ( grammarForms && grammarForms[form] ) {
+ return grammarForms[form][word];
+ }
+
+ // vowel harmony flag
+ aou = word.match( /[aou][^äöy]*$/i );
+ origWord = word;
+ if ( word.match( /wiki$/i ) ) {
+ aou = false;
+ }
+ //append i after final consonant
+ if ( word.match( /[bcdfghjklmnpqrstvwxz]$/i ) ) {
+ word += 'i';
+ }
+
+ switch ( form ) {
+ case 'genitive':
+ word += 'n';
+ break;
+ case 'elative':
+ word += ( aou ? 'sta' : 'stä' );
+ break;
+ case 'partitive':
+ word += ( aou ? 'a' : 'ä' );
+ break;
+ case 'illative':
+ // Double the last letter and add 'n'
+ word += word.slice( -1 ) + 'n';
+ break;
+ case 'inessive':
+ word += ( aou ? 'ssa' : 'ssä' );
+ break;
+ default:
+ word = origWord;
+ break;
+ }
+ return word;
+};
diff --git a/resources/src/mediawiki.language/languages/ga.js b/resources/src/mediawiki.language/languages/ga.js
new file mode 100644
index 00000000..fb4e9396
--- /dev/null
+++ b/resources/src/mediawiki.language/languages/ga.js
@@ -0,0 +1,38 @@
+/*!
+ * Irish (Gaeilge) language functions
+ */
+
+mediaWiki.language.convertGrammar = function ( word, form ) {
+ /*jshint onecase:true */
+ var grammarForms = mediaWiki.language.getData( 'ga', 'grammarForms' );
+ if ( grammarForms && grammarForms[form] ) {
+ return grammarForms[form][word];
+ }
+ switch ( form ) {
+ case 'ainmlae':
+ switch ( word ) {
+ case 'an Domhnach':
+ word = 'Dé Domhnaigh';
+ break;
+ case 'an Luan':
+ word = 'Dé Luain';
+ break;
+ case 'an Mháirt':
+ word = 'Dé Mháirt';
+ break;
+ case 'an Chéadaoin':
+ word = 'Dé Chéadaoin';
+ break;
+ case 'an Déardaoin':
+ word = 'Déardaoin';
+ break;
+ case 'an Aoine':
+ word = 'Dé hAoine';
+ break;
+ case 'an Satharn':
+ word = 'Dé Sathairn';
+ break;
+ }
+ }
+ return word;
+};
diff --git a/resources/src/mediawiki.language/languages/he.js b/resources/src/mediawiki.language/languages/he.js
new file mode 100644
index 00000000..d1eba43b
--- /dev/null
+++ b/resources/src/mediawiki.language/languages/he.js
@@ -0,0 +1,29 @@
+/*!
+ * Hebrew (עברית) language functions
+ */
+
+mediaWiki.language.convertGrammar = function ( word, form ) {
+ var grammarForms = mediaWiki.language.getData( 'he', 'grammarForms' );
+ if ( grammarForms && grammarForms[form] ) {
+ return grammarForms[form][word];
+ }
+ switch ( form ) {
+ case 'prefixed':
+ case 'תחילית': // the same word in Hebrew
+ // Duplicate prefixed "Waw", but only if it's not already double
+ if ( word.slice( 0, 1 ) === 'ו' && word.slice( 0, 2 ) !== 'וו' ) {
+ word = 'ו' + word;
+ }
+
+ // Remove the "He" if prefixed
+ if ( word.slice( 0, 1 ) === 'ה' ) {
+ word = word.slice( 1 );
+ }
+
+ // Add a hyphen (maqaf) before numbers and non-Hebrew letters
+ if ( word.slice( 0, 1 ) < 'א' || word.slice( 0, 1 ) > 'ת' ) {
+ word = '־' + word;
+ }
+ }
+ return word;
+};
diff --git a/resources/src/mediawiki.language/languages/hsb.js b/resources/src/mediawiki.language/languages/hsb.js
new file mode 100644
index 00000000..2d6b733e
--- /dev/null
+++ b/resources/src/mediawiki.language/languages/hsb.js
@@ -0,0 +1,19 @@
+/*!
+ * Upper Sorbian (Hornjoserbsce) language functions
+ */
+
+mediaWiki.language.convertGrammar = function ( word, form ) {
+ var grammarForms = mediaWiki.language.getData( 'hsb', 'grammarForms' );
+ if ( grammarForms && grammarForms[form] ) {
+ return grammarForms[form][word];
+ }
+ switch ( form ) {
+ case 'instrumental': // instrumental
+ word = 'z ' + word;
+ break;
+ case 'lokatiw': // lokatiw
+ word = 'wo ' + word;
+ break;
+ }
+ return word;
+};
diff --git a/resources/src/mediawiki.language/languages/hu.js b/resources/src/mediawiki.language/languages/hu.js
new file mode 100644
index 00000000..d72a1c05
--- /dev/null
+++ b/resources/src/mediawiki.language/languages/hu.js
@@ -0,0 +1,23 @@
+/*!
+ * Hungarian language functions
+ * @author Santhosh Thottingal
+ */
+
+mediaWiki.language.convertGrammar = function ( word, form ) {
+ var grammarForms = mediaWiki.language.getData( 'hu', 'grammarForms' );
+ if ( grammarForms && grammarForms[form] ) {
+ return grammarForms[form][word];
+ }
+ switch ( form ) {
+ case 'rol':
+ word += 'ról';
+ break;
+ case 'ba':
+ word += 'ba';
+ break;
+ case 'k':
+ word += 'k';
+ break;
+ }
+ return word;
+};
diff --git a/resources/src/mediawiki.language/languages/hy.js b/resources/src/mediawiki.language/languages/hy.js
new file mode 100644
index 00000000..9cae360b
--- /dev/null
+++ b/resources/src/mediawiki.language/languages/hy.js
@@ -0,0 +1,29 @@
+/*!
+ * Armenian (Հայերեն) language functions
+ */
+
+mediaWiki.language.convertGrammar = function ( word, form ) {
+ /*jshint onecase:true */
+ var grammarForms = mediaWiki.language.getData( 'hy', 'grammarForms' );
+ if ( grammarForms && grammarForms[form] ) {
+ return grammarForms[form][word];
+ }
+
+ // These rules are not perfect, but they are currently only used for site names so it doesn't
+ // matter if they are wrong sometimes. Just add a special case for your site name if necessary.
+
+ switch ( form ) {
+ case 'genitive': // սեռական հոլով
+ if ( word.slice( -1 ) === 'ա' ) {
+ word = word.slice( 0, -1 ) + 'այի';
+ } else if ( word.slice( -1 ) === 'ո' ) {
+ word = word.slice( 0, -1 ) + 'ոյի';
+ } else if ( word.slice( -4 ) === 'գիրք' ) {
+ word = word.slice( 0, -4 ) + 'գրքի';
+ } else {
+ word = word + 'ի';
+ }
+ break;
+ }
+ return word;
+};
diff --git a/resources/src/mediawiki.language/languages/la.js b/resources/src/mediawiki.language/languages/la.js
new file mode 100644
index 00000000..52e8dd44
--- /dev/null
+++ b/resources/src/mediawiki.language/languages/la.js
@@ -0,0 +1,50 @@
+/*!
+ * Latin (lingua Latina) language functions
+ * @author Santhosh Thottingal
+ */
+
+mediaWiki.language.convertGrammar = function ( word, form ) {
+ var grammarForms = mediaWiki.language.getData( 'la', 'grammarForms' );
+ if ( grammarForms && grammarForms[form] ) {
+ return grammarForms[form][word];
+ }
+ switch ( form ) {
+ case 'genitive':
+ // only a few declensions, and even for those mostly the singular only
+ word = word.replace( /u[ms]$/i, 'i' ); // 2nd declension singular
+ word = word.replace( /ommunia$/i, 'ommunium' ); // 3rd declension neuter plural (partly)
+ word = word.replace( /a$/i, 'ae' ); // 1st declension singular
+ word = word.replace( /libri$/i, 'librorum' ); // 2nd declension plural (partly)
+ word = word.replace( /nuntii$/i, 'nuntiorum' ); // 2nd declension plural (partly)
+ word = word.replace( /tio$/i, 'tionis' ); // 3rd declension singular (partly)
+ word = word.replace( /ns$/i, 'ntis' );
+ word = word.replace( /as$/i, 'atis' );
+ word = word.replace( /es$/i, 'ei' ); // 5th declension singular
+ break;
+ case 'accusative':
+ // only a few declensions, and even for those mostly the singular only
+ word = word.replace( /u[ms]$/i, 'um' ); // 2nd declension singular
+ word = word.replace( /ommunia$/i, 'am' ); // 3rd declension neuter plural (partly)
+ word = word.replace( /a$/i, 'ommunia' ); // 1st declension singular
+ word = word.replace( /libri$/i, 'libros' ); // 2nd declension plural (partly)
+ word = word.replace( /nuntii$/i, 'nuntios' );// 2nd declension plural (partly)
+ word = word.replace( /tio$/i, 'tionem' ); // 3rd declension singular (partly)
+ word = word.replace( /ns$/i, 'ntem' );
+ word = word.replace( /as$/i, 'atem');
+ word = word.replace( /es$/i, 'em' ); // 5th declension singular
+ break;
+ case 'ablative':
+ // only a few declensions, and even for those mostly the singular only
+ word = word.replace( /u[ms]$/i, 'o' ); // 2nd declension singular
+ word = word.replace( /ommunia$/i, 'ommunibus' ); // 3rd declension neuter plural (partly)
+ word = word.replace( /a$/i, 'a' ); // 1st declension singular
+ word = word.replace( /libri$/i, 'libris' ); // 2nd declension plural (partly)
+ word = word.replace( /nuntii$/i, 'nuntiis' ); // 2nd declension plural (partly)
+ word = word.replace( /tio$/i, 'tione' ); // 3rd declension singular (partly)
+ word = word.replace( /ns$/i, 'nte' );
+ word = word.replace( /as$/i, 'ate');
+ word = word.replace( /es$/i, 'e' ); // 5th declension singular
+ break;
+ }
+ return word;
+};
diff --git a/resources/src/mediawiki.language/languages/os.js b/resources/src/mediawiki.language/languages/os.js
new file mode 100644
index 00000000..787be36d
--- /dev/null
+++ b/resources/src/mediawiki.language/languages/os.js
@@ -0,0 +1,69 @@
+/*!
+ * Ossetian (Ирон) language functions
+ * @author Santhosh Thottingal
+ */
+
+mediaWiki.language.convertGrammar = function ( word, form ) {
+ var grammarForms = mediaWiki.language.getData( 'os', 'grammarForms' ),
+ // Ending for allative case
+ endAllative = 'мæ',
+ // Variable for 'j' beetwen vowels
+ jot = '',
+ // Variable for "-" for not Ossetic words
+ hyphen = '',
+ // Variable for ending
+ ending = '';
+
+ if ( grammarForms && grammarForms[form] ) {
+ return grammarForms[form][word];
+ }
+ // Checking if the $word is in plural form
+ if ( word.match( /тæ$/i ) ) {
+ word = word.slice( 0, -1 );
+ endAllative = 'æм';
+ }
+ // Works if word is in singular form.
+ // Checking if word ends on one of the vowels: е, ё, и, о, ы, э, ю, я.
+ else if ( word.match( /[аæеёиоыэюя]$/i ) ) {
+ jot = 'й';
+ }
+ // Checking if word ends on 'у'. 'У' can be either consonant 'W' or vowel 'U' in cyrillic Ossetic.
+ // Examples: {{grammar:genitive|аунеу}} = аунеуы, {{grammar:genitive|лæппу}} = лæппуйы.
+ else if ( word.match( /у$/i ) ) {
+ if ( !word.slice( -2, -1 ).match( /[аæеёиоыэюя]$/i ) ) {
+ jot = 'й';
+ }
+ } else if ( !word.match( /[бвгджзйклмнопрстфхцчшщьъ]$/i ) ) {
+ hyphen = '-';
+ }
+
+ switch ( form ) {
+ case 'genitive':
+ ending = hyphen + jot + 'ы';
+ break;
+ case 'dative':
+ ending = hyphen + jot + 'æн';
+ break;
+ case 'allative':
+ ending = hyphen + endAllative;
+ break;
+ case 'ablative':
+ if ( jot === 'й' ) {
+ ending = hyphen + jot + 'æ';
+ }
+ else {
+ ending = hyphen + jot + 'æй';
+ }
+ break;
+ case 'superessive':
+ ending = hyphen + jot + 'ыл';
+ break;
+ case 'equative':
+ ending = hyphen + jot + 'ау';
+ break;
+ case 'comitative':
+ ending = hyphen + 'имæ';
+ break;
+ }
+ return word + ending;
+};
diff --git a/resources/src/mediawiki.language/languages/ru.js b/resources/src/mediawiki.language/languages/ru.js
new file mode 100644
index 00000000..2077b6be
--- /dev/null
+++ b/resources/src/mediawiki.language/languages/ru.js
@@ -0,0 +1,57 @@
+/*!
+ * Russian (Русский) language functions
+ */
+
+// These tests were originally made for names of Wikimedia
+// websites, so they don't currently cover all the possible
+// cases.
+
+mediaWiki.language.convertGrammar = function ( word, form ) {
+ 'use strict';
+
+ var grammarForms = mediaWiki.language.getData( 'ru', 'grammarForms' );
+ if ( grammarForms && grammarForms[form] ) {
+ return grammarForms[form][word];
+ }
+ switch ( form ) {
+ case 'genitive': // родительный падеж
+ if ( word.slice( -1 ) === 'ь' ) {
+ word = word.slice( 0, -1 ) + 'я';
+ } else if ( word.slice( -2 ) === 'ия' ) {
+ word = word.slice( 0, -2 ) + 'ии';
+ } else if ( word.slice( -2 ) === 'ка' ) {
+ word = word.slice( 0, -2 ) + 'ки';
+ } else if ( word.slice( -2 ) === 'ти' ) {
+ word = word.slice( 0, -2 ) + 'тей';
+ } else if ( word.slice( -2 ) === 'ды' ) {
+ word = word.slice( 0, -2 ) + 'дов';
+ } else if ( word.slice( -1 ) === 'д' ) {
+ word = word.slice( 0, -1 ) + 'да';
+ } else if ( word.slice( -3 ) === 'ные' ) {
+ word = word.slice( 0, -3 ) + 'ных';
+ } else if ( word.slice( -3 ) === 'ник' ) {
+ word = word.slice( 0, -3 ) + 'ника';
+ }
+ break;
+ case 'prepositional': // предложный падеж
+ if ( word.slice( -1 ) === 'ь' ) {
+ word = word.slice( 0, -1 ) + 'е';
+ } else if ( word.slice( -2 ) === 'ия' ) {
+ word = word.slice( 0, -2 ) + 'ии';
+ } else if ( word.slice( -2 ) === 'ка' ) {
+ word = word.slice( 0, -2 ) + 'ке';
+ } else if ( word.slice( -2 ) === 'ти' ) {
+ word = word.slice( 0, -2 ) + 'тях';
+ } else if ( word.slice( -2 ) === 'ды' ) {
+ word = word.slice( 0, -2 ) + 'дах';
+ } else if ( word.slice( -1 ) === 'д' ) {
+ word = word.slice( 0, -1 ) + 'де';
+ } else if ( word.slice( -3 ) === 'ные' ) {
+ word = word.slice( 0, -3 ) + 'ных';
+ } else if ( word.slice( -3 ) === 'ник' ) {
+ word = word.slice( 0, -3 ) + 'нике';
+ }
+ break;
+ }
+ return word;
+};
diff --git a/resources/src/mediawiki.language/languages/sl.js b/resources/src/mediawiki.language/languages/sl.js
new file mode 100644
index 00000000..d20d0b34
--- /dev/null
+++ b/resources/src/mediawiki.language/languages/sl.js
@@ -0,0 +1,19 @@
+/*!
+ * Slovenian (Slovenščina) language functions
+ */
+
+mediaWiki.language.convertGrammar = function ( word, form ) {
+ var grammarForms = mediaWiki.language.getData( 'sl', 'grammarForms' );
+ if ( grammarForms && grammarForms[form] ) {
+ return grammarForms[form][word];
+ }
+ switch ( form ) {
+ case 'mestnik': // locative
+ word = 'o ' + word;
+ break;
+ case 'orodnik': // instrumental
+ word = 'z ' + word;
+ break;
+ }
+ return word;
+};
diff --git a/resources/src/mediawiki.language/languages/uk.js b/resources/src/mediawiki.language/languages/uk.js
new file mode 100644
index 00000000..550a388c
--- /dev/null
+++ b/resources/src/mediawiki.language/languages/uk.js
@@ -0,0 +1,37 @@
+/*!
+ * Ukrainian (Українська) language functions
+ */
+
+mediaWiki.language.convertGrammar = function ( word, form ) {
+ var grammarForms = mediaWiki.language.getData( 'uk', 'grammarForms' );
+ if ( grammarForms && grammarForms[form] ) {
+ return grammarForms[form][word];
+ }
+ switch ( form ) {
+ case 'genitive': // родовий відмінок
+ if ( word.slice( -4 ) !== 'вікі' && word.slice( -4 ) !== 'Вікі' ) {
+ if ( word.slice( -1 ) === 'ь' ) {
+ word = word.slice(0, -1 ) + 'я';
+ } else if ( word.slice( -2 ) === 'ія' ) {
+ word = word.slice(0, -2 ) + 'ії';
+ } else if ( word.slice( -2 ) === 'ка' ) {
+ word = word.slice(0, -2 ) + 'ки';
+ } else if ( word.slice( -2 ) === 'ти' ) {
+ word = word.slice(0, -2 ) + 'тей';
+ } else if ( word.slice( -2 ) === 'ды' ) {
+ word = word.slice(0, -2 ) + 'дов';
+ } else if ( word.slice( -3 ) === 'ник' ) {
+ word = word.slice(0, -3 ) + 'ника';
+ }
+ }
+ break;
+ case 'accusative': // знахідний відмінок
+ if ( word.slice( -4 ) !== 'вікі' && word.slice( -4 ) !== 'Вікі' ) {
+ if ( word.slice( -2 ) === 'ія' ) {
+ word = word.slice(0, -2 ) + 'ію';
+ }
+ }
+ break;
+ }
+ return word;
+};
diff --git a/resources/src/mediawiki.language/mediawiki.cldr.js b/resources/src/mediawiki.language/mediawiki.cldr.js
new file mode 100644
index 00000000..f6fb8f10
--- /dev/null
+++ b/resources/src/mediawiki.language/mediawiki.cldr.js
@@ -0,0 +1,32 @@
+( function ( mw ) {
+ 'use strict';
+
+ /**
+ * Namespace for CLDR-related utility methods.
+ *
+ * @class
+ * @singleton
+ */
+ mw.cldr = {
+ /**
+ * Get the plural form index for the number.
+ *
+ * In case none of the rules passed, we return `pluralRules.length` -
+ * that means it is the "other" form.
+ *
+ * @param {number} number
+ * @param {Array} pluralRules
+ * @return {number} plural form index
+ */
+ getPluralForm: function ( number, pluralRules ) {
+ var i;
+ for ( i = 0; i < pluralRules.length; i++ ) {
+ if ( mw.libs.pluralRuleParser( pluralRules[i], number ) ) {
+ break;
+ }
+ }
+ return i;
+ }
+ };
+
+}( mediaWiki ) );
diff --git a/resources/src/mediawiki.language/mediawiki.language.fallback.js b/resources/src/mediawiki.language/mediawiki.language.fallback.js
new file mode 100644
index 00000000..b1bab02a
--- /dev/null
+++ b/resources/src/mediawiki.language/mediawiki.language.fallback.js
@@ -0,0 +1,35 @@
+/*
+ * Language-fallback-chain-related utilities for mediawiki.language.
+ */
+( function ( mw, $ ) {
+ /**
+ * @class mw.language
+ */
+
+ $.extend( mw.language, {
+
+ /**
+ * Get the language fallback chain for current UI language (not including the language itself).
+ *
+ * @return {string[]} List of language keys, e.g. `['de', 'en']`
+ */
+ getFallbackLanguages: function () {
+ return mw.language.getData(
+ mw.config.get( 'wgUserLanguage' ),
+ 'fallbackLanguages'
+ ) || [];
+ },
+
+ /**
+ * Get the language fallback chain for current UI language, including the language itself.
+ *
+ * @return {string[]} List of language keys, e.g. `['pfl', de', 'en']`
+ */
+ getFallbackLanguageChain: function () {
+ return [ mw.config.get( 'wgUserLanguage' ) ]
+ .concat( mw.language.getFallbackLanguages() );
+ }
+
+ } );
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.language/mediawiki.language.init.js b/resources/src/mediawiki.language/mediawiki.language.init.js
new file mode 100644
index 00000000..df95d751
--- /dev/null
+++ b/resources/src/mediawiki.language/mediawiki.language.init.js
@@ -0,0 +1,81 @@
+( function ( mw ) {
+ /**
+ * Base language object with methods related to language support, attempting to mirror some of the
+ * functionality of the Language class in MediaWiki:
+ *
+ * - storing and retrieving language data
+ * - transforming message syntax (`{{PLURAL:}}`, `{{GRAMMAR:}}`, `{{GENDER:}}`)
+ * - formatting numbers
+ *
+ * @class
+ * @singleton
+ */
+ mw.language = {
+ /**
+ * Language-related data (keyed by language, contains instances of mw.Map).
+ * Loaded dynamically (see ResourceLoaderLanguageDataModule class in PHP, registered
+ * as mediawiki.language.data on the client).
+ *
+ * To set data:
+ *
+ * // Override, extend or create the language data object of 'nl'
+ * mw.language.setData( 'nl', 'myKey', 'My value' );
+ *
+ * // Set multiple key/values pairs at once
+ * mw.language.setData( 'nl', { foo: 'X', bar: 'Y' } );
+ *
+ * To get GrammarForms data for language 'nl':
+ *
+ * var grammarForms = mw.language.getData( 'nl', 'grammarForms' );
+ *
+ * Possible data keys:
+ *
+ * - `digitTransformTable`
+ * - `separatorTransformTable`
+ * - `grammarForms`
+ * - `pluralRules`
+ * - `digitGroupingPattern`
+ * - `fallbackLanguages`
+ *
+ * @property
+ */
+ data: {},
+
+ /**
+ * Convenience method for retrieving language data.
+ *
+ * Structured by language code and data key, covering for the potential inexistence of a
+ * data object for this language.
+ *
+ * @param {string} langCode
+ * @param {string} dataKey
+ * @return {Mixed} Value stored in the mw.Map (or `undefined` if there is no map for the
+ * specified langCode)
+ */
+ getData: function ( langCode, dataKey ) {
+ var langData = mw.language.data;
+ if ( langData && langData[langCode] instanceof mw.Map ) {
+ return langData[langCode].get( dataKey );
+ }
+ return undefined;
+ },
+
+ /**
+ * Convenience method for setting language data.
+ *
+ * Creates the data mw.Map if there isn't one for the specified language already.
+ *
+ * @param {string} langCode
+ * @param {string|Object} dataKey Key or object of key/values
+ * @param {Mixed} [value] Value for dataKey, omit if dataKey is an object
+ */
+ setData: function ( langCode, dataKey, value ) {
+ var langData = mw.language.data;
+ if ( !( langData[langCode] instanceof mw.Map ) ) {
+ langData[langCode] = new mw.Map();
+ }
+ langData[langCode].set( dataKey, value );
+ }
+ };
+
+}( mediaWiki ) );
diff --git a/resources/src/mediawiki.language/mediawiki.language.js b/resources/src/mediawiki.language/mediawiki.language.js
new file mode 100644
index 00000000..d4f3c69e
--- /dev/null
+++ b/resources/src/mediawiki.language/mediawiki.language.js
@@ -0,0 +1,171 @@
+/*
+ * Methods for transforming message syntax.
+ */
+( function ( mw, $ ) {
+
+/**
+ * @class mw.language
+ */
+$.extend( mw.language, {
+
+ /**
+ * Process the PLURAL template substitution
+ *
+ * @private
+ * @param {Object} template Template object
+ * @param {string} template.title
+ * @param {Array} template.parameters
+ * @return {string}
+ */
+ procPLURAL: function ( template ) {
+ if ( template.title && template.parameters && mw.language.convertPlural ) {
+ // Check if we have forms to replace
+ if ( template.parameters.length === 0 ) {
+ return '';
+ }
+ // Restore the count into a Number ( if it got converted earlier )
+ var count = mw.language.convertNumber( template.title, true );
+ // Do convertPlural call
+ return mw.language.convertPlural( parseInt( count, 10 ), template.parameters );
+ }
+ // Could not process plural return first form or nothing
+ if ( template.parameters[0] ) {
+ return template.parameters[0];
+ }
+ return '';
+ },
+
+ /**
+ * Plural form transformations, needed for some languages.
+ *
+ * @param {number} count Non-localized quantifier
+ * @param {Array} forms List of plural forms
+ * @return {string} Correct form for quantifier in this language
+ */
+ convertPlural: function ( count, forms ) {
+ var pluralRules,
+ formCount,
+ form,
+ index,
+ equalsPosition,
+ pluralFormIndex = 0;
+
+ if ( !forms || forms.length === 0 ) {
+ return '';
+ }
+
+ // Handle for explicit n= forms
+ for ( index = 0; index < forms.length; index++ ) {
+ form = forms[index];
+ if ( /^\d+=/.test( form ) ) {
+ equalsPosition = form.indexOf( '=' );
+ formCount = parseInt( form.slice( 0, equalsPosition ), 10 );
+ if ( formCount === count ) {
+ return form.slice( equalsPosition + 1 );
+ }
+ forms[index] = undefined;
+ }
+ }
+
+ // Remove explicit plural forms from the forms.
+ forms = $.map( forms, function ( form ) {
+ return form;
+ } );
+
+ if ( forms.length === 0 ) {
+ return '';
+ }
+
+ pluralRules = mw.language.getData( mw.config.get( 'wgUserLanguage' ), 'pluralRules' );
+ if ( !pluralRules ) {
+ // default fallback.
+ return ( count === 1 ) ? forms[0] : forms[1];
+ }
+ pluralFormIndex = mw.cldr.getPluralForm( count, pluralRules );
+ pluralFormIndex = Math.min( pluralFormIndex, forms.length - 1 );
+ return forms[pluralFormIndex];
+ },
+
+ /**
+ * Pads an array to a specific length by copying the last one element.
+ *
+ * @private
+ * @param {Array} forms Number of forms given to convertPlural
+ * @param {number} count Number of forms required
+ * @return {Array} Padded array of forms
+ */
+ preConvertPlural: function ( forms, count ) {
+ while ( forms.length < count ) {
+ forms.push( forms[ forms.length - 1 ] );
+ }
+ return forms;
+ },
+
+ /**
+ * Provides an alternative text depending on specified gender.
+ *
+ * Usage in message text: `{{gender:[gender|user object]|masculine|feminine|neutral}}`.
+ * If second or third parameter are not specified, masculine is used.
+ *
+ * These details may be overriden per language.
+ *
+ * @param {string} gender 'male', 'female', or anything else for neutral.
+ * @param {Array} forms List of gender forms
+ * @return string
+ */
+ gender: function ( gender, forms ) {
+ if ( !forms || forms.length === 0 ) {
+ return '';
+ }
+ forms = mw.language.preConvertPlural( forms, 2 );
+ if ( gender === 'male' ) {
+ return forms[0];
+ }
+ if ( gender === 'female' ) {
+ return forms[1];
+ }
+ return ( forms.length === 3 ) ? forms[2] : forms[0];
+ },
+
+ /**
+ * Grammatical transformations, needed for inflected languages.
+ * Invoked by putting `{{grammar:form|word}}` in a message.
+ *
+ * The rules can be defined in $wgGrammarForms global or computed
+ * dynamically by overriding this method per language.
+ *
+ * @param {string} word
+ * @param {string} form
+ * @return {string}
+ */
+ convertGrammar: function ( word, form ) {
+ var grammarForms = mw.language.getData( mw.config.get( 'wgUserLanguage' ), 'grammarForms' );
+ if ( grammarForms && grammarForms[form] ) {
+ return grammarForms[form][word] || word;
+ }
+ return word;
+ },
+
+ /**
+ * Turn a list of string into a simple list using commas and 'and'.
+ *
+ * See Language::listToText in languages/Language.php
+ *
+ * @param {string[]} list
+ * @return {string}
+ */
+ listToText: function ( list ) {
+ var text = '', i = 0;
+ for ( ; i < list.length; i++ ) {
+ text += list[i];
+ if ( list.length - 2 === i ) {
+ text += mw.msg( 'and' ) + mw.msg( 'word-separator' );
+ } else if ( list.length - 1 !== i ) {
+ text += mw.msg( 'comma-separator' );
+ }
+ }
+ return text;
+ }
+} );
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.language/mediawiki.language.months.js b/resources/src/mediawiki.language/mediawiki.language.months.js
new file mode 100644
index 00000000..5a1a5cb6
--- /dev/null
+++ b/resources/src/mediawiki.language/mediawiki.language.months.js
@@ -0,0 +1,56 @@
+/*
+ * Transfer of month names from messages into mw.language.
+ *
+ * Loading this module also ensures the availability of appropriate messages via mw.msg.
+ */
+( function ( mw, $ ) {
+ var
+ monthMessages = [
+ 'january', 'february', 'march', 'april',
+ 'may_long', 'june', 'july', 'august',
+ 'september', 'october', 'november', 'december'
+ ],
+ monthGenMessages = [
+ 'january-gen', 'february-gen', 'march-gen', 'april-gen',
+ 'may-gen', 'june-gen', 'july-gen', 'august-gen',
+ 'september-gen', 'october-gen', 'november-gen', 'december-gen'
+ ],
+ monthAbbrevMessages = [
+ 'jan', 'feb', 'mar', 'apr',
+ 'may', 'jun', 'jul', 'aug',
+ 'sep', 'oct', 'nov', 'dec'
+ ];
+
+ // Function suitable for passing to jQuery.map
+ // Can't use mw.msg directly because jQuery.map passes element index as second argument
+ function mwMsgMapper( key ) {
+ return mw.msg( key );
+ }
+
+ /**
+ * Information about month names in current UI language.
+ *
+ * Object keys:
+ *
+ * - `names`: array of month names (in nominative case in languages which have the distinction),
+ * zero-indexed
+ * - `genitive`: array of month names in genitive case, zero-indexed
+ * - `abbrev`: array of three-letter-long abbreviated month names, zero-indexed
+ * - `keys`: object with three keys like the above, containing zero-indexed arrays of message keys
+ * for appropriate messages which can be passed to mw.msg.
+ *
+ * @property
+ * @member mw.language
+ */
+ mw.language.months = {
+ keys: {
+ names: monthMessages,
+ genitive: monthGenMessages,
+ abbrev: monthAbbrevMessages
+ },
+ names: $.map( monthMessages, mwMsgMapper ),
+ genitive: $.map( monthGenMessages, mwMsgMapper ),
+ abbrev: $.map( monthAbbrevMessages, mwMsgMapper )
+ };
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.language/mediawiki.language.numbers.js b/resources/src/mediawiki.language/mediawiki.language.numbers.js
new file mode 100644
index 00000000..a0b81410
--- /dev/null
+++ b/resources/src/mediawiki.language/mediawiki.language.numbers.js
@@ -0,0 +1,258 @@
+/*
+ * Number-related utilities for mediawiki.language.
+ */
+( function ( mw, $ ) {
+ /**
+ * @class mw.language
+ */
+
+ /**
+ * Pad a string to guarantee that it is at least `size` length by
+ * filling with the character `ch` at either the start or end of the
+ * string. Pads at the start, by default.
+ *
+ * Example: Fill the string to length 10 with '+' characters on the right.
+ *
+ * pad( 'blah', 10, '+', true ); // => 'blah++++++'
+ *
+ * @private
+ * @param {string} text The string to pad
+ * @param {number} size The length to pad to
+ * @param {string} [ch='0'] Character to pad with
+ * @param {boolean} [end=false] Adds padding at the end if true, otherwise pads at start
+ * @return {string}
+ */
+ function pad( text, size, ch, end ) {
+ if ( !ch ) {
+ ch = '0';
+ }
+
+ var out = String( text ),
+ padStr = replicate( ch, Math.ceil( ( size - out.length ) / ch.length ) );
+
+ return end ? out + padStr : padStr + out;
+ }
+
+ /**
+ * Replicate a string 'n' times.
+ *
+ * @private
+ * @param {string} str The string to replicate
+ * @param {number} num Number of times to replicate the string
+ * @return {string}
+ */
+ function replicate( str, num ) {
+ if ( num <= 0 || !str ) {
+ return '';
+ }
+
+ var buf = [];
+ while ( num-- ) {
+ buf.push( str );
+ }
+ return buf.join( '' );
+ }
+
+ /**
+ * Apply numeric pattern to absolute value using options. Gives no
+ * consideration to local customs.
+ *
+ * Adapted from dojo/number library with thanks
+ * <http://dojotoolkit.org/reference-guide/1.8/dojo/number.html>
+ *
+ * @private
+ * @param {number} value the number to be formatted, ignores sign
+ * @param {string} pattern the number portion of a pattern (e.g. `#,##0.00`)
+ * @param {Object} [options] If provided, both option keys must be present:
+ * @param {string} options.decimal The decimal separator. Defaults to: `'.'`.
+ * @param {string} options.group The group separator. Defaults to: `','`.
+ * @return {string}
+ */
+ function commafyNumber( value, pattern, options ) {
+ options = options || {
+ group: ',',
+ decimal: '.'
+ };
+
+ if ( isNaN( value) ) {
+ return value;
+ }
+
+ var padLength,
+ patternDigits,
+ index,
+ whole,
+ off,
+ remainder,
+ patternParts = pattern.split( '.' ),
+ maxPlaces = ( patternParts[1] || [] ).length,
+ valueParts = String( Math.abs( value ) ).split( '.' ),
+ fractional = valueParts[1] || '',
+ groupSize = 0,
+ groupSize2 = 0,
+ pieces = [];
+
+ if ( patternParts[1] ) {
+ // Pad fractional with trailing zeros
+ padLength = ( patternParts[1] && patternParts[1].lastIndexOf( '0' ) + 1 );
+
+ if ( padLength > fractional.length ) {
+ valueParts[1] = pad( fractional, padLength, '0', true );
+ }
+
+ // Truncate fractional
+ if ( maxPlaces < fractional.length ) {
+ valueParts[1] = fractional.slice( 0, maxPlaces );
+ }
+ } else {
+ if ( valueParts[1] ) {
+ valueParts.pop();
+ }
+ }
+
+ // Pad whole with leading zeros
+ patternDigits = patternParts[0].replace( ',', '' );
+
+ padLength = patternDigits.indexOf( '0' );
+
+ if ( padLength !== -1 ) {
+ padLength = patternDigits.length - padLength;
+
+ if ( padLength > valueParts[0].length ) {
+ valueParts[0] = pad( valueParts[0], padLength );
+ }
+
+ // Truncate whole
+ if ( patternDigits.indexOf( '#' ) === -1 ) {
+ valueParts[0] = valueParts[0].slice( valueParts[0].length - padLength );
+ }
+ }
+
+ // Add group separators
+ index = patternParts[0].lastIndexOf( ',' );
+
+ if ( index !== -1 ) {
+ groupSize = patternParts[0].length - index - 1;
+ remainder = patternParts[0].slice( 0, index );
+ index = remainder.lastIndexOf( ',' );
+ if ( index !== -1 ) {
+ groupSize2 = remainder.length - index - 1;
+ }
+ }
+
+ for ( whole = valueParts[0]; whole; ) {
+ off = groupSize ? whole.length - groupSize : 0;
+ pieces.push( ( off > 0 ) ? whole.slice( off ) : whole );
+ whole = ( off > 0 ) ? whole.slice( 0, off ) : '';
+
+ if ( groupSize2 ) {
+ groupSize = groupSize2;
+ groupSize2 = null;
+ }
+ }
+ valueParts[0] = pieces.reverse().join( options.group );
+
+ return valueParts.join( options.decimal );
+ }
+
+ $.extend( mw.language, {
+
+ /**
+ * Converts a number using #getDigitTransformTable.
+ *
+ * @param {number} num Value to be converted
+ * @param {boolean} [integer=false] Whether to convert the return value to an integer
+ * @return {number|string} Formatted number
+ */
+ convertNumber: function ( num, integer ) {
+ var i, tmp, transformTable, numberString, convertedNumber, pattern;
+
+ pattern = mw.language.getData( mw.config.get( 'wgUserLanguage' ),
+ 'digitGroupingPattern' ) || '#,##0.###';
+
+ // Set the target transform table:
+ transformTable = mw.language.getDigitTransformTable();
+
+ if ( !transformTable ) {
+ return num;
+ }
+
+ // Check if the 'restore' to Latin number flag is set:
+ if ( integer ) {
+ if ( parseInt( num, 10 ) === num ) {
+ return num;
+ }
+ tmp = [];
+ for ( i in transformTable ) {
+ tmp[ transformTable[ i ] ] = i;
+ }
+ transformTable = tmp;
+ numberString = num + '';
+ } else {
+ numberString = mw.language.commafy( num, pattern );
+ }
+
+ convertedNumber = '';
+ for ( i = 0; i < numberString.length; i++ ) {
+ if ( transformTable[ numberString[i] ] ) {
+ convertedNumber += transformTable[numberString[i]];
+ } else {
+ convertedNumber += numberString[i];
+ }
+ }
+ return integer ? parseInt( convertedNumber, 10 ) : convertedNumber;
+ },
+
+ /**
+ * Get the digit transform table for current UI language.
+ * @return {Object|Array}
+ */
+ getDigitTransformTable: function () {
+ return mw.language.getData( mw.config.get( 'wgUserLanguage' ),
+ 'digitTransformTable' ) || [];
+ },
+
+ /**
+ * Get the separator transform table for current UI language.
+ * @return {Object|Array}
+ */
+ getSeparatorTransformTable: function () {
+ return mw.language.getData( mw.config.get( 'wgUserLanguage' ),
+ 'separatorTransformTable' ) || [];
+ },
+
+ /**
+ * Apply pattern to format value as a string.
+ *
+ * Using patterns from [Unicode TR35](http://www.unicode.org/reports/tr35/#Number_Format_Patterns).
+ *
+ * @param {number} value
+ * @param {string} pattern Pattern string as described by Unicode TR35
+ * @throws {Error} If unable to find a number expression in `pattern`.
+ * @return {string}
+ */
+ commafy: function ( value, pattern ) {
+ var numberPattern,
+ transformTable = mw.language.getSeparatorTransformTable(),
+ group = transformTable[','] || ',',
+ numberPatternRE = /[#0,]*[#0](?:\.0*#*)?/, // not precise, but good enough
+ decimal = transformTable['.'] || '.',
+ patternList = pattern.split( ';' ),
+ positivePattern = patternList[0];
+
+ pattern = patternList[ ( value < 0 ) ? 1 : 0] || ( '-' + positivePattern );
+ numberPattern = positivePattern.match( numberPatternRE );
+
+ if ( !numberPattern ) {
+ throw new Error( 'unable to find a number expression in pattern: ' + pattern );
+ }
+
+ return pattern.replace( numberPatternRE, commafyNumber( value, numberPattern[0], {
+ decimal: decimal,
+ group: group
+ } ) );
+ }
+
+ } );
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.legacy/ajax.js b/resources/src/mediawiki.legacy/ajax.js
new file mode 100644
index 00000000..6b9464a9
--- /dev/null
+++ b/resources/src/mediawiki.legacy/ajax.js
@@ -0,0 +1,194 @@
+/**
+ * Remote Scripting Library
+ * Copyright 2005 modernmethod, inc
+ * Under the open source BSD license
+ * http://www.modernmethod.com/sajax/
+ */
+
+/*jshint camelcase:false */
+/*global alert */
+( function ( mw ) {
+
+/**
+ * if sajax_debug_mode is true, this function outputs given the message into
+ * the element with id = sajax_debug; if no such element exists in the document,
+ * it is injected.
+ */
+function debug( text ) {
+ if ( !window.sajax_debug_mode ) {
+ return false;
+ }
+
+ var b, m,
+ e = document.getElementById( 'sajax_debug' );
+
+ if ( !e ) {
+ e = document.createElement( 'p' );
+ e.className = 'sajax_debug';
+ e.id = 'sajax_debug';
+
+ b = document.getElementsByTagName( 'body' )[0];
+
+ if ( b.firstChild ) {
+ b.insertBefore( e, b.firstChild );
+ } else {
+ b.appendChild( e );
+ }
+ }
+
+ m = document.createElement( 'div' );
+ m.appendChild( document.createTextNode( text ) );
+
+ e.appendChild( m );
+
+ return true;
+}
+
+/**
+ * Compatibility wrapper for creating a new XMLHttpRequest object.
+ */
+function createXhr() {
+ debug( 'sajax_init_object() called..' );
+ var a;
+ try {
+ // Try the new style before ActiveX so we don't
+ // unnecessarily trigger warnings in IE 7 when
+ // set to prompt about ActiveX usage
+ a = new XMLHttpRequest();
+ } catch ( xhrE ) {
+ try {
+ a = new window.ActiveXObject( 'Msxml2.XMLHTTP' );
+ } catch ( msXmlE ) {
+ try {
+ a = new window.ActiveXObject( 'Microsoft.XMLHTTP' );
+ } catch ( msXhrE ) {
+ a = null;
+ }
+ }
+ }
+ if ( !a ) {
+ debug( 'Could not create connection object.' );
+ }
+
+ return a;
+}
+
+/**
+ * Perform an AJAX call to MediaWiki. Calls are handled by AjaxDispatcher.php
+ * func_name - the name of the function to call. Must be registered in $wgAjaxExportList
+ * args - an array of arguments to that function
+ * target - the target that will handle the result of the call. If this is a function,
+ * if will be called with the XMLHttpRequest as a parameter; if it's an input
+ * element, its value will be set to the resultText; if it's another type of
+ * element, its innerHTML will be set to the resultText.
+ *
+ * Example:
+ * sajax_do_call( 'doFoo', [1, 2, 3], document.getElementById( 'showFoo' ) );
+ *
+ * This will call the doFoo function via MediaWiki's AjaxDispatcher, with
+ * (1, 2, 3) as the parameter list, and will show the result in the element
+ * with id = showFoo
+ */
+function doAjaxRequest( func_name, args, target ) {
+ var i, x, uri, post_data;
+ uri = mw.util.wikiScript() + '?action=ajax';
+ if ( window.sajax_request_type === 'GET' ) {
+ if ( uri.indexOf( '?' ) === -1 ) {
+ uri = uri + '?rs=' + encodeURIComponent( func_name );
+ } else {
+ uri = uri + '&rs=' + encodeURIComponent( func_name );
+ }
+ for ( i = 0; i < args.length; i++ ) {
+ uri = uri + '&rsargs[]=' + encodeURIComponent( args[i] );
+ }
+ //uri = uri + '&rsrnd=' + new Date().getTime();
+ post_data = null;
+ } else {
+ post_data = 'rs=' + encodeURIComponent( func_name );
+ for ( i = 0; i < args.length; i++ ) {
+ post_data = post_data + '&rsargs[]=' + encodeURIComponent( args[i] );
+ }
+ }
+ x = createXhr();
+ if ( !x ) {
+ alert( 'AJAX not supported' );
+ return false;
+ }
+
+ try {
+ x.open( window.sajax_request_type, uri, true );
+ } catch ( e ) {
+ if ( location.hostname === 'localhost' ) {
+ alert( 'Your browser blocks XMLHttpRequest to "localhost", try using a real hostname for development/testing.' );
+ }
+ throw e;
+ }
+ if ( window.sajax_request_type === 'POST' ) {
+ x.setRequestHeader( 'Method', 'POST ' + uri + ' HTTP/1.1' );
+ x.setRequestHeader( 'Content-Type', 'application/x-www-form-urlencoded' );
+ }
+ x.setRequestHeader( 'Pragma', 'cache=yes' );
+ x.setRequestHeader( 'Cache-Control', 'no-transform' );
+ x.onreadystatechange = function () {
+ if ( x.readyState !== 4 ) {
+ return;
+ }
+
+ debug( 'received (' + x.status + ' ' + x.statusText + ') ' + x.responseText );
+
+ //if ( x.status != 200 )
+ // alert( 'Error: ' + x.status + ' ' + x.statusText + ': ' + x.responseText );
+ //else
+
+ if ( typeof target === 'function' ) {
+ target( x );
+ } else if ( typeof target === 'object' ) {
+ if ( target.tagName === 'INPUT' ) {
+ if ( x.status === 200 ) {
+ target.value = x.responseText;
+ }
+ //else alert( 'Error: ' + x.status + ' ' + x.statusText + ' (' + x.responseText + ')' );
+ } else {
+ if ( x.status === 200 ) {
+ target.innerHTML = x.responseText;
+ } else {
+ target.innerHTML = '<div class="error">Error: ' + x.status +
+ ' ' + x.statusText + ' (' + x.responseText + ')</div>';
+ }
+ }
+ } else {
+ alert( 'Bad target for sajax_do_call: not a function or object: ' + target );
+ }
+ };
+
+ debug( func_name + ' uri = ' + uri + ' / post = ' + post_data );
+ x.send( post_data );
+ debug( func_name + ' waiting..' );
+
+ return true;
+}
+
+/**
+ * @return {boolean} Whether the browser supports AJAX
+ */
+function wfSupportsAjax() {
+ var request = createXhr(),
+ supportsAjax = request ? true : false;
+
+ request = undefined;
+ return supportsAjax;
+}
+
+// Expose + Mark as deprecated
+var deprecationNotice = 'Sajax is deprecated, use jQuery.ajax or mediawiki.api instead.';
+
+// Variables
+mw.log.deprecate( window, 'sajax_debug_mode', false, deprecationNotice );
+mw.log.deprecate( window, 'sajax_request_type', 'GET', deprecationNotice );
+// Methods
+mw.log.deprecate( window, 'sajax_debug', debug, deprecationNotice );
+mw.log.deprecate( window, 'sajax_init_object', createXhr, deprecationNotice );
+mw.log.deprecate( window, 'sajax_do_call', doAjaxRequest, deprecationNotice );
+mw.log.deprecate( window, 'wfSupportsAjax', wfSupportsAjax, deprecationNotice );
+
+}( mediaWiki ) );
diff --git a/resources/src/mediawiki.legacy/commonPrint.css b/resources/src/mediawiki.legacy/commonPrint.css
new file mode 100644
index 00000000..830b02fa
--- /dev/null
+++ b/resources/src/mediawiki.legacy/commonPrint.css
@@ -0,0 +1,435 @@
+/**
+ * MediaWiki Print style sheet for CSS2-capable browsers.
+ * Copyright Gabriel Wicke, http://www.aulinx.de/
+ *
+ * Derived from the plone (http://plone.org/) styles
+ * Copyright Alexander Limi
+ */
+
+/* Thanks to A List Apart (http://alistapart.com/) for useful extras */
+
+/**
+ * Hide all the elements irrelevant for printing
+ */
+.noprint,
+div#jump-to-nav,
+.mw-jump,
+div.top,
+div#column-one,
+#colophon,
+.mw-editsection,
+.mw-editsection-like,
+.toctoggle,
+#toc.tochidden,
+div#f-poweredbyico,
+div#f-copyrightico,
+li#viewcount,
+li#about,
+li#disclaimer,
+li#mobileview,
+li#privacy,
+#footer-places,
+.mw-hidden-catlinks,
+tr.mw-metadata-show-hide-extended,
+span.mw-filepage-other-resolutions,
+#filetoc,
+.usermessage,
+.patrollink,
+#mw-navigation,
+#siteNotice {
+ display: none;
+}
+
+/**
+ * Pagination
+ */
+.wikitable, .thumb, img {
+ page-break-inside: avoid;
+}
+
+h2, h3, h4, h5, h6 {
+ page-break-after: avoid;
+}
+
+p {
+ widows: 3;
+ orphans: 3;
+}
+
+/**
+ * Generic HTML elements
+ */
+body {
+ background: white;
+ color: black;
+ margin: 0;
+ padding: 0;
+}
+
+ul {
+ list-style-type: square;
+}
+
+h1, h2, h3, h4, h5, h6 {
+ font-weight: bold;
+}
+
+dt {
+ font-weight: bold;
+}
+
+p {
+ margin: 1em 0;
+ line-height: 1.2em;
+}
+
+pre, .mw-code {
+ border: 1pt dashed black;
+ white-space: pre;
+ font-size: 8pt;
+ overflow: auto;
+ padding: 1em 0;
+ background: white;
+ color: black;
+}
+
+/**
+ * MediaWiki-specific elements
+ */
+#globalWrapper {
+ width: 100% !important;
+ min-width: 0 !important;
+}
+
+.mw-body {
+ background: white;
+ border: none !important;
+ padding: 0 !important;
+ margin: 0 !important;
+ direction: ltr;
+ color: black;
+}
+
+#column-content {
+ margin: 0 !important;
+}
+
+#column-content .mw-body {
+ padding: 1em;
+ margin: 0 !important;
+}
+
+#toc {
+ border: 1px solid #aaaaaa;
+ background-color: #f9f9f9;
+ padding: 5px;
+ display: -moz-inline-block;
+ display: inline-block;
+ display: table;
+ /* IE7 and earlier */
+ zoom: 1;
+ *display: inline;
+}
+
+#footer {
+ background: white;
+ color: black;
+ margin-top: 1em;
+ border-top: 1px solid #AAA;
+ direction: ltr;
+}
+
+img {
+ border: none;
+ vertical-align: middle;
+}
+
+/* math */
+span.texhtml {
+ font-family: serif;
+}
+
+/**
+ * Links
+ */
+a.stub,
+a.new {
+ color: #ba0000;
+ text-decoration: none;
+}
+
+a {
+ color: black !important;
+ background: none !important;
+ padding: 0 !important;
+}
+
+a:link, a:visited {
+ color: #520;
+ background: transparent;
+ text-decoration: underline;
+}
+
+/* Expand URLs for printing */
+.mw-body a.external.text:after,
+.mw-body a.external.autonumber:after {
+ content: " (" attr(href) ")";
+}
+
+/* Expand protocol-relative URLs for printing */
+.mw-body a.external.text[href^='//']:after,
+.mw-body a.external.autonumber[href^='//']:after {
+ content: " (https:" attr(href) ")";
+}
+
+/* MSIE/Win doesn't understand 'inherit' */
+a,
+a.external,
+a.new,
+a.stub {
+ color: black !important;
+ text-decoration: none !important;
+}
+
+/* Continue ... */
+a,
+a.external,
+a.new,
+a.stub {
+ color: inherit !important;
+ text-decoration: inherit !important;
+}
+
+/**
+ * Floating divs
+ */
+div.floatright {
+ float: right;
+ clear: right;
+ position: relative;
+ margin: 0.5em 0 0.8em 1.4em;
+}
+
+div.floatright p {
+ font-style: italic;
+}
+
+div.floatleft {
+ float: left;
+ clear: left;
+ position: relative;
+ margin: 0.5em 1.4em 0.8em 0;
+}
+
+div.floatleft p {
+ font-style: italic;
+}
+
+div.center {
+ text-align: center;
+}
+
+/**
+ * Thumbnails
+ */
+div.thumb {
+ border: none;
+ width: auto;
+ margin-top: 0.5em;
+ margin-bottom: 0.8em;
+ background-color: transparent;
+}
+
+div.thumbinner {
+ border: 1px solid #cccccc;
+ padding: 3px !important;
+ background-color: White;
+ font-size: 94%;
+ text-align: center;
+ overflow: hidden;
+}
+
+html .thumbimage {
+ border: 1px solid #cccccc;
+}
+
+html .thumbcaption {
+ border: none;
+ text-align: left;
+ line-height: 1.4em;
+ padding: 3px !important;
+ font-size: 94%;
+}
+
+div.magnify {
+ display: none;
+}
+
+/* @noflip */
+div.tright {
+ float: right;
+ clear: right;
+ margin: 0.5em 0 0.8em 1.4em;
+}
+
+/* @noflip */
+div.tleft {
+ float: left;
+ clear: left;
+ margin: 0.5em 1.4em 0.8em 0;
+}
+
+img.thumbborder {
+ border: 1px solid #dddddd;
+}
+
+/**
+ * Galleries (see shared.css for more info)
+ */
+li.gallerybox {
+ vertical-align: top;
+ display: inline-block;
+}
+
+ul.gallery, li.gallerybox {
+ zoom: 1;
+ *display: inline;
+}
+
+ul.gallery {
+ margin: 2px;
+ padding: 2px;
+ display: block;
+}
+
+li.gallerycaption {
+ font-weight: bold;
+ text-align: center;
+ display: block;
+ word-wrap: break-word;
+}
+
+li.gallerybox div.thumb {
+ text-align: center;
+ border: 1px solid #ccc;
+ margin: 2px;
+}
+
+div.gallerytext {
+ overflow: hidden;
+ font-size: 94%;
+ padding: 2px 4px;
+ word-wrap: break-word;
+}
+
+/**
+ * Diff rendering
+ */
+table.diff {
+ background: white;
+}
+
+td.diff-otitle {
+ background: #ffffff;
+}
+
+td.diff-ntitle {
+ background: #ffffff;
+}
+
+td.diff-addedline {
+ background: #ccffcc;
+ font-size: smaller;
+ border: solid 2px black;
+}
+
+td.diff-deletedline {
+ background: #ffffaa;
+ font-size: smaller;
+ border: dotted 2px black;
+}
+
+td.diff-context {
+ background: #eeeeee;
+ font-size: smaller;
+}
+
+.diffchange {
+ color: silver;
+ font-weight: bold;
+ text-decoration: underline;
+}
+
+/**
+ * Table rendering
+ * As on shared.css but with white background.
+ */
+table.wikitable,
+table.mw_metadata {
+ margin: 1em 0;
+ border: 1px #aaa solid;
+ background: white;
+ border-collapse: collapse;
+}
+
+table.wikitable > tr > th, table.wikitable > tr > td,
+table.wikitable > * > tr > th, table.wikitable > * > tr > td,
+.mw_metadata th, .mw_metadata td {
+ border: 1px #aaa solid;
+ padding: 0.2em;
+}
+
+table.wikitable > tr > th,
+table.wikitable > * > tr > th,
+.mw_metadata th {
+ text-align: center;
+ background: white;
+ font-weight: bold;
+}
+
+table.wikitable > caption,
+.mw_metadata caption {
+ font-weight: bold;
+}
+
+table.listing,
+table.listing td {
+ border: 1pt solid black;
+ border-collapse: collapse;
+}
+
+a.sortheader {
+ margin: 0 0.3em;
+}
+
+/**
+ * Categories
+ */
+.catlinks ul {
+ display: inline;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ list-style-type: none;
+ list-style-image: none;
+ vertical-align: middle !ie;
+}
+
+.catlinks li {
+ display: inline-block;
+ line-height: 1.15em;
+ padding: 0 .4em;
+ border-left: 1px solid #AAA;
+ margin: 0.1em 0;
+ zoom: 1;
+ display: inline !ie;
+}
+
+.catlinks li:first-child {
+ padding-left: .2em;
+ border-left: none;
+}
+
+.printfooter {
+ padding: 1em 0 1em 0;
+}
diff --git a/resources/src/mediawiki.legacy/images/ajax-loader.gif b/resources/src/mediawiki.legacy/images/ajax-loader.gif
new file mode 100644
index 00000000..72203fdd
--- /dev/null
+++ b/resources/src/mediawiki.legacy/images/ajax-loader.gif
Binary files differ
diff --git a/resources/src/mediawiki.legacy/images/checker.png b/resources/src/mediawiki.legacy/images/checker.png
new file mode 100644
index 00000000..3e9e3d09
--- /dev/null
+++ b/resources/src/mediawiki.legacy/images/checker.png
Binary files differ
diff --git a/resources/src/mediawiki.legacy/images/feed-icon.png b/resources/src/mediawiki.legacy/images/feed-icon.png
new file mode 100644
index 00000000..00f49f6c
--- /dev/null
+++ b/resources/src/mediawiki.legacy/images/feed-icon.png
Binary files differ
diff --git a/resources/src/mediawiki.legacy/images/feed-icon.svg b/resources/src/mediawiki.legacy/images/feed-icon.svg
new file mode 100644
index 00000000..6e5f570a
--- /dev/null
+++ b/resources/src/mediawiki.legacy/images/feed-icon.svg
@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 256 256"><defs><linearGradient x1=".085" y1=".085" x2=".915" y2=".915" id="a"><stop offset="0" stop-color="#E3702D"/><stop offset=".107" stop-color="#EA7D31"/><stop offset=".35" stop-color="#F69537"/><stop offset=".5" stop-color="#FB9E3A"/><stop offset=".702" stop-color="#EA7C31"/><stop offset=".887" stop-color="#DE642B"/><stop offset="1" stop-color="#D95B29"/></linearGradient></defs><rect width="256" height="256" rx="55" ry="55" fill="#CC5D15"/><rect width="246" height="246" rx="50" ry="50" x="5" y="5" fill="#F49C52"/><rect width="236" height="236" rx="47" ry="47" x="10" y="10" fill="url(#a)"/><circle cx="68" cy="189" r="24" fill="#FFF"/><path d="M160 213h-34a82 82 0 0 0-82-82v-34a116 116 0 0 1 116 116zM184 213a140 140 0 0 0-140-140v-35a175 175 0 0 1 175 175z" fill="#FFF"/></svg> \ No newline at end of file
diff --git a/resources/src/mediawiki.legacy/images/help-question-hover.gif b/resources/src/mediawiki.legacy/images/help-question-hover.gif
new file mode 100644
index 00000000..515138db
--- /dev/null
+++ b/resources/src/mediawiki.legacy/images/help-question-hover.gif
Binary files differ
diff --git a/resources/src/mediawiki.legacy/images/help-question.gif b/resources/src/mediawiki.legacy/images/help-question.gif
new file mode 100644
index 00000000..b4fc9c5b
--- /dev/null
+++ b/resources/src/mediawiki.legacy/images/help-question.gif
Binary files differ
diff --git a/resources/src/mediawiki.legacy/images/question.png b/resources/src/mediawiki.legacy/images/question.png
new file mode 100644
index 00000000..f7405d26
--- /dev/null
+++ b/resources/src/mediawiki.legacy/images/question.png
Binary files differ
diff --git a/resources/src/mediawiki.legacy/images/question.svg b/resources/src/mediawiki.legacy/images/question.svg
new file mode 100644
index 00000000..98fbe8dd
--- /dev/null
+++ b/resources/src/mediawiki.legacy/images/question.svg
@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="21.059" height="21.06"><path fill="#575757" d="M10.529 0c-5.814 0-10.529 4.714-10.529 10.529s4.715 10.53 10.529 10.53c5.816 0 10.529-4.715 10.529-10.53s-4.712-10.529-10.529-10.529zm-.002 16.767c-.861 0-1.498-.688-1.498-1.516 0-.862.637-1.534 1.498-1.534.828 0 1.5.672 1.5 1.534 0 .827-.672 1.516-1.5 1.516zm2.137-6.512c-.723.568-1 .931-1 1.739v.5h-2.205v-.603c0-1.517.449-2.136 1.154-2.688.707-.552 1.139-.845 1.139-1.637 0-.672-.414-1.051-1.24-1.051-.707 0-1.328.189-1.982.638l-1.051-1.807c.861-.604 1.93-1.034 3.342-1.034 1.912 0 3.516 1.051 3.516 3.066-.001 1.43-.794 2.188-1.673 2.877z"/></svg> \ No newline at end of file
diff --git a/resources/src/mediawiki.legacy/images/spinner.gif b/resources/src/mediawiki.legacy/images/spinner.gif
new file mode 100644
index 00000000..6146be4e
--- /dev/null
+++ b/resources/src/mediawiki.legacy/images/spinner.gif
Binary files differ
diff --git a/resources/src/mediawiki.legacy/oldshared.css b/resources/src/mediawiki.legacy/oldshared.css
new file mode 100644
index 00000000..d92d3bb9
--- /dev/null
+++ b/resources/src/mediawiki.legacy/oldshared.css
@@ -0,0 +1,489 @@
+/**
+ * oldshared.css
+ * This file contains CSS settings common to Wikistandard, Nostalgia and
+ * CologneBlue, the old pre-Monobook skins
+ */
+
+/* For clarity, explicitly state some recommendations from
+ * http://www.w3.org/TR/CSS21/sample.html to make sure the editsection links scale right
+ */
+
+h1 {
+ font-size: 2em;
+}
+
+h2 {
+ font-size: 1.5em;
+}
+
+h3 {
+ font-size: 1.17em;
+}
+
+h4 {
+ font-size: 1.11em;
+}
+
+h5 {
+ font-size: 1.05em;
+}
+
+h6 {
+ font-size: 1em;
+}
+
+h1, h2, h3, h4, h5, h6 {
+ font-weight: bolder;
+}
+
+/* Now the custom parts */
+
+#footer {
+ clear: both;
+}
+
+/* images */
+/* @noflip */
+div.floatright {
+ float: right;
+ clear: right;
+ margin: 0 0 1em 1em;
+}
+
+/* @noflip */
+div.floatright p {
+ font-style: italic;
+}
+
+/* @noflip */
+div.floatleft {
+ float: left;
+ clear: left;
+ margin: 0.3em 0.5em 0.5em 0;
+}
+
+/* @noflip */
+div.floatleft p {
+ font-style: italic;
+}
+
+/* table standards */
+table.rimage {
+ float: right;
+ margin-left: 1em;
+ margin-bottom: 1em;
+ text-align: center;
+ font-size: smaller;
+}
+
+/* thumbnails */
+div.thumb {
+ margin-bottom: .5em;
+ border-style: solid;
+ border-color: white;
+ width: auto;
+}
+
+div.thumbinner {
+ border: 1px solid #ccc;
+ padding: 3px;
+ background-color: #f9f9f9;
+ font-size: 94%;
+ text-align: center;
+ overflow: hidden;
+}
+
+html .thumbimage {
+ border: 1px solid #ccc;
+}
+
+html .thumbcaption {
+ border: none;
+ line-height: 1.4em;
+ padding: 3px;
+ font-size: 94%;
+ text-align: left;
+}
+
+div.magnify {
+ float: right;
+ margin-left: 3px;
+}
+
+div.magnify a {
+ display: block;
+ /* Hide the text… */
+ text-indent: 15px;
+ white-space: nowrap;
+ overflow: hidden;
+ /* …and replace it with the image */
+ width: 15px;
+ height: 11px;
+ /* @embed */
+ background: url(images/magnify-clip-ltr.png) center center no-repeat;
+ /* Don't annoy people who copy-paste everything too much */
+ -moz-user-select: none;
+ -webkit-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+}
+
+/* @noflip */
+div.tright {
+ clear: right;
+ float: right;
+ border-width: .5em 0 .8em 1.4em;
+}
+
+/* @noflip */
+div.tleft {
+ float: left;
+ clear: left;
+ margin-right: .5em;
+ border-width: .5em 1.4em .8em 0;
+}
+
+img.thumbborder {
+ border: 1px solid #dddddd;
+}
+
+/* Page history styling */
+/* the auto-generated edit comments */
+.autocomment {
+ color: #4b4b4b;
+}
+
+img {
+ border: none;
+}
+
+#toc,
+.toc {
+ border: 1px solid #bba;
+ background-color: #f7f8ff;
+ padding: 5px;
+ font-size: 95%;
+ text-align: center;
+ display: -moz-inline-block;
+ display: inline-block;
+ display: table;
+
+ /* IE7 and earlier */
+ zoom: 1;
+ *display: inline;
+
+ padding: 7px;
+}
+
+/* CSS for backwards-compatibility with cached page renders and creative uses in wikitext */
+table#toc,
+table.toc {
+ border-collapse: collapse;
+}
+
+/* Remove additional paddings inside table-cells that are not present in <div>s */
+table#toc td,
+table.toc td {
+ padding: 0;
+}
+
+#toc h2,
+.toc h2 {
+ display: inline;
+ border: none;
+ padding: 0;
+ font-size: 100%;
+ font-weight: bold;
+}
+
+#toc ul,
+.toc ul {
+ list-style-type: none;
+ list-style-image: none;
+ padding: 0;
+ text-align: left;
+}
+
+#toc ul ul,
+.toc ul ul {
+ margin: 0 0 0 2em;
+}
+
+#toc .toctoggle,
+.toc .toctoggle {
+ font-size: 94%;
+}
+
+.error {
+ color: red;
+ font-size: larger;
+}
+
+/* preference page with js-genrated toc */
+#preftoc {
+ float: left;
+ margin: 1em 1em 1em 1em;
+ width: 13em;
+}
+
+#preftoc li {
+ border: 1px solid White;
+}
+
+#preftoc li.selected {
+ background-color: #f9f9f9;
+ border: 1px dashed #aaaaaa;
+}
+
+#preftoc a,
+#preftoc a:active {
+ display: block;
+ color: #005189;
+}
+
+.mw-prefs-buttons {
+ clear: left;
+ float: left;
+ margin-top: 1em;
+}
+
+div.htmlform-tip {
+ font-size: 94%;
+ margin-top: 0.4em;
+ color: #666;
+}
+
+fieldset.prefsection {
+ margin-top: 1em;
+}
+
+fieldset.operaprefsection {
+ margin-left: 15em;
+}
+
+/* emulate center */
+.center {
+ width: 100%;
+ text-align: center;
+}
+
+*.center * {
+ margin-left: auto;
+ margin-right: auto;
+}
+
+/* small for tables and similar */
+.small {
+ font-size: 94%;
+}
+
+table.small {
+ font-size: 100%;
+}
+
+/* use this instead of #toc for page content */
+.toccolours {
+ border: 1px solid #aaaaaa;
+ background-color: #f9f9f9;
+ padding: 5px;
+ font-size: 95%;
+}
+
+#siteNotice {
+ border: 1px solid #aaaaaa;
+ padding-left: 0.5em;
+ padding-right: 0.5em;
+}
+
+.sharedUploadNotice {
+ font-style: italic;
+}
+
+span.unpatrolled {
+ font-weight: bold;
+ color: red;
+}
+
+span.updatedmarker {
+ color: black;
+ background-color: #00FF00;
+}
+
+div.gallerybox {
+ width: 150px;
+}
+
+span.comment {
+ font-style: italic;
+}
+
+span.changedby {
+ font-size: 95%;
+}
+
+.previewnote {
+ text-align: center;
+ color: #cc0000;
+}
+
+.editExternally {
+ border-style: solid;
+ border-width: 1px;
+ border-color: gray;
+ background: #ffffff;
+ padding: 3px;
+ margin-top: 0.5em;
+ float: left;
+ font-size: small;
+ text-align: center;
+}
+
+.editExternallyHelp {
+ font-style: italic;
+ color: gray;
+}
+
+li span.deleted {
+ text-decoration: line-through;
+ color: #888;
+ font-style: italic;
+}
+
+/* Classes for Exif data display */
+table.mw_metadata {
+ margin-left: 0.5em;
+}
+
+table.mw_metadata caption {
+ font-weight: bold;
+}
+
+table.mw_metadata th {
+ font-weight: normal;
+}
+
+table.mw_metadata td {
+ padding: 0.1em;
+}
+
+table.mw_metadata {
+ border: none;
+ border-collapse: collapse;
+}
+
+table.mw_metadata td,
+table.mw_metadata th {
+ border: 1px solid #aaaaaa;
+ padding-left: 4px;
+ padding-right: 4px;
+}
+
+table.mw_metadata th {
+ background-color: #f9f9f9;
+}
+
+table.mw_metadata td {
+ background-color: #fcfcfc;
+}
+
+table.mw_metadata td.spacer {
+ background: inherit;
+ border-top: none;
+ border-bottom: none;
+}
+
+.visualClear {
+ clear: both;
+}
+
+/* Allmessages table */
+#allmessagestable th {
+ background-color: #b2b2ff;
+}
+
+#allmessagestable tr.orig {
+ background-color: #ffe2e2;
+}
+
+#allmessagestable tr.new {
+ background-color: #e2ffe2;
+}
+
+#allmessagestable tr.def {
+ background-color: #f0f0ff;
+}
+
+#jump-to-nav {
+ display: none;
+}
+
+div.multipageimagenavbox {
+ border: solid 1px silver;
+ padding: 4px;
+ margin: 1em;
+ background: #f0f0f0;
+}
+
+div.multipageimagenavbox div.thumb {
+ border: none;
+ margin-left: 2em;
+ margin-right: 2em;
+}
+
+div.multipageimagenavbox hr {
+ margin: 6px;
+}
+
+table.multipageimage td {
+ text-align: center;
+}
+
+.templatesUsed {
+ margin-top: 1em;
+}
+
+.MediaTransformError {
+ border: thin solid #777;
+ background-color: #ccc;
+ padding: 0.1em;
+}
+
+.MediaTransformError td {
+ text-align: center;
+ vertical-align: middle;
+ font-size: 90%;
+}
+
+form#specialpages {
+ display: inline;
+}
+
+body {
+ direction: ltr;
+ unicode-bidi: embed;
+ background-color: #ffffec;
+}
+
+body.ns-0 {
+ background-color: white;
+}
+
+/** RTL specific CSS starts here **/
+
+/**
+ * Lists:
+ * The following lines don't have a visible effect on non-Gecko browsers
+ * They fix a problem with Gecko browsers rendering lists to the right of
+ * left-floated objects in an RTL layout.
+ */
+/* @noflip */
+html > body.rtl div#article ul {
+ display: table;
+}
+
+/* @noflip */
+html > body.rtl .mw-body ul#filetoc {
+ display: block;
+}
+
+/* RTL specific CSS ends here **/
diff --git a/resources/src/mediawiki.legacy/protect.js b/resources/src/mediawiki.legacy/protect.js
new file mode 100644
index 00000000..f9069b6f
--- /dev/null
+++ b/resources/src/mediawiki.legacy/protect.js
@@ -0,0 +1,240 @@
+( function ( mw, $ ) {
+
+var ProtectionForm = window.ProtectionForm = {
+ /**
+ * Set up the protection chaining interface (i.e. "unlock move permissions" checkbox)
+ * on the protection form
+ */
+ init: function () {
+ var $cell = $( '<td>' ), $row = $( '<tr>' ).append( $cell );
+
+ if ( !$( '#mwProtectSet' ).length ) {
+ return false;
+ }
+
+ if ( mw.config.get( 'wgCascadeableLevels' ) !== undefined ) {
+ $( 'form#mw-Protect-Form' ).submit( this.toggleUnchainedInputs.bind( ProtectionForm, true ) );
+ }
+ this.getExpirySelectors().each( function () {
+ $( this ).change( ProtectionForm.updateExpiryList.bind( ProtectionForm, this ) );
+ } );
+ this.getExpiryInputs().each( function () {
+ $( this ).on( 'keyup change', ProtectionForm.updateExpiry.bind( ProtectionForm, this ) );
+ } );
+ this.getLevelSelectors().each( function () {
+ $( this ).change( ProtectionForm.updateLevels.bind( ProtectionForm, this ) );
+ } );
+
+ $( '#mwProtectSet > tbody > tr:first' ).after( $row );
+
+ // If there is only one protection type, there is nothing to chain
+ if ( $( '[id ^= mw-protect-table-]' ).length > 1 ) {
+ $cell.append(
+ $( '<input>' )
+ .attr( { id: 'mwProtectUnchained', type: 'checkbox' } )
+ .click( this.onChainClick.bind( this ) )
+ .prop( 'checked', !this.areAllTypesMatching() ),
+ document.createTextNode( ' ' ),
+ $( '<label>' )
+ .attr( 'for', 'mwProtectUnchained' )
+ .text( mw.msg( 'protect-unchain-permissions' ) )
+ );
+
+ this.toggleUnchainedInputs( !this.areAllTypesMatching() );
+ }
+
+ $( '#mwProtect-reason' ).byteLimit( 180 );
+
+ this.updateCascadeCheckbox();
+ },
+
+ /**
+ * Sets the disabled attribute on the cascade checkbox depending on the current selected levels
+ */
+ updateCascadeCheckbox: function () {
+ this.getLevelSelectors().each( function () {
+ if ( !ProtectionForm.isCascadeableLevel( $( this ).val() ) ) {
+ $( '#mwProtect-cascade' ).prop( { checked: false, disabled: true } );
+ return false;
+ } else {
+ $( '#mwProtect-cascade' ).prop( 'disabled', false );
+ }
+ } );
+ },
+
+ /**
+ * Checks if a cerain protection level is cascadeable.
+ *
+ * @param {string} level
+ * @return {boolean}
+ */
+ isCascadeableLevel: function ( level ) {
+ return $.inArray( level, mw.config.get( 'wgCascadeableLevels' ) ) !== -1;
+ },
+
+ /**
+ * When protection levels are locked together, update the rest
+ * when one action's level changes
+ *
+ * @param {Element} source Level selector that changed
+ */
+ updateLevels: function ( source ) {
+ if ( !this.isUnchained() ) {
+ this.setAllSelectors( source.selectedIndex );
+ }
+ this.updateCascadeCheckbox();
+ },
+
+ /**
+ * When protection levels are locked together, update the
+ * expiries when one changes
+ *
+ * @param {Element} source expiry input that changed
+ */
+
+ updateExpiry: function ( source ) {
+ if ( !this.isUnchained() ) {
+ this.getExpiryInputs().each( function () {
+ this.value = source.value;
+ } );
+ }
+ if ( this.isUnchained() ) {
+ $( '#' + source.id.replace( /^mwProtect-(\w+)-expires$/, 'mwProtectExpirySelection-$1' ) ).val( 'othertime' );
+ } else {
+ this.getExpirySelectors().each( function () {
+ this.value = 'othertime';
+ } );
+ }
+ },
+
+ /**
+ * When protection levels are locked together, update the
+ * expiry lists when one changes and clear the custom inputs
+ *
+ * @param {Element} source Expiry selector that changed
+ */
+ updateExpiryList: function ( source ) {
+ if ( !this.isUnchained() ) {
+ this.getExpirySelectors().each( function () {
+ this.value = source.value;
+ } );
+ this.getExpiryInputs().each( function () {
+ this.value = '';
+ } );
+ }
+ },
+
+ /**
+ * Update chain status and enable/disable various bits of the UI
+ * when the user changes the "unlock move permissions" checkbox
+ */
+ onChainClick: function () {
+ this.toggleUnchainedInputs( this.isUnchained() );
+ if ( !this.isUnchained() ) {
+ this.setAllSelectors( this.getMaxLevel() );
+ }
+ this.updateCascadeCheckbox();
+ },
+
+ /**
+ * Returns true if the named attribute in all objects in the given array are matching
+ *
+ * @param {Object[]} objects
+ * @param {string} attrName
+ * @return {boolean}
+ */
+ matchAttribute: function ( objects, attrName ) {
+ return $.map( objects, function ( object ) {
+ return object[attrName];
+ } ).filter( function ( item, index, a ) {
+ return index === a.indexOf( item );
+ } ).length === 1;
+ },
+
+ /**
+ * Are all actions protected at the same level, with the same expiry time?
+ *
+ * @return {boolean}
+ */
+ areAllTypesMatching: function () {
+ return this.matchAttribute( this.getLevelSelectors(), 'selectedIndex' )
+ && this.matchAttribute( this.getExpirySelectors(), 'selectedIndex' )
+ && this.matchAttribute( this.getExpiryInputs(), 'value' );
+ },
+
+ /**
+ * Is protection chaining off?
+ *
+ * @return {boolean}
+ */
+ isUnchained: function () {
+ var element = document.getElementById( 'mwProtectUnchained' );
+ return element
+ ? element.checked
+ : true; // No control, so we need to let the user set both levels
+ },
+
+ /**
+ * Find the highest protection level in any selector
+ * @return {number}
+ */
+ getMaxLevel: function () {
+ return Math.max.apply( Math, this.getLevelSelectors().map( function () {
+ return this.selectedIndex;
+ } ) );
+ },
+
+ /**
+ * Protect all actions at the specified level
+ *
+ * @param {number} index Protection level
+ */
+ setAllSelectors: function ( index ) {
+ this.getLevelSelectors().each( function () {
+ this.selectedIndex = index;
+ } );
+ },
+
+ /**
+ * Get a list of all protection selectors on the page
+ *
+ * @return {jQuery}
+ */
+ getLevelSelectors: function () {
+ return $( 'select[id ^= mwProtect-level-]' );
+ },
+
+ /**
+ * Get a list of all expiry inputs on the page
+ *
+ * @return {jQuery}
+ */
+ getExpiryInputs: function () {
+ return $( 'input[id ^= mwProtect-][id $= -expires]' );
+ },
+
+ /**
+ * Get a list of all expiry selector lists on the page
+ *
+ * @return {jQuery}
+ */
+ getExpirySelectors: function () {
+ return $( 'select[id ^= mwProtectExpirySelection-]' );
+ },
+
+ /**
+ * Enable/disable protection selectors and expiry inputs
+ *
+ * @param {boolean} val Enable?
+ */
+ toggleUnchainedInputs: function ( val ) {
+ var setDisabled = function () { this.disabled = !val; };
+ this.getLevelSelectors().slice( 1 ).each( setDisabled );
+ this.getExpiryInputs().slice( 1 ).each( setDisabled );
+ this.getExpirySelectors().slice( 1 ).each( setDisabled );
+ }
+};
+
+$( ProtectionForm.init.bind( ProtectionForm ) );
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.legacy/shared.css b/resources/src/mediawiki.legacy/shared.css
new file mode 100644
index 00000000..0604773e
--- /dev/null
+++ b/resources/src/mediawiki.legacy/shared.css
@@ -0,0 +1,1167 @@
+/**
+ * CSS in this file is used by *all* skins (that have any CSS at all). Be
+ * careful what you put in here, since what looks good in one skin may not in
+ * another, but don't ignore the poor pre-Monobook users either.
+ */
+
+/* GENERAL CLASSES FOR DIRECTIONALITY SUPPORT */
+
+/**
+ * These classes should be used for text depending on the content direction.
+ * Content stuff like editsection, ul/ol and TOC depend on this.
+ */
+.mw-content-ltr {
+ /* @noflip */
+ direction: ltr;
+}
+
+.mw-content-rtl {
+ /* @noflip */
+ direction: rtl;
+}
+
+/* Most input fields should be in site direction */
+.sitedir-ltr textarea,
+.sitedir-ltr input {
+ /* @noflip */
+ direction: ltr;
+}
+
+.sitedir-rtl textarea,
+.sitedir-rtl input {
+ /* @noflip */
+ direction: rtl;
+}
+
+.mw-userlink {
+ unicode-bidi: embed;
+}
+
+/* User-Agent styles for new HTML5 elements */
+mark {
+ background-color: yellow;
+ color: black;
+}
+
+/* Helper for wbr element on IE 8+; in HTML5, but not supported by default as of IE 11. */
+/* Note canonical HTML5 styles recommend "content: \u200B", but this doesn't work as of IE 11. */
+wbr {
+ display: inline-block;
+}
+
+/* Input types that should follow user direction, like buttons */
+/* TODO: What about buttons in wikipage content ? */
+input[type="submit"],
+input[type="button"],
+input[type="reset"],
+input[type="file"] {
+ direction: ltr;
+}
+
+/* Override default values */
+textarea[dir="ltr"],
+input[dir="ltr"] {
+ /* @noflip */
+ direction: ltr;
+}
+
+textarea[dir="rtl"],
+input[dir="rtl"] {
+ /* @noflip */
+ direction: rtl;
+}
+
+/* Default style for semantic tags */
+abbr[title],
+.explain[title] {
+ border-bottom: 1px dotted;
+ cursor: help;
+}
+
+/* Colored watchlist and recent changes numbers */
+.mw-plusminus-pos {
+ color: #006400; /* dark green */
+}
+
+.mw-plusminus-neg {
+ color: #8b0000; /* dark red */
+}
+
+.mw-plusminus-null {
+ color: #aaa; /* gray */
+}
+
+/**
+ * Links to redirects appear italicized on [[Special:AllPages]], [[Special:PrefixIndex]],
+ * [[Special:Watchlist/edit]] and in category listings.
+ */
+.allpagesredirect,
+.redirect-in-category,
+.watchlistredir {
+ font-style: italic;
+}
+
+/* Comment and username portions of RC entries */
+span.comment {
+ font-style: italic;
+}
+
+span.changedby {
+ font-size: 95%;
+}
+
+/* Math */
+.texvc {
+ direction: ltr;
+ unicode-bidi: embed;
+}
+
+img.tex {
+ vertical-align: middle;
+}
+
+span.texhtml {
+ font-family: serif;
+}
+
+/**
+ * Add a bit of margin space between the preview and the toolbar.
+ * This replaces the ugly <p><br /></p> we used to insert into the page source
+ */
+#wikiPreview.ontop {
+ margin-bottom: 1em;
+}
+
+/* Stop floats from intruding into edit area in previews */
+#editform,
+#toolbar,
+#wpTextbox1 {
+ clear: both;
+}
+
+/**
+ * File description page
+ */
+
+div.mw-filepage-resolutioninfo {
+ font-size: smaller;
+}
+
+/**
+ * File histories
+ */
+h2#filehistory {
+ clear: both;
+}
+
+table.filehistory th,
+table.filehistory td {
+ vertical-align: top;
+}
+
+table.filehistory th {
+ text-align: left;
+}
+
+table.filehistory td.mw-imagepage-filesize,
+table.filehistory th.mw-imagepage-filesize {
+ white-space: nowrap;
+}
+
+table.filehistory td.filehistory-selected {
+ font-weight: bold;
+}
+
+/**
+ * Add a checkered background image on hover for file
+ * description pages. (bug 26470)
+ */
+.filehistory a img,
+#file img:hover {
+ /* @embed */
+ background: white url(images/checker.png) repeat;
+}
+
+/**
+ * rev_deleted stuff
+ */
+li span.deleted,
+span.history-deleted {
+ text-decoration: line-through;
+ color: #888;
+ font-style: italic;
+}
+
+/**
+ * Patrol stuff
+ */
+.not-patrolled {
+ background-color: #ffa;
+}
+
+.unpatrolled {
+ font-weight: bold;
+ color: red;
+}
+
+div.patrollink {
+ font-size: 75%;
+ text-align: right;
+}
+
+/**
+ * Forms
+ */
+td.mw-label {
+ text-align: right;
+}
+
+td.mw-input {
+ text-align: left;
+}
+
+td.mw-submit {
+ text-align: left;
+}
+
+td.mw-label {
+ vertical-align: top;
+}
+
+.prefsection td.mw-label {
+ width: 20%;
+}
+
+.prefsection table {
+ width: 100%;
+}
+
+.prefsection table.mw-htmlform-matrix {
+ width: auto;
+}
+
+.mw-icon-question {
+ /* SVG support using a transparent gradient to guarantee cross-browser
+ * compatibility (browsers able to understand gradient syntax support also SVG).
+ * http://pauginer.tumblr.com/post/36614680636/invisible-gradient-technique */
+ background-image: url(images/question.png);
+ /* @embed */
+ background-image: -webkit-linear-gradient(transparent, transparent), url(images/question.svg);
+ /* @embed */
+ background-image: linear-gradient(transparent, transparent), url(images/question.svg);
+ background-repeat: no-repeat;
+ background-size: 13px 13px;
+ display: inline-block;
+ height: 13px;
+ width: 13px;
+ margin-left: 4px;
+}
+
+.mw-icon-question:lang(ar),
+.mw-icon-question:lang(fa),
+.mw-icon-question:lang(ur) {
+ -webkit-transform: scaleX(-1);
+ -ms-transform: scaleX(-1);
+ transform: scaleX(-1);
+}
+
+td.mw-submit {
+ white-space: nowrap;
+}
+
+table.mw-htmlform-nolabel td.mw-label {
+ width: 1px;
+}
+
+tr.mw-htmlform-vertical-label td.mw-label {
+ text-align: left !important;
+}
+
+.mw-htmlform-invalid-input td.mw-input input {
+ border-color: red;
+}
+
+.mw-htmlform-flatlist div.mw-htmlform-flatlist-item {
+ display: inline;
+ margin-right: 1em;
+ white-space: nowrap;
+}
+
+.mw-htmlform-matrix td {
+ padding-left: 0.5em;
+ padding-right: 0.5em;
+}
+
+input#wpSummary {
+ width: 80%;
+ margin-bottom: 1em;
+}
+
+/**
+ * Image captions.
+ *
+ * This is only meant to provide the most basic of styles, visual settings shouldn't be added here.
+ */
+
+/* @noflip */
+.mw-content-ltr .thumbcaption {
+ text-align: left;
+}
+
+/* @noflip */
+.mw-content-ltr .magnify {
+ float: right;
+}
+
+/* @noflip */
+.mw-content-rtl .thumbcaption {
+ text-align: right;
+}
+
+/* @noflip */
+.mw-content-rtl .magnify {
+ float: left;
+}
+
+/**
+ * Categories
+ */
+#catlinks {
+ /**
+ * Overrides text justification (user preference)
+ * See bug 31990
+ */
+ text-align: left;
+}
+
+.catlinks ul {
+ display: inline;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ list-style-type: none;
+ list-style-image: none;
+ vertical-align: middle !ie;
+}
+
+.catlinks li {
+ display: inline-block;
+ line-height: 1.25em;
+ border-left: 1px solid #AAA;
+ margin: 0.125em 0;
+ padding: 0 0.5em;
+ zoom: 1;
+ display: inline !ie;
+}
+
+.catlinks li:first-child {
+ padding-left: 0.25em;
+ border-left: none;
+}
+
+/* (bug 5346) make category redirects italic */
+.catlinks li a.mw-redirect {
+ font-style: italic;
+}
+
+/**
+ * Hidden categories
+ */
+.mw-hidden-cats-hidden {
+ display: none;
+}
+
+.catlinks-allhidden {
+ display: none;
+}
+
+/**
+ * Convenience links to edit block, delete and protect reasons
+ * and upload licenses
+ */
+p.mw-ipb-conveniencelinks,
+p.mw-protect-editreasons,
+p.mw-filedelete-editreasons,
+p.mw-delete-editreasons,
+p.mw-revdel-editreasons,
+p.mw-upload-editlicenses {
+ font-size: 90%;
+ text-align: right;
+}
+
+/* Page history styling */
+
+/* The auto-generated edit comments */
+.autocomment {
+ color: gray;
+}
+
+#pagehistory .history-user {
+ margin-left: 0.4em;
+ margin-right: 0.2em;
+}
+
+#pagehistory span.minor {
+ font-weight: bold;
+}
+
+#pagehistory li {
+ border: 1px solid white;
+}
+
+#pagehistory li.selected {
+ background-color: #f9f9f9;
+ border: 1px dashed #aaa;
+}
+
+.mw-history-revisiondelete-button, #mw-fileduplicatesearch-icon {
+ float: right;
+}
+
+/** Generic minor/bot/newpage styling (recent changes) */
+.newpage,
+.minoredit,
+.botedit {
+ font-weight: bold;
+}
+
+#shared-image-dup,
+#shared-image-conflict {
+ font-style: italic;
+}
+
+/**
+ * Recreating deleted page warning
+ * Reupload file warning
+ * Page protection warning
+ * incl. log entries for these warnings
+ */
+div.mw-warning-with-logexcerpt {
+ padding: 3px;
+ margin-bottom: 3px;
+ border: 2px solid #2F6FAB;
+ clear: both;
+}
+
+div.mw-warning-with-logexcerpt ul li {
+ font-size: 90%;
+}
+
+/* (show/hide) revision deletion links */
+span.mw-revdelundel-link,
+strong.mw-revdelundel-link {
+ font-size: 90%;
+}
+
+span.mw-revdelundel-hidden,
+input.mw-revdelundel-hidden {
+ visibility: hidden;
+}
+
+td.mw-revdel-checkbox,
+th.mw-revdel-checkbox {
+ padding-right: 10px;
+ text-align: center;
+}
+
+/* red links; see bug 36276 */
+a.new {
+ color: #BA0000;
+}
+
+/* feed links */
+a.feedlink {
+ /* SVG support using a transparent gradient to guarantee cross-browser
+ * compatibility (browsers able to understand gradient syntax support also SVG).
+ * http://pauginer.tumblr.com/post/36614680636/invisible-gradient-technique */
+ background-image: url(images/feed-icon.png);
+ /* @embed */
+ background-image: -webkit-linear-gradient(transparent, transparent), url(images/feed-icon.svg);
+ /* @embed */
+ background-image: linear-gradient(transparent, transparent), url(images/feed-icon.svg);
+ background-position: center left;
+ background-repeat: no-repeat;
+ background-size: 12px 12px;
+ padding-left: 16px;
+}
+
+/* Plainlinks - this can be used to switch
+ * off special external link styling */
+.plainlinks a.external {
+ background: none !important;
+ padding: 0 !important;
+}
+
+/* External URLs should always be treated as LTR (bug 4330) */
+/* @noflip */ .rtl a.external.free,
+.rtl a.external.autonumber {
+ direction: ltr;
+ unicode-bidi: embed;
+}
+
+/**
+ * wikitable class for skinning normal tables
+ * keep in sync with commonPrint.css
+ */
+table.wikitable {
+ margin: 1em 0;
+ background-color: #f9f9f9;
+ border: 1px #aaa solid;
+ border-collapse: collapse;
+ color: black;
+}
+
+table.wikitable > tr > th,
+table.wikitable > tr > td,
+table.wikitable > * > tr > th,
+table.wikitable > * > tr > td {
+ border: 1px #aaa solid;
+ padding: 0.2em;
+}
+
+table.wikitable > tr > th,
+table.wikitable > * > tr > th {
+ background-color: #f2f2f2;
+ text-align: center;
+}
+
+table.wikitable > caption {
+ font-weight: bold;
+}
+
+/* success and error messages */
+.error,
+.warning,
+.success {
+ font-size: larger;
+}
+
+.error {
+ color: #cc0000;
+}
+
+.warning {
+ color: #705000;
+}
+
+.success {
+ color: #009000;
+}
+
+.errorbox,
+.warningbox,
+.successbox {
+ border: 1px solid;
+ padding: .5em 1em;
+ margin-bottom: 1em;
+ display: -moz-inline-block;
+ display: inline-block;
+ zoom: 1;
+ *display: inline;
+}
+
+.errorbox h2,
+.warningbox h2,
+.successbox h2 {
+ font-size: 1em;
+ color: inherit;
+ font-weight: bold;
+ display: inline;
+ margin: 0 .5em 0 0;
+ border: none;
+}
+
+.errorbox {
+ color: #cc0000;
+ border-color: #fac5c5;
+ background-color: #fae3e3;
+}
+
+.warningbox {
+ color: #705000;
+ border-color: #fde29b;
+ background-color: #fdf1d1;
+}
+
+.successbox {
+ color: #009000;
+ border-color: #b7fdb5;
+ background-color: #e1fddf;
+}
+
+/* general info/warning box for SP */
+.mw-infobox {
+ border: 2px solid #ff7f00;
+ margin: 0.5em;
+ clear: left;
+ overflow: hidden;
+}
+
+.mw-infobox-left {
+ margin: 7px;
+ float: left;
+ width: 35px;
+}
+
+.mw-infobox-right {
+ margin: 0.5em 0.5em 0.5em 49px;
+}
+
+/* Note on preview page */
+.previewnote {
+ color: #c00;
+ margin-bottom: 1em;
+}
+
+.previewnote p {
+ text-indent: 3em;
+ margin: 0.8em 0;
+}
+
+.visualClear {
+ clear: both;
+}
+
+/**
+ * Data table style
+ *
+ * Transparent table with suddle borders
+ * and blue row-highlighting.
+ */
+.mw-datatable {
+ border-collapse: collapse;
+}
+
+.mw-datatable,
+.mw-datatable td,
+.mw-datatable th {
+ border: 1px solid #aaaaaa;
+ padding: 0 0.15em 0 0.15em;
+}
+
+.mw-datatable th {
+ background-color: #ddddff;
+}
+
+.mw-datatable td {
+ background-color: #ffffff;
+}
+
+.mw-datatable tr:hover td {
+ background-color: #eeeeff;
+}
+
+/* filetoc */
+ul#filetoc {
+ text-align: center;
+ border: 1px solid #aaaaaa;
+ background-color: #f9f9f9;
+ padding: 5px;
+ font-size: 95%;
+ margin-bottom: 0.5em;
+ margin-left: 0;
+ margin-right: 0;
+}
+
+#filetoc li {
+ display: inline;
+ list-style-type: none;
+ padding-right: 2em;
+}
+
+/* Classes for Exif data display */
+table.mw_metadata {
+ font-size: 0.8em;
+ margin-left: 0.5em;
+ margin-bottom: 0.5em;
+ width: 400px;
+}
+
+table.mw_metadata caption {
+ font-weight: bold;
+}
+
+table.mw_metadata th {
+ font-weight: normal;
+}
+
+table.mw_metadata td {
+ padding: 0.1em;
+}
+
+table.mw_metadata {
+ border: none;
+ border-collapse: collapse;
+}
+
+table.mw_metadata td,
+table.mw_metadata th {
+ text-align: center;
+ border: 1px solid #aaaaaa;
+ padding-left: 5px;
+ padding-right: 5px;
+}
+
+table.mw_metadata th {
+ background-color: #f9f9f9;
+}
+
+table.mw_metadata td {
+ background-color: #fcfcfc;
+}
+
+table.mw_metadata ul.metadata-langlist {
+ list-style-type: none;
+ list-style-image: none;
+ padding-right: 5px;
+ padding-left: 5px;
+ margin: 0;
+}
+
+/* Correct directionality when page dir is different from site/user dir */
+.mw-content-ltr ul,
+.mw-content-rtl .mw-content-ltr ul {
+ /* @noflip */
+ margin: 0.3em 0 0 1.6em;
+ padding: 0;
+}
+
+.mw-content-rtl ul,
+.mw-content-ltr .mw-content-rtl ul {
+ /* @noflip */
+ margin: 0.3em 1.6em 0 0;
+ padding: 0;
+}
+
+.mw-content-ltr ol,
+.mw-content-rtl .mw-content-ltr ol {
+ /* @noflip */
+ margin: 0.3em 0 0 3.2em;
+ padding: 0;
+}
+
+.mw-content-rtl ol,
+.mw-content-ltr .mw-content-rtl ol {
+ /* @noflip */
+ margin: 0.3em 3.2em 0 0;
+ padding: 0;
+}
+
+/* @noflip */
+.mw-content-ltr dd,
+.mw-content-rtl .mw-content-ltr dd {
+ margin-left: 1.6em;
+ margin-right: 0;
+}
+
+/* @noflip */
+.mw-content-rtl dd,
+.mw-content-ltr .mw-content-rtl dd {
+ margin-right: 1.6em;
+ margin-left: 0;
+}
+
+/* Galleries */
+/* These display attributes look nonsensical, but are needed to support IE and FF2 */
+/* Don't forget to update commonPrint.css */
+li.gallerybox {
+ vertical-align: top;
+ display: -moz-inline-box;
+ display: inline-block;
+}
+
+ul.gallery,
+li.gallerybox {
+ zoom: 1;
+ *display: inline;
+}
+
+ul.gallery {
+ margin: 2px;
+ padding: 2px;
+ display: block;
+}
+
+li.gallerycaption {
+ font-weight: bold;
+ text-align: center;
+ display: block;
+ word-wrap: break-word;
+}
+
+li.gallerybox div.thumb {
+ text-align: center;
+ border: 1px solid #ccc;
+ background-color: #f9f9f9;
+ margin: 2px;
+}
+
+li.gallerybox div.thumb img {
+ display: block;
+ margin: 0 auto;
+}
+
+div.gallerytext {
+ overflow: hidden;
+ font-size: 94%;
+ padding: 2px 4px;
+ word-wrap: break-word;
+}
+
+/* new gallery stuff */
+ul.mw-gallery-nolines li.gallerybox div.thumb {
+ background-color: transparent;
+ border: none;
+}
+
+ul.mw-gallery-nolines li.gallerybox div.gallerytext {
+ text-align: center;
+}
+
+/* height constrained gallery */
+
+ul.mw-gallery-packed li.gallerybox div.thumb,
+ul.mw-gallery-packed-overlay li.gallerybox div.thumb,
+ul.mw-gallery-packed-hover li.gallerybox div.thumb {
+ background-color: transparent;
+ border: none;
+}
+
+ul.mw-gallery-packed li.gallerybox div.thumb img,
+ul.mw-gallery-packed-overlay li.gallerybox div.thumb img,
+ul.mw-gallery-packed-hover li.gallerybox div.thumb img {
+ margin: 0 auto;
+}
+
+ul.mw-gallery-packed-hover li.gallerybox,
+ul.mw-gallery-packed-overlay li.gallerybox {
+ position: relative;
+}
+
+ul.mw-gallery-packed-hover div.gallerytextwrapper {
+ overflow: hidden;
+ height: 0;
+}
+
+ul.mw-gallery-packed-hover li.gallerybox:hover div.gallerytextwrapper,
+ul.mw-gallery-packed-overlay li.gallerybox div.gallerytextwrapper,
+ul.mw-gallery-packed-hover li.gallerybox.mw-gallery-focused div.gallerytextwrapper {
+ position: absolute;
+ background: white;
+ background: rgba(255, 255, 255, 0.8);
+ padding: 5px 10px;
+ bottom: 0;
+ left: 0; /* Needed for IE */
+ height: auto;
+ font-weight: bold;
+ margin: 2px; /* correspond to style on div.thumb */
+}
+
+ul.mw-gallery-packed-hover,
+ul.mw-gallery-packed-overlay,
+ul.mw-gallery-packed {
+ text-align: center;
+}
+
+.mw-ajax-loader {
+ /* @embed */
+ background-image: url(images/ajax-loader.gif);
+ background-position: center center;
+ background-repeat: no-repeat;
+ padding: 16px;
+ position: relative;
+ top: -16px;
+}
+
+.mw-small-spinner {
+ padding: 10px !important;
+ margin-right: 0.6em;
+ /* @embed */
+ background-image: url(images/spinner.gif);
+ background-position: center center;
+ background-repeat: no-repeat;
+}
+
+/* Language specific height correction for titles. Ref Bug 29405 and Bug 30809 */
+/* Languages like hi or ml require slightly more vertical space to show diacritics properly */
+h1:lang(anp),
+h1:lang(as),
+h1:lang(bh), /* Macrolanguage, used on bh.wikipedia.org, should be removed one day */
+h1:lang(bho),
+h1:lang(bn),
+h1:lang(gu),
+h1:lang(hi),
+h1:lang(kn),
+h1:lang(ks),
+h1:lang(ml),
+h1:lang(mr),
+h1:lang(my),
+h1:lang(mai),
+h1:lang(ne),
+h1:lang(new),
+h1:lang(or),
+h1:lang(pa),
+h1:lang(pi),
+h1:lang(sa),
+h1:lang(ta),
+h1:lang(te) {
+ line-height: 1.6em !important;
+}
+
+h2:lang(anp), h3:lang(anp), h4:lang(anp), h5:lang(anp), h6:lang(anp),
+h2:lang(as), h3:lang(as), h4:lang(as), h5:lang(as), h6:lang(as),
+h2:lang(bho), h3:lang(bho), h4:lang(bho), h5:lang(bho), h6:lang(bho),
+h2:lang(bh), h3:lang(bh), h4:lang(bh), h5:lang(bh), h6:lang(bh),
+h2:lang(bn), h3:lang(bn), h4:lang(bn), h5:lang(bn), h6:lang(bn),
+h2:lang(gu), h3:lang(gu), h4:lang(gu), h5:lang(gu), h6:lang(gu),
+h2:lang(hi), h3:lang(hi), h4:lang(hi), h5:lang(hi), h6:lang(hi),
+h2:lang(kn), h3:lang(kn), h4:lang(kn), h5:lang(kn), h6:lang(kn),
+h2:lang(ks), h3:lang(ks), h4:lang(ks), h5:lang(ks), h6:lang(ks),
+h2:lang(ml), h3:lang(ml), h4:lang(ml), h5:lang(ml), h6:lang(ml),
+h2:lang(mr), h3:lang(mr), h4:lang(mr), h5:lang(mr), h6:lang(mr),
+h2:lang(my), h3:lang(my), h4:lang(my), h5:lang(my), h6:lang(my),
+h2:lang(mai), h3:lang(mai), h4:lang(mai), h5:lang(mai), h6:lang(mai),
+h2:lang(ne), h3:lang(ne), h4:lang(ne), h5:lang(ne), h6:lang(ne),
+h2:lang(new), h3:lang(new), h4:lang(new), h5:lang(new), h6:lang(new),
+h2:lang(or), h3:lang(or), h4:lang(or), h5:lang(or), h6:lang(or),
+h2:lang(pa), h3:lang(pa), h4:lang(pa), h5:lang(pa), h6:lang(pa),
+h2:lang(pi), h3:lang(pi), h4:lang(pi), h5:lang(pi), h6:lang(pi),
+h2:lang(sa), h3:lang(sa), h4:lang(sa), h5:lang(sa), h6:lang(sa),
+h2:lang(ta), h3:lang(ta), h4:lang(ta), h5:lang(ta), h6:lang(ta),
+h2:lang(te), h3:lang(te), h4:lang(te), h5:lang(te), h6:lang(te) {
+ line-height: 1.2em;
+}
+
+/* Localised ordered list numbering for some languages */
+ol:lang(bcc) li,
+ol:lang(bqi) li,
+ol:lang(fa) li,
+ol:lang(glk) li,
+ol:lang(kk-arab) li,
+ol:lang(mzn) li {
+ list-style-type: -moz-persian;
+ list-style-type: persian;
+}
+
+ol:lang(ckb) li {
+ list-style-type: -moz-arabic-indic;
+ list-style-type: arabic-indic;
+}
+
+ol:lang(hi) li,
+ol:lang(mr) li {
+ list-style-type: -moz-devanagari;
+ list-style-type: devanagari;
+}
+
+ol:lang(as) li,
+ol:lang(bn) li {
+ list-style-type: -moz-bengali;
+ list-style-type: bengali;
+}
+
+ol:lang(or) li {
+ list-style-type: -moz-oriya;
+ list-style-type: oriya;
+}
+
+#toc ul, .toc ul {
+ margin: .3em 0;
+}
+
+/* Correct directionality when page dir is different from site/user dir */
+/* @noflip */ .mw-content-ltr .toc ul,
+.mw-content-ltr #toc ul,
+.mw-content-rtl .mw-content-ltr .toc ul,
+.mw-content-rtl .mw-content-ltr #toc ul {
+ text-align: left;
+}
+
+/* @noflip */ .mw-content-rtl .toc ul,
+.mw-content-rtl #toc ul,
+.mw-content-ltr .mw-content-rtl .toc ul,
+.mw-content-ltr .mw-content-rtl #toc ul {
+ text-align: right;
+}
+
+/* @noflip */ .mw-content-ltr .toc ul ul,
+.mw-content-ltr #toc ul ul,
+.mw-content-rtl .mw-content-ltr .toc ul ul,
+.mw-content-rtl .mw-content-ltr #toc ul ul {
+ margin: 0 0 0 2em;
+}
+
+/* @noflip */ .mw-content-rtl .toc ul ul,
+.mw-content-rtl #toc ul ul,
+.mw-content-ltr .mw-content-rtl .toc ul ul,
+.mw-content-ltr .mw-content-rtl #toc ul ul {
+ margin: 0 2em 0 0;
+}
+
+#toc #toctitle,
+.toc #toctitle,
+#toc .toctitle,
+.toc .toctitle {
+ direction: ltr;
+}
+
+/* tooltip styles */
+.mw-help-field-hint {
+ display: none;
+ margin-left: 2px;
+ margin-bottom: -8px;
+ padding: 0 0 0 15px;
+ /* @embed */
+ background-image: url(images/help-question.gif);
+ background-position: left center;
+ background-repeat: no-repeat;
+ cursor: pointer;
+ font-size: .8em;
+ text-decoration: underline;
+ color: #0645ad;
+}
+
+.mw-help-field-hint:hover {
+ /* @embed */
+ background-image: url(images/help-question-hover.gif);
+}
+
+.mw-help-field-data {
+ display: block;
+ background-color: #d6f3ff;
+ padding: 5px 8px 4px 8px;
+ border: 1px solid #5dc9f4;
+ margin-left: 20px;
+}
+
+#mw-clearyourcache,
+#mw-sitecsspreview,
+#mw-sitejspreview,
+#mw-usercsspreview,
+#mw-userjspreview {
+ direction: ltr;
+ unicode-bidi: embed;
+}
+
+/* Correct user & content directionality when viewing a diff */
+.diff-currentversion-title,
+.diff {
+ direction: ltr;
+ unicode-bidi: embed;
+}
+
+/* @noflip */ .diff-contentalign-right td {
+ direction: rtl;
+ unicode-bidi: embed;
+}
+
+/* @noflip */ .diff-contentalign-left td {
+ direction: ltr;
+ unicode-bidi: embed;
+}
+
+.diff-multi,
+.diff-otitle,
+.diff-ntitle,
+.diff-lineno {
+ direction: ltr !important;
+ unicode-bidi: embed;
+}
+
+#mw-revision-info,
+#mw-revision-info-current,
+#mw-revision-nav {
+ direction: ltr;
+ display: inline;
+}
+
+/* Images */
+
+/* @noflip */ div.tright,
+div.floatright,
+table.floatright {
+ clear: right;
+ float: right;
+}
+
+/* @noflip */ div.tleft,
+div.floatleft,
+table.floatleft {
+ float: left;
+ clear: left;
+}
+
+div.floatright,
+table.floatright,
+div.floatleft,
+table.floatleft {
+ position: relative;
+}
+
+/* bug 12205 */
+#mw-credits a {
+ unicode-bidi: embed;
+}
+
+/* Accessibility */
+.mw-jump,
+#jump-to-nav {
+ overflow: hidden;
+ height: 0;
+ zoom: 1; /* http://webaim.org/techniques/skipnav/#iequirk */
+}
+
+/* Print footer should be hidden by default in screen. */
+.printfooter {
+ display: none;
+}
+
+/* For developers */
+.xdebug-error {
+ position: absolute;
+ z-index: 99;
+}
+
+.mw-editsection,
+.toctoggle,
+#jump-to-nav {
+ -moz-user-select: none;
+ -webkit-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+}
+
+/* Display editsection links smaller and next to headings */
+.mw-editsection,
+.mw-editsection-like {
+ font-size: small;
+ font-weight: normal;
+ margin-left: 1em;
+ vertical-align: baseline;
+ /* Reset line-height; headings tend to have it set to larger values */
+ line-height: 1em;
+ /* As .mw-editsection is a <span> (inline element), it is treated as part */
+ /* of the heading content when selecting text by multiple clicks and thus */
+ /* selected together with heading content, despite the user-select: none; */
+ /* rule set above. This enforces non-selection without changing the look. */
+ display: inline-block;
+}
+
+/* Correct directionality when page dir is different from site/user dir */
+/* @noflip */
+.mw-content-ltr .mw-editsection,
+.mw-content-rtl .mw-content-ltr .mw-editsection {
+ margin-left: 1em;
+}
+
+/* @noflip */
+.mw-content-rtl .mw-editsection,
+.mw-content-ltr .mw-content-rtl .mw-editsection {
+ margin-right: 1em;
+}
+
+/* Prevent citations and subscripts from interfering with the line-height */
+sup,
+sub {
+ line-height: 1;
+}
diff --git a/resources/src/mediawiki.legacy/wikibits.js b/resources/src/mediawiki.legacy/wikibits.js
new file mode 100644
index 00000000..a4039966
--- /dev/null
+++ b/resources/src/mediawiki.legacy/wikibits.js
@@ -0,0 +1,204 @@
+/**
+ * MediaWiki legacy wikibits
+ */
+( function ( mw, $ ) {
+ var msg,
+ win = window,
+ ua = navigator.userAgent.toLowerCase(),
+ onloadFuncts = [];
+
+/**
+ * User-agent sniffing.
+ *
+ * @deprecated since 1.17 Use jquery.client instead
+ */
+
+msg = 'Use feature detection or module jquery.client instead.';
+
+mw.log.deprecate( win, 'clientPC', ua, msg );
+
+// Ignored dummy values
+mw.log.deprecate( win, 'is_gecko', false, msg );
+mw.log.deprecate( win, 'is_chrome_mac', false, msg );
+mw.log.deprecate( win, 'is_chrome', false, msg );
+mw.log.deprecate( win, 'webkit_version', false, msg );
+mw.log.deprecate( win, 'is_safari_win', false, msg );
+mw.log.deprecate( win, 'is_safari', false, msg );
+mw.log.deprecate( win, 'webkit_match', false, msg );
+mw.log.deprecate( win, 'is_ff2', false, msg );
+mw.log.deprecate( win, 'ff2_bugs', false, msg );
+mw.log.deprecate( win, 'is_ff2_win', false, msg );
+mw.log.deprecate( win, 'is_ff2_x11', false, msg );
+mw.log.deprecate( win, 'opera95_bugs', false, msg );
+mw.log.deprecate( win, 'opera7_bugs', false, msg );
+mw.log.deprecate( win, 'opera6_bugs', false, msg );
+mw.log.deprecate( win, 'is_opera_95', false, msg );
+mw.log.deprecate( win, 'is_opera_preseven', false, msg );
+mw.log.deprecate( win, 'is_opera', false, msg );
+mw.log.deprecate( win, 'ie6_bugs', false, msg );
+
+/**
+ * DOM utilities for handling of events, text nodes and selecting elements
+ *
+ * @deprecated since 1.17 Use jQuery instead
+ */
+msg = 'Use jQuery instead.';
+
+// Ignored dummy values
+mw.log.deprecate( win, 'doneOnloadHook', undefined, msg );
+mw.log.deprecate( win, 'onloadFuncts', [], msg );
+mw.log.deprecate( win, 'runOnloadHook', $.noop, msg );
+mw.log.deprecate( win, 'changeText', $.noop, msg );
+mw.log.deprecate( win, 'killEvt', $.noop, msg );
+mw.log.deprecate( win, 'addHandler', $.noop, msg );
+mw.log.deprecate( win, 'hookEvent', $.noop, msg );
+mw.log.deprecate( win, 'addClickHandler', $.noop, msg );
+mw.log.deprecate( win, 'removeHandler', $.noop, msg );
+mw.log.deprecate( win, 'getElementsByClassName', function () { return []; }, msg );
+mw.log.deprecate( win, 'getInnerText', function () { return ''; }, msg );
+
+// Run a function after the window onload event is fired
+mw.log.deprecate( win, 'addOnloadHook', function ( hookFunct ) {
+ if ( onloadFuncts ) {
+ onloadFuncts.push(hookFunct);
+ } else {
+ // If func queue is gone the event has happened already,
+ // run immediately instead of queueing.
+ hookFunct();
+ }
+}, msg );
+
+$( win ).on( 'load', function () {
+ var i, functs;
+
+ // Don't run twice
+ if ( !onloadFuncts ) {
+ return;
+ }
+
+ // Deference and clear onloadFuncts before running any
+ // hooks to make sure we don't miss any addOnloadHook
+ // calls.
+ functs = onloadFuncts.slice();
+ onloadFuncts = undefined;
+
+ // Execute the queued functions
+ for ( i = 0; i < functs.length; i++ ) {
+ functs[i]();
+ }
+} );
+
+/**
+ * Toggle checkboxes with shift selection
+ *
+ * @deprecated since 1.17 Use jquery.checkboxShiftClick instead
+ */
+msg = 'Use jquery.checkboxShiftClick instead.';
+mw.log.deprecate( win, 'checkboxes', [], msg );
+mw.log.deprecate( win, 'lastCheckbox', null, msg );
+mw.log.deprecate( win, 'setupCheckboxShiftClick', $.noop, msg );
+mw.log.deprecate( win, 'addCheckboxClickHandlers', $.noop, msg );
+mw.log.deprecate( win, 'checkboxClickHandler', $.noop, msg );
+
+/**
+ * Add a button to the default editor toolbar
+ *
+ * @deprecated since 1.17 Use mw.toolbar instead
+ */
+mw.log.deprecate( win, 'mwEditButtons', [], 'Use mw.toolbar instead.' );
+mw.log.deprecate( win, 'mwCustomEditButtons', [], 'Use mw.toolbar instead.' );
+
+/**
+ * Spinner creation, injection and removal
+ *
+ * @deprecated since 1.18 Use jquery.spinner instead
+ */
+mw.log.deprecate( win, 'injectSpinner', $.noop, 'Use jquery.spinner instead.' );
+mw.log.deprecate( win, 'removeSpinner', $.noop, 'Use jquery.spinner instead.' );
+
+/**
+ * Escape utilities
+ *
+ * @deprecated since 1.18 Use mw.html instead
+ */
+mw.log.deprecate( win, 'escapeQuotes', $.noop, 'Use mw.html instead.' );
+mw.log.deprecate( win, 'escapeQuotesHTML', $.noop, 'Use mw.html instead.' );
+
+/**
+ * Display a message to the user
+ *
+ * @deprecated since 1.17 Use mediawiki.notify instead
+ * @param {string|HTMLElement} message To be put inside the message box
+ */
+mw.log.deprecate( win, 'jsMsg', function ( message ) {
+ if ( !arguments.length || message === '' || message === null ) {
+ return true;
+ }
+ if ( typeof message !== 'object' ) {
+ message = $.parseHTML( message );
+ }
+ mw.notify( message, { autoHide: true, tag: 'legacy' } );
+ return true;
+}, 'Use mediawiki.notify instead.' );
+
+/**
+ * Misc. utilities
+ *
+ * @deprecated since 1.17 Use mediawiki.util or jquery.accessKeyLabel instead
+ */
+msg = 'Use mediawiki.util instead.';
+mw.log.deprecate( win, 'addPortletLink', mw.util.addPortletLink, msg );
+mw.log.deprecate( win, 'appendCSS', mw.util.addCSS, msg );
+msg = 'Use jquery.accessKeyLabel instead.';
+mw.log.deprecate( win, 'tooltipAccessKeyPrefix', 'alt-', msg );
+mw.log.deprecate( win, 'tooltipAccessKeyRegexp', /\[(alt-)?(.)\]$/, msg );
+// mw.util.updateTooltipAccessKeys already generates a deprecation message.
+win.updateTooltipAccessKeys = function () {
+ return mw.util.updateTooltipAccessKeys.apply( null, arguments );
+};
+
+/**
+ * Wikipage import methods
+ */
+
+// included-scripts tracker
+win.loadedScripts = {};
+
+win.importScript = function ( page ) {
+ var uri = mw.config.get( 'wgScript' ) + '?title=' +
+ mw.util.wikiUrlencode( page ) +
+ '&action=raw&ctype=text/javascript';
+ return win.importScriptURI( uri );
+};
+
+win.importScriptURI = function ( url ) {
+ if ( win.loadedScripts[url] ) {
+ return null;
+ }
+ win.loadedScripts[url] = true;
+ var s = document.createElement( 'script' );
+ s.setAttribute( 'src', url );
+ s.setAttribute( 'type', 'text/javascript' );
+ document.getElementsByTagName( 'head' )[0].appendChild( s );
+ return s;
+};
+
+win.importStylesheet = function ( page ) {
+ var uri = mw.config.get( 'wgScript' ) + '?title=' +
+ mw.util.wikiUrlencode( page ) +
+ '&action=raw&ctype=text/css';
+ return win.importStylesheetURI( uri );
+};
+
+win.importStylesheetURI = function ( url, media ) {
+ var l = document.createElement( 'link' );
+ l.rel = 'stylesheet';
+ l.href = url;
+ if ( media ) {
+ l.media = media;
+ }
+ document.getElementsByTagName('head')[0].appendChild( l );
+ return l;
+};
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.less/mediawiki.mixins.animation.less b/resources/src/mediawiki.less/mediawiki.mixins.animation.less
new file mode 100644
index 00000000..ec3cddc6
--- /dev/null
+++ b/resources/src/mediawiki.less/mediawiki.mixins.animation.less
@@ -0,0 +1,12 @@
+.animation (...) {
+ -webkit-animation: @arguments;
+ -moz-animation: @arguments;
+ -o-animation: @arguments;
+ animation: @arguments;
+}
+
+.transform-rotate (@deg) {
+ -webkit-transform: rotate(@deg);
+ -moz-transform: rotate(@deg);
+ transform: rotate(@deg);
+} \ No newline at end of file
diff --git a/resources/src/mediawiki.less/mediawiki.mixins.less b/resources/src/mediawiki.less/mediawiki.mixins.less
new file mode 100644
index 00000000..c360e1f4
--- /dev/null
+++ b/resources/src/mediawiki.less/mediawiki.mixins.less
@@ -0,0 +1,61 @@
+/**
+ * Common LESS mixin library for MediaWiki
+ *
+ * By default the folder containing this file is included in $wgResourceLoaderLESSImportPaths,
+ * which makes this file importable by all less files via '@import "mediawiki.mixins";'.
+ *
+ * The mixins included below are considered a public interface for MediaWiki extensions.
+ * The signatures of parametrized mixins should be kept as stable as possible.
+ *
+ * See <http://lesscss.org/#-mixins> for more information about how to write mixins.
+ */
+
+.background-image(@url) {
+ background-image: e('/* @embed */') url(@url);
+}
+
+.vertical-gradient(@startColor: gray, @endColor: white, @startPos: 0, @endPos: 100%) {
+ background-color: @endColor;
+ background-image: -moz-linear-gradient( top, @startColor @startPos, @endColor @endPos ); // Firefox 3.6+
+ background-image: -webkit-gradient( linear, left top, left bottom, color-stop( @startPos, @startColor ), color-stop( @endPos, @endColor ) ); // Safari 4+, Chrome 2+
+ background-image: -webkit-linear-gradient( top, @startColor @startPos, @endColor @endPos ); // Safari 5.1+, Chrome 10+
+ background-image: linear-gradient( @startColor @startPos, @endColor @endPos ); // Standard
+}
+
+/*
+ * SVG support using a transparent gradient to guarantee cross-browser
+ * compatibility (browsers able to understand gradient syntax support also SVG).
+ * http://pauginer.tumblr.com/post/36614680636/invisible-gradient-technique
+ *
+ * We use gzip compression, which means that it is okay to embed twice.
+ *
+ * We do not embed the fallback image on the assumption that the gain for old browsers
+ * is not worth the harm done to modern ones.
+ */
+.background-image-svg(@svg, @fallback) {
+ background-image: url(@fallback);
+ background-image: -webkit-linear-gradient(transparent, transparent), e('/* @embed */') url(@svg);
+ background-image: linear-gradient(transparent, transparent), e('/* @embed */') url(@svg);
+}
+
+.list-style-image(@url) {
+ list-style-image: e('/* @embed */') url(@url);
+}
+
+.transition(@value) {
+ -webkit-transition: @value; // Safari 3.1-6.0, iOS 3.2-6.1, Android 2.1-4.3
+ -moz-transition: @value; // Firefox 4-15
+ -o-transition: @value; // Opera 10.5-12.0
+ transition: @value; // Chrome 26+, Firefox 16+, IE 10+, Safari 6.1+, Opera 12.1+, iOS 7+, Android 4.4+
+}
+
+.box-sizing(@value) {
+ -webkit-box-sizing: @value; // Safari 3.1-5.0, iOS 3.2-4.3, Android 2.1-3.0
+ -moz-box-sizing: @value; // Firefox 4-28,
+ box-sizing: @value; // Chrome 10+, Firefox 29+, IE 8+, Safari 5.1+, Opera 10+, iOS 5+, Android 4+
+}
+
+.box-shadow(@value) {
+ -webkit-box-shadow: @value; // Safari 3.1-5.0, iOS 3.2-4.3, Android 2.1-3.0
+ box-shadow: @value; // Chrome 10+, Firefox 4+, IE 9+, Safari 5.1+, Opera 11+, iOS 5+, Android 4+
+}
diff --git a/resources/src/mediawiki.less/mediawiki.mixins.rotation.less b/resources/src/mediawiki.less/mediawiki.mixins.rotation.less
new file mode 100644
index 00000000..e28b333f
--- /dev/null
+++ b/resources/src/mediawiki.less/mediawiki.mixins.rotation.less
@@ -0,0 +1,33 @@
+// This is a separate file because importing the mixin causes
+// the keyframes blocks to be included in the output, regardless
+// of whether .rotation is used.
+@import "mediawiki.mixins.animation";
+
+.rotate-frames () {
+ from {
+ .transform-rotate(0deg);
+ }
+ to {
+ .transform-rotate(360deg);
+ }
+}
+
+@-webkit-keyframes rotate {
+ .rotate-frames;
+}
+
+@-moz-keyframes rotate {
+ .rotate-frames;
+}
+
+@-o-keyframes rotate {
+ .rotate-frames;
+}
+
+@keyframes rotate {
+ .rotate-frames;
+}
+
+.rotation( @time ) {
+ .animation(rotate, @time, infinite, linear);
+}
diff --git a/resources/src/mediawiki.less/mediawiki.ui/mixins.less b/resources/src/mediawiki.less/mediawiki.ui/mixins.less
new file mode 100644
index 00000000..ec9888f2
--- /dev/null
+++ b/resources/src/mediawiki.less/mediawiki.ui/mixins.less
@@ -0,0 +1,122 @@
+// ----------------------------------------------------------------------------
+// Form styling mixins
+// ----------------------------------------------------------------------------
+.agora-label-styling() {
+ font-size: 0.9em;
+ color: @colorText;
+
+ * {
+ font-weight: normal;
+ }
+}
+
+.agora-inline-label-styling() {
+ margin-bottom: 0.5em;
+ cursor: pointer;
+ vertical-align: bottom;
+ line-height: normal;
+
+ font-weight: normal;
+
+ & > input[type="checkbox"],
+ & > input[type="radio"] {
+ width: auto;
+ height: auto;
+ margin: 0 0.1em 0 0;
+ padding: 0;
+ border: 1px solid @colorFieldBorder;
+ cursor: pointer;
+ }
+}
+
+// ----------------------------------------------------------------------------
+// Button styling
+// ----------------------------------------------------------------------------
+
+.button-colors(@bgColor) {
+ background: @bgColor;
+
+ &:hover,
+ &:focus {
+ // The inner bottom bevel should match the active background color.
+ box-shadow: 0 1px rgba(0, 0, 0, 10%), inset 0 -3px rgba(0, 0, 0, 20%);
+ border-bottom-color: mix(#000, @bgColor, 20%);
+ outline: none;
+ // remove outline in Firefox
+ &::-moz-focus-inner {
+ border-color: transparent;
+ }
+ }
+
+ &:active,
+ &.mw-ui-checked {
+ // lessphp doesn't implement shade (https://github.com/leafo/lessphp/issues/528);
+ // it passes it through, then ResourceLoader drops it.
+ // background: shade(@bgColor, 20%);
+ background: mix(#000, @bgColor, 20%);
+ box-shadow: none;
+ }
+}
+
+.button-colors(@bgColor) when (lightness(@bgColor) >= 70%) {
+ color: @colorButtonText;
+ border: 1px solid @colorGray12;
+
+ &:disabled {
+ color: @colorDisabledText;
+
+ // make sure disabled buttons don't have hover and active states
+ &:hover,
+ &:active {
+ background: @bgColor;
+ box-shadow: none;
+ }
+ }
+}
+
+.button-colors(@bgColor) when (lightness(@bgColor) < 70%) {
+ color: #fff;
+ // border of the same color as background so that light background and
+ // dark background buttons are the same height (only top and bottom to
+ // make box shadow on hover cover the corners too)
+ border: 1px solid @bgColor;
+ border-left: none;
+ border-right: none;
+ text-shadow: 0 1px rgba(0, 0, 0, .1);
+
+ &:disabled {
+ background: @colorGray12;
+ border-color: @colorGray12;
+
+ // make sure disabled buttons don't have hover and active states
+ &:hover,
+ &:active,
+ &.mw-ui-checked {
+ box-shadow: none;
+ }
+ }
+}
+
+.button-colors-quiet(@textColor) {
+ // Quiet buttons all start gray, and reveal
+ // constructive/progressive/destructive color on hover and active.
+ color: @colorButtonText;
+
+ &:hover,
+ &:focus {
+ // lessphp doesn't implement tint, see above
+ // color: tint(@textColor, 20%);
+ color: mix(#fff, @textColor, 20%);
+ }
+
+ &:active,
+ &.mw-ui-checked {
+ // lessphp doesn't implement shade, see above
+ // color: shade(@textColor, 20%);
+ color: mix(#000, @textColor, 20%);
+ }
+
+ &:disabled {
+ color: @colorDisabledText;
+ }
+}
diff --git a/resources/src/mediawiki.less/mediawiki.ui/variables.less b/resources/src/mediawiki.less/mediawiki.ui/variables.less
new file mode 100644
index 00000000..e91302be
--- /dev/null
+++ b/resources/src/mediawiki.less/mediawiki.ui/variables.less
@@ -0,0 +1,62 @@
+// Colors for use in mediawiki.ui and elsewhere
+
+// Although this defines many shades, be parsimonious in your own use of grays. Prefer
+// colors already in use in MediaWiki. Prefer semantic color names such as "@colorText".
+@colorGray1: #111; // darkest
+@colorGray2: #222;
+@colorGray3: #333;
+@colorGray4: #444;
+@colorGray5: #555;
+@colorGray6: #666;
+@colorGray7: #777;
+@colorGray8: #888;
+@colorGray9: #999;
+@colorGray10: #AAA;
+@colorGray11: #BBB;
+@colorGray12: #CCC;
+@colorGray13: #DDD;
+@colorGray14: #EEE;
+@colorGray15: #F9F9F9; // lightest
+
+// Semantic background colors
+// Blue; for contextual use of a continuing action
+@colorProgressive: #347bff;
+// Green; for contextual use of a positive finalizing action
+@colorConstructive: #00af89;
+// Orange; for contextual use of returning to a past action
+@colorRegressive: #FF5D00;
+// Red; for contextual use of a negative action of high severity
+@colorDestructive: #d11d13;
+// Orange; for contextual use of a potentially negative action of medium severity
+@colorMediumSevere: #FF5D00;
+// Yellow; for contextual use of a potentially negative action of low severity
+@colorLowSevere: #FFB50D;
+
+// Used in mixins to darken contextual colors by the same amount (eg. focus)
+@colorDarkenPercentage: 13.5%;
+// Used in mixins to lighten contextual colors by the same amount (eg. hover)
+@colorLightenPercentage: 13.5%;
+
+// Text colors
+@colorText: @colorGray2;
+@colorTextLight: @colorGray6;
+@colorButtonText: @colorGray5;
+@colorDisabledText: @colorGray12;
+@colorErrorText: #CC0000;
+
+// UI colors
+@colorFieldBorder: @colorGray12;
+@colorShadow: @colorGray14;
+@colorPlaceholder: @colorGray10;
+@colorNeutral: @colorGray7;
+
+// The following rules are deprecated
+@colorWhite: #fff;
+@colorOffWhite: #fafafa;
+@colorGrayDark: #898989;
+@colorGrayLight: #ccc;
+@colorGrayLighter: #ddd;
+@colorGrayLightest: #eee;
+
+// Global border radius to be used to buttons and inputs
+@borderRadius: 2px;
diff --git a/resources/src/mediawiki.libs/CLDRPluralRuleParser.js b/resources/src/mediawiki.libs/CLDRPluralRuleParser.js
new file mode 100644
index 00000000..83c25245
--- /dev/null
+++ b/resources/src/mediawiki.libs/CLDRPluralRuleParser.js
@@ -0,0 +1,475 @@
+/* This is CLDRPluralRuleParser v1.1, ported to MediaWiki ResourceLoader */
+
+/**
+* CLDRPluralRuleParser.js
+* A parser engine for CLDR plural rules.
+*
+* Copyright 2012 GPLV3+, Santhosh Thottingal
+*
+* @version 0.1.0-alpha
+* @source https://github.com/santhoshtr/CLDRPluralRuleParser
+* @author Santhosh Thottingal <santhosh.thottingal@gmail.com>
+* @author Timo Tijhof
+* @author Amir Aharoni
+*/
+
+( function ( mw ) {
+/**
+ * Evaluates a plural rule in CLDR syntax for a number
+ * @param {string} rule
+ * @param {integer} number
+ * @return {boolean} true if evaluation passed, false if evaluation failed.
+ */
+
+function pluralRuleParser(rule, number) {
+ /*
+ Syntax: see http://unicode.org/reports/tr35/#Language_Plural_Rules
+ -----------------------------------------------------------------
+ condition = and_condition ('or' and_condition)*
+ ('@integer' samples)?
+ ('@decimal' samples)?
+ and_condition = relation ('and' relation)*
+ relation = is_relation | in_relation | within_relation
+ is_relation = expr 'is' ('not')? value
+ in_relation = expr (('not')? 'in' | '=' | '!=') range_list
+ within_relation = expr ('not')? 'within' range_list
+ expr = operand (('mod' | '%') value)?
+ operand = 'n' | 'i' | 'f' | 't' | 'v' | 'w'
+ range_list = (range | value) (',' range_list)*
+ value = digit+
+ digit = 0|1|2|3|4|5|6|7|8|9
+ range = value'..'value
+ samples = sampleRange (',' sampleRange)* (',' ('…'|'...'))?
+ sampleRange = decimalValue '~' decimalValue
+ decimalValue = value ('.' value)?
+ */
+
+ // we don't evaluate the samples section of the rule. Ignore it.
+ rule = rule.split('@')[0].replace(/^\s*/, '').replace(/\s*$/, '');
+
+ if (!rule.length) {
+ // empty rule or 'other' rule.
+ return true;
+ }
+ // Indicates current position in the rule as we parse through it.
+ // Shared among all parsing functions below.
+ var pos = 0,
+ operand,
+ expression,
+ relation,
+ result,
+ whitespace = makeRegexParser(/^\s+/),
+ value = makeRegexParser(/^\d+/),
+ _n_ = makeStringParser('n'),
+ _i_ = makeStringParser('i'),
+ _f_ = makeStringParser('f'),
+ _t_ = makeStringParser('t'),
+ _v_ = makeStringParser('v'),
+ _w_ = makeStringParser('w'),
+ _is_ = makeStringParser('is'),
+ _isnot_ = makeStringParser('is not'),
+ _isnot_sign_ = makeStringParser('!='),
+ _equal_ = makeStringParser('='),
+ _mod_ = makeStringParser('mod'),
+ _percent_ = makeStringParser('%'),
+ _not_ = makeStringParser('not'),
+ _in_ = makeStringParser('in'),
+ _within_ = makeStringParser('within'),
+ _range_ = makeStringParser('..'),
+ _comma_ = makeStringParser(','),
+ _or_ = makeStringParser('or'),
+ _and_ = makeStringParser('and');
+
+ function debug() {
+ // console.log.apply(console, arguments);
+ }
+
+ debug('pluralRuleParser', rule, number);
+
+ // Try parsers until one works, if none work return null
+
+ function choice(parserSyntax) {
+ return function() {
+ for (var i = 0; i < parserSyntax.length; i++) {
+ var result = parserSyntax[i]();
+ if (result !== null) {
+ return result;
+ }
+ }
+ return null;
+ };
+ }
+
+ // Try several parserSyntax-es in a row.
+ // All must succeed; otherwise, return null.
+ // This is the only eager one.
+
+ function sequence(parserSyntax) {
+ var originalPos = pos;
+ var result = [];
+ for (var i = 0; i < parserSyntax.length; i++) {
+ var res = parserSyntax[i]();
+ if (res === null) {
+ pos = originalPos;
+ return null;
+ }
+ result.push(res);
+ }
+ return result;
+ }
+
+ // Run the same parser over and over until it fails.
+ // Must succeed a minimum of n times; otherwise, return null.
+
+ function nOrMore(n, p) {
+ return function() {
+ var originalPos = pos;
+ var result = [];
+ var parsed = p();
+ while (parsed !== null) {
+ result.push(parsed);
+ parsed = p();
+ }
+ if (result.length < n) {
+ pos = originalPos;
+ return null;
+ }
+ return result;
+ };
+ }
+
+ // Helpers -- just make parserSyntax out of simpler JS builtin types
+ function makeStringParser(s) {
+ var len = s.length;
+ return function() {
+ var result = null;
+ if (rule.substr(pos, len) === s) {
+ result = s;
+ pos += len;
+ }
+
+ return result;
+ };
+ }
+
+ function makeRegexParser(regex) {
+ return function() {
+ var matches = rule.substr(pos).match(regex);
+ if (matches === null) {
+ return null;
+ }
+ pos += matches[0].length;
+ return matches[0];
+ };
+ }
+
+ /*
+ * integer digits of n.
+ */
+ function i() {
+ var result = _i_();
+ if (result === null) {
+ debug(' -- failed i', parseInt(number, 10));
+ return result;
+ }
+ result = parseInt(number, 10);
+ debug(' -- passed i ', result);
+ return result;
+ }
+
+ /*
+ * absolute value of the source number (integer and decimals).
+ */
+ function n() {
+ var result = _n_();
+ if (result === null) {
+ debug(' -- failed n ', number);
+ return result;
+ }
+ result = parseFloat(number, 10);
+ debug(' -- passed n ', result);
+ return result;
+ }
+
+ /*
+ * visible fractional digits in n, with trailing zeros.
+ */
+ function f() {
+ var result = _f_();
+ if (result === null) {
+ debug(' -- failed f ', number);
+ return result;
+ }
+ result = (number + '.').split('.')[1] || 0;
+ debug(' -- passed f ', result);
+ return result;
+ }
+
+ /*
+ * visible fractional digits in n, without trailing zeros.
+ */
+ function t() {
+ var result = _t_();
+ if (result === null) {
+ debug(' -- failed t ', number);
+ return result;
+ }
+ result = (number + '.').split('.')[1].replace(/0$/, '') || 0;
+ debug(' -- passed t ', result);
+ return result;
+ }
+
+ /*
+ * number of visible fraction digits in n, with trailing zeros.
+ */
+ function v() {
+ var result = _v_();
+ if (result === null) {
+ debug(' -- failed v ', number);
+ return result;
+ }
+ result = (number + '.').split('.')[1].length || 0;
+ debug(' -- passed v ', result);
+ return result;
+ }
+
+ /*
+ * number of visible fraction digits in n, without trailing zeros.
+ */
+ function w() {
+ var result = _w_();
+ if (result === null) {
+ debug(' -- failed w ', number);
+ return result;
+ }
+ result = (number + '.').split('.')[1].replace(/0$/, '').length || 0;
+ debug(' -- passed w ', result);
+ return result;
+ }
+
+ // operand = 'n' | 'i' | 'f' | 't' | 'v' | 'w'
+ operand = choice([n, i, f, t, v, w]);
+
+ // expr = operand (('mod' | '%') value)?
+ expression = choice([mod, operand]);
+
+ function mod() {
+ var result = sequence([operand, whitespace, choice([_mod_, _percent_]), whitespace, value]);
+ if (result === null) {
+ debug(' -- failed mod');
+ return null;
+ }
+ debug(' -- passed ' + parseInt(result[0], 10) + ' ' + result[2] + ' ' + parseInt(result[4], 10));
+ return parseInt(result[0], 10) % parseInt(result[4], 10);
+ }
+
+ function not() {
+ var result = sequence([whitespace, _not_]);
+ if (result === null) {
+ debug(' -- failed not');
+ return null;
+ }
+
+ return result[1];
+ }
+
+ // is_relation = expr 'is' ('not')? value
+ function is() {
+ var result = sequence([expression, whitespace, choice([_is_]), whitespace, value]);
+ if (result !== null) {
+ debug(' -- passed is : ' + result[0] + ' == ' + parseInt(result[4], 10));
+ return result[0] === parseInt(result[4], 10);
+ }
+ debug(' -- failed is');
+ return null;
+ }
+
+ // is_relation = expr 'is' ('not')? value
+ function isnot() {
+ var result = sequence([expression, whitespace, choice([_isnot_, _isnot_sign_]), whitespace, value]);
+ if (result !== null) {
+ debug(' -- passed isnot: ' + result[0] + ' != ' + parseInt(result[4], 10));
+ return result[0] !== parseInt(result[4], 10);
+ }
+ debug(' -- failed isnot');
+ return null;
+ }
+
+ function not_in() {
+ var result = sequence([expression, whitespace, _isnot_sign_, whitespace, rangeList]);
+ if (result !== null) {
+ debug(' -- passed not_in: ' + result[0] + ' != ' + result[4]);
+ var range_list = result[4];
+ for (var i = 0; i < range_list.length; i++) {
+ if (parseInt(range_list[i], 10) === parseInt(result[0], 10)) {
+ return false;
+ }
+ }
+ return true;
+ }
+ debug(' -- failed not_in');
+ return null;
+ }
+
+ // range_list = (range | value) (',' range_list)*
+ function rangeList() {
+ var result = sequence([choice([range, value]), nOrMore(0, rangeTail)]);
+ var resultList = [];
+ if (result !== null) {
+ resultList = resultList.concat(result[0]);
+ if (result[1][0]) {
+ resultList = resultList.concat(result[1][0]);
+ }
+ return resultList;
+ }
+ debug(' -- failed rangeList');
+ return null;
+ }
+
+ function rangeTail() {
+ // ',' range_list
+ var result = sequence([_comma_, rangeList]);
+ if (result !== null) {
+ return result[1];
+ }
+ debug(' -- failed rangeTail');
+ return null;
+ }
+
+ // range = value'..'value
+
+ function range() {
+ var i;
+ var result = sequence([value, _range_, value]);
+ if (result !== null) {
+ debug(' -- passed range');
+ var array = [];
+ var left = parseInt(result[0], 10);
+ var right = parseInt(result[2], 10);
+ for (i = left; i <= right; i++) {
+ array.push(i);
+ }
+ return array;
+ }
+ debug(' -- failed range');
+ return null;
+ }
+
+ function _in() {
+ // in_relation = expr ('not')? 'in' range_list
+ var result = sequence([expression, nOrMore(0, not), whitespace, choice([_in_, _equal_]), whitespace, rangeList]);
+ if (result !== null) {
+ debug(' -- passed _in:' + result);
+ var range_list = result[5];
+ for (var i = 0; i < range_list.length; i++) {
+ if (parseInt(range_list[i], 10) === parseInt(result[0], 10)) {
+ return (result[1][0] !== 'not');
+ }
+ }
+ return (result[1][0] === 'not');
+ }
+ debug(' -- failed _in ');
+ return null;
+ }
+
+ /*
+ * The difference between in and within is that in only includes integers in the specified range,
+ * while within includes all values.
+ */
+
+ function within() {
+ // within_relation = expr ('not')? 'within' range_list
+ var result = sequence([expression, nOrMore(0, not), whitespace, _within_, whitespace, rangeList]);
+ if (result !== null) {
+ debug(' -- passed within');
+ var range_list = result[5];
+ if ((result[0] >= parseInt(range_list[0], 10)) &&
+ (result[0] < parseInt(range_list[range_list.length - 1], 10))) {
+ return (result[1][0] !== 'not');
+ }
+ return (result[1][0] === 'not');
+ }
+ debug(' -- failed within ');
+ return null;
+ }
+
+ // relation = is_relation | in_relation | within_relation
+ relation = choice([is, not_in, isnot, _in, within]);
+
+ // and_condition = relation ('and' relation)*
+ function and() {
+ var result = sequence([relation, nOrMore(0, andTail)]);
+ if (result) {
+ if (!result[0]) {
+ return false;
+ }
+ for (var i = 0; i < result[1].length; i++) {
+ if (!result[1][i]) {
+ return false;
+ }
+ }
+ return true;
+ }
+ debug(' -- failed and');
+ return null;
+ }
+
+ // ('and' relation)*
+ function andTail() {
+ var result = sequence([whitespace, _and_, whitespace, relation]);
+ if (result !== null) {
+ debug(' -- passed andTail' + result);
+ return result[3];
+ }
+ debug(' -- failed andTail');
+ return null;
+
+ }
+ // ('or' and_condition)*
+ function orTail() {
+ var result = sequence([whitespace, _or_, whitespace, and]);
+ if (result !== null) {
+ debug(' -- passed orTail: ' + result[3]);
+ return result[3];
+ }
+ debug(' -- failed orTail');
+ return null;
+
+ }
+
+ // condition = and_condition ('or' and_condition)*
+ function condition() {
+ var result = sequence([and, nOrMore(0, orTail)]);
+ if (result) {
+ for (var i = 0; i < result[1].length; i++) {
+ if (result[1][i]) {
+ return true;
+ }
+ }
+ return result[0];
+
+ }
+ return false;
+ }
+
+ result = condition();
+ /*
+ * For success, the pos must have gotten to the end of the rule
+ * and returned a non-null.
+ * n.b. This is part of language infrastructure, so we do not throw an internationalizable message.
+ */
+ if (result === null) {
+ throw new Error('Parse error at position ' + pos.toString() + ' for rule: ' + rule);
+ }
+
+ if (pos !== rule.length) {
+ debug('Warning: Rule not parsed completely. Parser stopped at ' + rule.substr(0, pos) + ' for rule: ' + rule);
+ }
+
+ return result;
+}
+
+/* pluralRuleParser ends here */
+mw.libs.pluralRuleParser = pluralRuleParser;
+
+} )( mediaWiki );
diff --git a/resources/src/mediawiki.libs/mediawiki.libs.jpegmeta.js b/resources/src/mediawiki.libs/mediawiki.libs.jpegmeta.js
new file mode 100644
index 00000000..b3ed88c8
--- /dev/null
+++ b/resources/src/mediawiki.libs/mediawiki.libs.jpegmeta.js
@@ -0,0 +1,737 @@
+/**
+ * This is JsJpegMeta v1.0
+ * From: https://code.google.com/p/jsjpegmeta/downloads/list
+ * From: https://github.com/bennoleslie/jsjpegmeta/blob/v1.0.0/jpegmeta.js
+ *
+ * Ported to MediaWiki ResourceLoader by Bryan Tong Minh
+ * Changes:
+ * - Add closure.
+ * - Add this.JpegMeta assignment to expose it as global.
+ * - Add mw.libs.jpegmeta wrapper.
+ */
+
+( function () {
+ /*
+ Copyright (c) 2009 Ben Leslie
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in
+ all copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ THE SOFTWARE.
+ */
+
+ /*
+ This JavaScript library is used to parse meta-data from files
+ with mime-type image/jpeg.
+
+ Include it with something like:
+
+ <script type="text/javascript" src="jpegmeta.js"></script>
+
+ This adds a single 'module' object called 'JpegMeta' to the global
+ namespace.
+
+ Public Functions
+ ----------------
+ JpegMeta.parseNum - parse unsigned integers from binary data
+ JpegMeta.parseSnum - parse signed integers from binary data
+
+ Public Classes
+ --------------
+ JpegMeta.Rational - A rational number class
+ JpegMeta.JfifSegment
+ JpegMeta.ExifSegment
+ JpegMeta.JpegFile - Primary class for Javascript parsing
+ */
+
+ var JpegMeta = {};
+ // MediaWiki: Expose as global
+ this.JpegMeta = JpegMeta;
+
+ /*
+ parse an unsigned number of size bytes at offset in some binary string data.
+ If endian
+ is "<" parse the data as little endian, if endian
+ is ">" parse as big-endian.
+ */
+ JpegMeta.parseNum = function parseNum(endian, data, offset, size) {
+ var i;
+ var ret;
+ var big_endian = (endian === ">");
+ if (offset === undefined) offset = 0;
+ if (size === undefined) size = data.length - offset;
+ for (big_endian ? i = offset : i = offset + size - 1;
+ big_endian ? i < offset + size : i >= offset;
+ big_endian ? i++ : i--) {
+ ret <<= 8;
+ ret += data.charCodeAt(i);
+ }
+ return ret;
+ };
+
+ /*
+ parse an signed number of size bytes at offset in some binary string data.
+ If endian
+ is "<" parse the data as little endian, if endian
+ is ">" parse as big-endian.
+ */
+ JpegMeta.parseSnum = function parseSnum(endian, data, offset, size) {
+ var i;
+ var ret;
+ var neg;
+ var big_endian = (endian === ">");
+ if (offset === undefined) offset = 0;
+ if (size === undefined) size = data.length - offset;
+ for (big_endian ? i = offset : i = offset + size - 1;
+ big_endian ? i < offset + size : i >= offset;
+ big_endian ? i++ : i--) {
+ if (neg === undefined) {
+ /* Negative if top bit is set */
+ neg = (data.charCodeAt(i) & 0x80) === 0x80;
+ }
+ ret <<= 8;
+ /* If it is negative we invert the bits */
+ ret += neg ? ~data.charCodeAt(i) & 0xff: data.charCodeAt(i);
+ }
+ if (neg) {
+ /* If it is negative we do two's complement */
+ ret += 1;
+ ret *= -1;
+ }
+ return ret;
+ };
+
+ /* Rational number class */
+ JpegMeta.Rational = function Rational(num, den)
+ {
+ this.num = num;
+ this.den = den || 1;
+ return this;
+ };
+
+ /* Rational number methods */
+ JpegMeta.Rational.prototype.toString = function toString() {
+ if (this.num === 0) {
+ return "" + this.num;
+ }
+ if (this.den === 1) {
+ return "" + this.num;
+ }
+ if (this.num === 1) {
+ return this.num + " / " + this.den;
+ }
+ return this.num / this.den; // + "/" + this.den;
+ };
+
+ JpegMeta.Rational.prototype.asFloat = function asFloat() {
+ return this.num / this.den;
+ };
+
+ /* MetaGroup class */
+ JpegMeta.MetaGroup = function MetaGroup(fieldName, description) {
+ this.fieldName = fieldName;
+ this.description = description;
+ this.metaProps = {};
+ return this;
+ };
+
+ JpegMeta.MetaGroup.prototype._addProperty = function _addProperty(fieldName, description, value) {
+ var property = new JpegMeta.MetaProp(fieldName, description, value);
+ this[property.fieldName] = property;
+ this.metaProps[property.fieldName] = property;
+ };
+
+ JpegMeta.MetaGroup.prototype.toString = function toString() {
+ return "[MetaGroup " + this.description + "]";
+ };
+
+ /* MetaProp class */
+ JpegMeta.MetaProp = function MetaProp(fieldName, description, value) {
+ this.fieldName = fieldName;
+ this.description = description;
+ this.value = value;
+ return this;
+ };
+
+ JpegMeta.MetaProp.prototype.toString = function toString() {
+ return "" + this.value;
+ };
+
+ /* JpegFile class */
+ JpegMeta.JpegFile = function JpegFile(binary_data, filename) {
+ /* Change this to EOI if we want to parse. */
+ var break_segment = this._SOS;
+
+ this.metaGroups = {};
+ this._binary_data = binary_data;
+ this.filename = filename;
+
+ /* Go through and parse. */
+ var pos = 0;
+ var pos_start_of_segment = 0;
+ var delim;
+ var mark;
+ var _mark;
+ var segsize;
+ var headersize;
+ var mark_code;
+ var mark_fn;
+
+ /* Check to see if this looks like a JPEG file */
+ if (this._binary_data.slice(0, 2) !== this._SOI_MARKER) {
+ throw new Error("Doesn't look like a JPEG file. First two bytes are " +
+ this._binary_data.charCodeAt(0) + "," +
+ this._binary_data.charCodeAt(1) + ".");
+ }
+
+ pos += 2;
+
+ while (pos < this._binary_data.length) {
+ delim = this._binary_data.charCodeAt(pos++);
+ mark = this._binary_data.charCodeAt(pos++);
+
+ pos_start_of_segment = pos;
+
+ if (delim != this._DELIM) {
+ break;
+ }
+
+ if (mark === break_segment) {
+ break;
+ }
+
+ headersize = JpegMeta.parseNum(">", this._binary_data, pos, 2);
+
+ /* Find the end */
+ pos += headersize;
+ while (pos < this._binary_data.length) {
+ delim = this._binary_data.charCodeAt(pos++);
+ if (delim == this._DELIM) {
+ _mark = this._binary_data.charCodeAt(pos++);
+ if (_mark != 0x0) {
+ pos -= 2;
+ break;
+ }
+ }
+ }
+
+ segsize = pos - pos_start_of_segment;
+
+ if (this._markers[mark]) {
+ mark_code = this._markers[mark][0];
+ mark_fn = this._markers[mark][1];
+ } else {
+ mark_code = "UNKN";
+ mark_fn = undefined;
+ }
+
+ if (mark_fn) {
+ this[mark_fn](mark, pos_start_of_segment + 2);
+ }
+
+ }
+
+ if (this.general === undefined) {
+ throw Error("Invalid JPEG file.");
+ }
+
+ return this;
+ };
+
+ this.JpegMeta.JpegFile.prototype.toString = function () {
+ return "[JpegFile " + this.filename + " " +
+ this.general.type + " " +
+ this.general.pixelWidth + "x" +
+ this.general.pixelHeight +
+ " Depth: " + this.general.depth + "]";
+ };
+
+ /* Some useful constants */
+ this.JpegMeta.JpegFile.prototype._SOI_MARKER = '\xff\xd8';
+ this.JpegMeta.JpegFile.prototype._DELIM = 0xff;
+ this.JpegMeta.JpegFile.prototype._EOI = 0xd9;
+ this.JpegMeta.JpegFile.prototype._SOS = 0xda;
+
+ this.JpegMeta.JpegFile.prototype._sofHandler = function _sofHandler (mark, pos) {
+ if (this.general !== undefined) {
+ throw Error("Unexpected multiple-frame image");
+ }
+
+ this._addMetaGroup("general", "General");
+ this.general._addProperty("depth", "Depth", JpegMeta.parseNum(">", this._binary_data, pos, 1));
+ this.general._addProperty("pixelHeight", "Pixel Height", JpegMeta.parseNum(">", this._binary_data, pos + 1, 2));
+ this.general._addProperty("pixelWidth", "Pixel Width",JpegMeta.parseNum(">", this._binary_data, pos + 3, 2));
+ this.general._addProperty("type", "Type", this._markers[mark][2]);
+ };
+
+ /* JFIF idents */
+ this.JpegMeta.JpegFile.prototype._JFIF_IDENT = "JFIF\x00";
+ this.JpegMeta.JpegFile.prototype._JFXX_IDENT = "JFXX\x00";
+
+ /* Exif idents */
+ this.JpegMeta.JpegFile.prototype._EXIF_IDENT = "Exif\x00";
+
+ /* TIFF types */
+ this.JpegMeta.JpegFile.prototype._types = {
+ /* The format is identifier : ["type name", type_size_in_bytes ] */
+ 1 : ["BYTE", 1],
+ 2 : ["ASCII", 1],
+ 3 : ["SHORT", 2],
+ 4 : ["LONG", 4],
+ 5 : ["RATIONAL", 8],
+ 6 : ["SBYTE", 1],
+ 7 : ["UNDEFINED", 1],
+ 8 : ["SSHORT", 2],
+ 9 : ["SLONG", 4],
+ 10 : ["SRATIONAL", 8],
+ 11 : ["FLOAT", 4],
+ 12 : ["DOUBLE", 8]
+ };
+
+ this.JpegMeta.JpegFile.prototype._tifftags = {
+ /* A. Tags relating to image data structure */
+ 256 : ["Image width", "ImageWidth"],
+ 257 : ["Image height", "ImageLength"],
+ 258 : ["Number of bits per component", "BitsPerSample"],
+ 259 : ["Compression scheme", "Compression",
+ {1 : "uncompressed", 6 : "JPEG compression" }],
+ 262 : ["Pixel composition", "PhotmetricInerpretation",
+ {2 : "RGB", 6 : "YCbCr"}],
+ 274 : ["Orientation of image", "Orientation",
+ /* FIXME: Check the mirror-image / reverse encoding and rotation */
+ {1 : "Normal", 2 : "Reverse?",
+ 3 : "Upside-down", 4 : "Upside-down Reverse",
+ 5 : "90 degree CW", 6 : "90 degree CW reverse",
+ 7 : "90 degree CCW", 8 : "90 degree CCW reverse"}],
+ 277 : ["Number of components", "SamplesPerPixel"],
+ 284 : ["Image data arrangement", "PlanarConfiguration",
+ {1 : "chunky format", 2 : "planar format"}],
+ 530 : ["Subsampling ratio of Y to C", "YCbCrSubSampling"],
+ 531 : ["Y and C positioning", "YCbCrPositioning",
+ {1 : "centered", 2 : "co-sited"}],
+ 282 : ["X Resolution", "XResolution"],
+ 283 : ["Y Resolution", "YResolution"],
+ 296 : ["Resolution Unit", "ResolutionUnit",
+ {2 : "inches", 3 : "centimeters"}],
+ /* B. Tags realting to recording offset */
+ 273 : ["Image data location", "StripOffsets"],
+ 278 : ["Number of rows per strip", "RowsPerStrip"],
+ 279 : ["Bytes per compressed strip", "StripByteCounts"],
+ 513 : ["Offset to JPEG SOI", "JPEGInterchangeFormat"],
+ 514 : ["Bytes of JPEG Data", "JPEGInterchangeFormatLength"],
+ /* C. Tags relating to image data characteristics */
+ 301 : ["Transfer function", "TransferFunction"],
+ 318 : ["White point chromaticity", "WhitePoint"],
+ 319 : ["Chromaticities of primaries", "PrimaryChromaticities"],
+ 529 : ["Color space transformation matrix coefficients", "YCbCrCoefficients"],
+ 532 : ["Pair of black and white reference values", "ReferenceBlackWhite"],
+ /* D. Other tags */
+ 306 : ["Date and time", "DateTime"],
+ 270 : ["Image title", "ImageDescription"],
+ 271 : ["Make", "Make"],
+ 272 : ["Model", "Model"],
+ 305 : ["Software", "Software"],
+ 315 : ["Person who created the image", "Artist"],
+ 316 : ["Host Computer", "HostComputer"],
+ 33432 : ["Copyright holder", "Copyright"],
+
+ 34665 : ["Exif tag", "ExifIfdPointer"],
+ 34853 : ["GPS tag", "GPSInfoIfdPointer"]
+ };
+
+ this.JpegMeta.JpegFile.prototype._exiftags = {
+ /* Tag Support Levels (2) - 0th IFX Exif Private Tags */
+ /* A. Tags Relating to Version */
+ 36864 : ["Exif Version", "ExifVersion"],
+ 40960 : ["FlashPix Version", "FlashpixVersion"],
+
+ /* B. Tag Relating to Image Data Characteristics */
+ 40961 : ["Color Space", "ColorSpace"],
+
+ /* C. Tags Relating to Image Configuration */
+ 37121 : ["Meaning of each component", "ComponentsConfiguration"],
+ 37122 : ["Compressed Bits Per Pixel", "CompressedBitsPerPixel"],
+ 40962 : ["Pixel X Dimension", "PixelXDimension"],
+ 40963 : ["Pixel Y Dimension", "PixelYDimension"],
+
+ /* D. Tags Relating to User Information */
+ 37500 : ["Manufacturer notes", "MakerNote"],
+ 37510 : ["User comments", "UserComment"],
+
+ /* E. Tag Relating to Related File Information */
+ 40964 : ["Related audio file", "RelatedSoundFile"],
+
+ /* F. Tags Relating to Date and Time */
+ 36867 : ["Date Time Original", "DateTimeOriginal"],
+ 36868 : ["Date Time Digitized", "DateTimeDigitized"],
+ 37520 : ["DateTime subseconds", "SubSecTime"],
+ 37521 : ["DateTimeOriginal subseconds", "SubSecTimeOriginal"],
+ 37522 : ["DateTimeDigitized subseconds", "SubSecTimeDigitized"],
+
+ /* G. Tags Relating to Picture-Taking Conditions */
+ 33434 : ["Exposure time", "ExposureTime"],
+ 33437 : ["FNumber", "FNumber"],
+ 34850 : ["Exposure program", "ExposureProgram"],
+ 34852 : ["Spectral sensitivity", "SpectralSensitivity"],
+ 34855 : ["ISO Speed Ratings", "ISOSpeedRatings"],
+ 34856 : ["Optoelectric coefficient", "OECF"],
+ 37377 : ["Shutter Speed", "ShutterSpeedValue"],
+ 37378 : ["Aperture Value", "ApertureValue"],
+ 37379 : ["Brightness", "BrightnessValue"],
+ 37380 : ["Exposure Bias Value", "ExposureBiasValue"],
+ 37381 : ["Max Aperture Value", "MaxApertureValue"],
+ 37382 : ["Subject Distance", "SubjectDistance"],
+ 37383 : ["Metering Mode", "MeteringMode"],
+ 37384 : ["Light Source", "LightSource"],
+ 37385 : ["Flash", "Flash"],
+ 37386 : ["Focal Length", "FocalLength"],
+ 37396 : ["Subject Area", "SubjectArea"],
+ 41483 : ["Flash Energy", "FlashEnergy"],
+ 41484 : ["Spatial Frequency Response", "SpatialFrequencyResponse"],
+ 41486 : ["Focal Plane X Resolution", "FocalPlaneXResolution"],
+ 41487 : ["Focal Plane Y Resolution", "FocalPlaneYResolution"],
+ 41488 : ["Focal Plane Resolution Unit", "FocalPlaneResolutionUnit"],
+ 41492 : ["Subject Location", "SubjectLocation"],
+ 41493 : ["Exposure Index", "ExposureIndex"],
+ 41495 : ["Sensing Method", "SensingMethod"],
+ 41728 : ["File Source", "FileSource"],
+ 41729 : ["Scene Type", "SceneType"],
+ 41730 : ["CFA Pattern", "CFAPattern"],
+ 41985 : ["Custom Rendered", "CustomRendered"],
+ 41986 : ["Exposure Mode", "Exposure Mode"],
+ 41987 : ["White Balance", "WhiteBalance"],
+ 41988 : ["Digital Zoom Ratio", "DigitalZoomRatio"],
+ 41990 : ["Scene Capture Type", "SceneCaptureType"],
+ 41991 : ["Gain Control", "GainControl"],
+ 41992 : ["Contrast", "Contrast"],
+ 41993 : ["Saturation", "Saturation"],
+ 41994 : ["Sharpness", "Sharpness"],
+ 41995 : ["Device settings description", "DeviceSettingDescription"],
+ 41996 : ["Subject distance range", "SubjectDistanceRange"],
+
+ /* H. Other Tags */
+ 42016 : ["Unique image ID", "ImageUniqueID"],
+
+ 40965 : ["Interoperability tag", "InteroperabilityIFDPointer"]
+ };
+
+ this.JpegMeta.JpegFile.prototype._gpstags = {
+ /* A. Tags Relating to GPS */
+ 0 : ["GPS tag version", "GPSVersionID"],
+ 1 : ["North or South Latitude", "GPSLatitudeRef"],
+ 2 : ["Latitude", "GPSLatitude"],
+ 3 : ["East or West Longitude", "GPSLongitudeRef"],
+ 4 : ["Longitude", "GPSLongitude"],
+ 5 : ["Altitude reference", "GPSAltitudeRef"],
+ 6 : ["Altitude", "GPSAltitude"],
+ 7 : ["GPS time (atomic clock)", "GPSTimeStamp"],
+ 8 : ["GPS satellites usedd for measurement", "GPSSatellites"],
+ 9 : ["GPS receiver status", "GPSStatus"],
+ 10 : ["GPS mesaurement mode", "GPSMeasureMode"],
+ 11 : ["Measurement precision", "GPSDOP"],
+ 12 : ["Speed unit", "GPSSpeedRef"],
+ 13 : ["Speed of GPS receiver", "GPSSpeed"],
+ 14 : ["Reference for direction of movement", "GPSTrackRef"],
+ 15 : ["Direction of movement", "GPSTrack"],
+ 16 : ["Reference for direction of image", "GPSImgDirectionRef"],
+ 17 : ["Direction of image", "GPSImgDirection"],
+ 18 : ["Geodetic survey data used", "GPSMapDatum"],
+ 19 : ["Reference for latitude of destination", "GPSDestLatitudeRef"],
+ 20 : ["Latitude of destination", "GPSDestLatitude"],
+ 21 : ["Reference for longitude of destination", "GPSDestLongitudeRef"],
+ 22 : ["Longitude of destination", "GPSDestLongitude"],
+ 23 : ["Reference for bearing of destination", "GPSDestBearingRef"],
+ 24 : ["Bearing of destination", "GPSDestBearing"],
+ 25 : ["Reference for distance to destination", "GPSDestDistanceRef"],
+ 26 : ["Distance to destination", "GPSDestDistance"],
+ 27 : ["Name of GPS processing method", "GPSProcessingMethod"],
+ 28 : ["Name of GPS area", "GPSAreaInformation"],
+ 29 : ["GPS Date", "GPSDateStamp"],
+ 30 : ["GPS differential correction", "GPSDifferential"]
+ };
+
+ this.JpegMeta.JpegFile.prototype._markers = {
+ /* Start Of Frame markers, non-differential, Huffman coding */
+ 0xc0: ["SOF0", "_sofHandler", "Baseline DCT"],
+ 0xc1: ["SOF1", "_sofHandler", "Extended sequential DCT"],
+ 0xc2: ["SOF2", "_sofHandler", "Progressive DCT"],
+ 0xc3: ["SOF3", "_sofHandler", "Lossless (sequential)"],
+
+ /* Start Of Frame markers, differential, Huffman coding */
+ 0xc5: ["SOF5", "_sofHandler", "Differential sequential DCT"],
+ 0xc6: ["SOF6", "_sofHandler", "Differential progressive DCT"],
+ 0xc7: ["SOF7", "_sofHandler", "Differential lossless (sequential)"],
+
+ /* Start Of Frame markers, non-differential, arithmetic coding */
+ 0xc8: ["JPG", null, "Reserved for JPEG extensions"],
+ 0xc9: ["SOF9", "_sofHandler", "Extended sequential DCT"],
+ 0xca: ["SOF10", "_sofHandler", "Progressive DCT"],
+ 0xcb: ["SOF11", "_sofHandler", "Lossless (sequential)"],
+
+ /* Start Of Frame markers, differential, arithmetic coding */
+ 0xcd: ["SOF13", "_sofHandler", "Differential sequential DCT"],
+ 0xce: ["SOF14", "_sofHandler", "Differential progressive DCT"],
+ 0xcf: ["SOF15", "_sofHandler", "Differential lossless (sequential)"],
+
+ /* Huffman table specification */
+ 0xc4: ["DHT", null, "Define Huffman table(s)"],
+ 0xcc: ["DAC", null, "Define arithmetic coding conditioning(s)"],
+
+ /* Restart interval termination" */
+ 0xd0: ["RST0", null, "Restart with modulo 8 count “0”"],
+ 0xd1: ["RST1", null, "Restart with modulo 8 count “1”"],
+ 0xd2: ["RST2", null, "Restart with modulo 8 count “2”"],
+ 0xd3: ["RST3", null, "Restart with modulo 8 count “3”"],
+ 0xd4: ["RST4", null, "Restart with modulo 8 count “4”"],
+ 0xd5: ["RST5", null, "Restart with modulo 8 count “5”"],
+ 0xd6: ["RST6", null, "Restart with modulo 8 count “6”"],
+ 0xd7: ["RST7", null, "Restart with modulo 8 count “7”"],
+
+ /* Other markers */
+ 0xd8: ["SOI", null, "Start of image"],
+ 0xd9: ["EOI", null, "End of image"],
+ 0xda: ["SOS", null, "Start of scan"],
+ 0xdb: ["DQT", null, "Define quantization table(s)"],
+ 0xdc: ["DNL", null, "Define number of lines"],
+ 0xdd: ["DRI", null, "Define restart interval"],
+ 0xde: ["DHP", null, "Define hierarchical progression"],
+ 0xdf: ["EXP", null, "Expand reference component(s)"],
+ 0xe0: ["APP0", "_app0Handler", "Reserved for application segments"],
+ 0xe1: ["APP1", "_app1Handler"],
+ 0xe2: ["APP2", null],
+ 0xe3: ["APP3", null],
+ 0xe4: ["APP4", null],
+ 0xe5: ["APP5", null],
+ 0xe6: ["APP6", null],
+ 0xe7: ["APP7", null],
+ 0xe8: ["APP8", null],
+ 0xe9: ["APP9", null],
+ 0xea: ["APP10", null],
+ 0xeb: ["APP11", null],
+ 0xec: ["APP12", null],
+ 0xed: ["APP13", null],
+ 0xee: ["APP14", null],
+ 0xef: ["APP15", null],
+ 0xf0: ["JPG0", null], /* Reserved for JPEG extensions */
+ 0xf1: ["JPG1", null],
+ 0xf2: ["JPG2", null],
+ 0xf3: ["JPG3", null],
+ 0xf4: ["JPG4", null],
+ 0xf5: ["JPG5", null],
+ 0xf6: ["JPG6", null],
+ 0xf7: ["JPG7", null],
+ 0xf8: ["JPG8", null],
+ 0xf9: ["JPG9", null],
+ 0xfa: ["JPG10", null],
+ 0xfb: ["JPG11", null],
+ 0xfc: ["JPG12", null],
+ 0xfd: ["JPG13", null],
+ 0xfe: ["COM", null], /* Comment */
+
+ /* Reserved markers */
+ 0x01: ["JPG13", null] /* For temporary private use in arithmetic coding */
+ /* 02 -> bf are reserverd */
+ };
+
+ /* Private methods */
+ this.JpegMeta.JpegFile.prototype._addMetaGroup = function _addMetaGroup(name, description) {
+ var group = new JpegMeta.MetaGroup(name, description);
+ this[group.fieldName] = group;
+ this.metaGroups[group.fieldName] = group;
+ return group;
+ };
+
+ this.JpegMeta.JpegFile.prototype._parseIfd = function _parseIfd(endian, _binary_data, base, ifd_offset, tags, name, description) {
+ var num_fields = JpegMeta.parseNum(endian, _binary_data, base + ifd_offset, 2);
+ /* Per tag variables */
+ var i, j;
+ var tag_base;
+ var tag_field;
+ var type, type_field, type_size;
+ var num_values;
+ var value_offset;
+ var value;
+ var _val;
+ var num;
+ var den;
+
+ var group;
+
+ group = this._addMetaGroup(name, description);
+
+ for (var i = 0; i < num_fields; i++) {
+ /* parse the field */
+ tag_base = base + ifd_offset + 2 + (i * 12);
+ tag_field = JpegMeta.parseNum(endian, _binary_data, tag_base, 2);
+ type_field = JpegMeta.parseNum(endian, _binary_data, tag_base + 2, 2);
+ num_values = JpegMeta.parseNum(endian, _binary_data, tag_base + 4, 4);
+ value_offset = JpegMeta.parseNum(endian, _binary_data, tag_base + 8, 4);
+ if (this._types[type_field] === undefined) {
+ continue;
+ }
+ type = this._types[type_field][0];
+ type_size = this._types[type_field][1];
+
+ if (type_size * num_values <= 4) {
+ /* Data is in-line */
+ value_offset = tag_base + 8;
+ } else {
+ value_offset = base + value_offset;
+ }
+
+ /* Read the value */
+ if (type == "UNDEFINED") {
+ value = _binary_data.slice(value_offset, value_offset + num_values);
+ } else if (type == "ASCII") {
+ value = _binary_data.slice(value_offset, value_offset + num_values);
+ value = value.split('\x00')[0];
+ /* strip trail nul */
+ } else {
+ value = new Array();
+ for (j = 0; j < num_values; j++, value_offset += type_size) {
+ if (type == "BYTE" || type == "SHORT" || type == "LONG") {
+ value.push(JpegMeta.parseNum(endian, _binary_data, value_offset, type_size));
+ }
+ if (type == "SBYTE" || type == "SSHORT" || type == "SLONG") {
+ value.push(JpegMeta.parseSnum(endian, _binary_data, value_offset, type_size));
+ }
+ if (type == "RATIONAL") {
+ num = JpegMeta.parseNum(endian, _binary_data, value_offset, 4);
+ den = JpegMeta.parseNum(endian, _binary_data, value_offset + 4, 4);
+ value.push(new JpegMeta.Rational(num, den));
+ }
+ if (type == "SRATIONAL") {
+ num = JpegMeta.parseSnum(endian, _binary_data, value_offset, 4);
+ den = JpegMeta.parseSnum(endian, _binary_data, value_offset + 4, 4);
+ value.push(new JpegMeta.Rational(num, den));
+ }
+ value.push();
+ }
+ if (num_values === 1) {
+ value = value[0];
+ }
+ }
+ if (tags[tag_field] !== undefined) {
+ group._addProperty(tags[tag_field][1], tags[tag_field][0], value);
+ }
+ }
+ };
+
+ this.JpegMeta.JpegFile.prototype._jfifHandler = function _jfifHandler(mark, pos) {
+ if (this.jfif !== undefined) {
+ throw Error("Multiple JFIF segments found");
+ }
+ this._addMetaGroup("jfif", "JFIF");
+ this.jfif._addProperty("version_major", "Version Major", this._binary_data.charCodeAt(pos + 5));
+ this.jfif._addProperty("version_minor", "Version Minor", this._binary_data.charCodeAt(pos + 6));
+ this.jfif._addProperty("version", "JFIF Version", this.jfif.version_major.value + "." + this.jfif.version_minor.value);
+ this.jfif._addProperty("units", "Density Unit", this._binary_data.charCodeAt(pos + 7));
+ this.jfif._addProperty("Xdensity", "X density", JpegMeta.parseNum(">", this._binary_data, pos + 8, 2));
+ this.jfif._addProperty("Ydensity", "Y Density", JpegMeta.parseNum(">", this._binary_data, pos + 10, 2));
+ this.jfif._addProperty("Xthumbnail", "X Thumbnail", JpegMeta.parseNum(">", this._binary_data, pos + 12, 1));
+ this.jfif._addProperty("Ythumbnail", "Y Thumbnail", JpegMeta.parseNum(">", this._binary_data, pos + 13, 1));
+ };
+
+ /* Handle app0 segments */
+ this.JpegMeta.JpegFile.prototype._app0Handler = function app0Handler(mark, pos) {
+ var ident = this._binary_data.slice(pos, pos + 5);
+ if (ident == this._JFIF_IDENT) {
+ this._jfifHandler(mark, pos);
+ } else if (ident == this._JFXX_IDENT) {
+ /* Don't handle JFXX Ident yet */
+ } else {
+ /* Don't know about other idents */
+ }
+ };
+
+ /* Handle app1 segments */
+ this.JpegMeta.JpegFile.prototype._app1Handler = function _app1Handler(mark, pos) {
+ var ident = this._binary_data.slice(pos, pos + 5);
+ if (ident == this._EXIF_IDENT) {
+ this._exifHandler(mark, pos + 6);
+ } else {
+ /* Don't know about other idents */
+ }
+ };
+
+ /* Handle exif segments */
+ JpegMeta.JpegFile.prototype._exifHandler = function _exifHandler(mark, pos) {
+ if (this.exif !== undefined) {
+ throw new Error("Multiple JFIF segments found");
+ }
+
+ /* Parse this TIFF header */
+ var endian;
+ var magic_field;
+ var ifd_offset;
+ var primary_ifd, exif_ifd, gps_ifd;
+ var endian_field = this._binary_data.slice(pos, pos + 2);
+
+ /* Trivia: This 'I' is for Intel, the 'M' is for Motorola */
+ if (endian_field === "II") {
+ endian = "<";
+ } else if (endian_field === "MM") {
+ endian = ">";
+ } else {
+ throw new Error("Malformed TIFF meta-data. Unknown endianess: " + endian_field);
+ }
+
+ magic_field = JpegMeta.parseNum(endian, this._binary_data, pos + 2, 2);
+
+ if (magic_field !== 42) {
+ throw new Error("Malformed TIFF meta-data. Bad magic: " + magic_field);
+ }
+
+ ifd_offset = JpegMeta.parseNum(endian, this._binary_data, pos + 4, 4);
+
+ /* Parse 0th IFD */
+ this._parseIfd(endian, this._binary_data, pos, ifd_offset, this._tifftags, "tiff", "TIFF");
+
+ if (this.tiff.ExifIfdPointer) {
+ this._parseIfd(endian, this._binary_data, pos, this.tiff.ExifIfdPointer.value, this._exiftags, "exif", "Exif");
+ }
+
+ if (this.tiff.GPSInfoIfdPointer) {
+ this._parseIfd(endian, this._binary_data, pos, this.tiff.GPSInfoIfdPointer.value, this._gpstags, "gps", "GPS");
+ if (this.gps.GPSLatitude) {
+ var latitude;
+ latitude = this.gps.GPSLatitude.value[0].asFloat() +
+ (1 / 60) * this.gps.GPSLatitude.value[1].asFloat() +
+ (1 / 3600) * this.gps.GPSLatitude.value[2].asFloat();
+ if (this.gps.GPSLatitudeRef.value === "S") {
+ latitude = -latitude;
+ }
+ this.gps._addProperty("latitude", "Dec. Latitude", latitude);
+ }
+ if (this.gps.GPSLongitude) {
+ var longitude;
+ longitude = this.gps.GPSLongitude.value[0].asFloat() +
+ (1 / 60) * this.gps.GPSLongitude.value[1].asFloat() +
+ (1 / 3600) * this.gps.GPSLongitude.value[2].asFloat();
+ if (this.gps.GPSLongitudeRef.value === "W") {
+ longitude = -longitude;
+ }
+ this.gps._addProperty("longitude", "Dec. Longitude", longitude);
+ }
+ }
+ };
+
+ // MediaWiki: Add mw.libs wrapper
+ mw.libs.jpegmeta = function( fileReaderResult, fileName ) {
+ return new JpegMeta.JpegFile( fileReaderResult, fileName );
+ };
+
+}() );
diff --git a/resources/src/mediawiki.page/mediawiki.page.gallery.js b/resources/src/mediawiki.page/mediawiki.page.gallery.js
new file mode 100644
index 00000000..1892967a
--- /dev/null
+++ b/resources/src/mediawiki.page/mediawiki.page.gallery.js
@@ -0,0 +1,212 @@
+/*!
+ * Show gallery captions when focused. Copied directly from jquery.mw-jump.js.
+ * Also Dynamically resize images to justify them.
+ */
+( function ( $ ) {
+ $( function () {
+ var isTouchScreen,
+ gettingFocus,
+ galleries = 'ul.mw-gallery-packed-overlay, ul.mw-gallery-packed-hover, ul.mw-gallery-packed';
+
+ // Is there a better way to detect a touchscreen? Current check taken from stack overflow.
+ isTouchScreen = !!( window.ontouchstart !== undefined || window.DocumentTouch !== undefined && document instanceof window.DocumentTouch );
+
+ if ( isTouchScreen ) {
+ // Always show the caption for a touch screen.
+ $( 'ul.mw-gallery-packed-hover' )
+ .addClass( 'mw-gallery-packed-overlay' )
+ .removeClass( 'mw-gallery-packed-hover' );
+ } else {
+ // Note use of just "a", not a.image, since we want this to trigger if a link in
+ // the caption receives focus
+ $( 'ul.mw-gallery-packed-hover li.gallerybox' ).on( 'focus blur', 'a', function ( e ) {
+ // Confusingly jQuery leaves e.type as focusout for delegated blur events
+ gettingFocus = e.type !== 'blur' && e.type !== 'focusout';
+ $( this ).closest( 'li.gallerybox' ).toggleClass( 'mw-gallery-focused', gettingFocus );
+ } );
+ }
+
+ // Now on to justification.
+ // We may still get ragged edges if someone resizes their window. Could bind to
+ // that event, otoh do we really want to constantly be resizing galleries?
+ $( galleries ).each( function () {
+ var lastTop,
+ $img,
+ imgWidth,
+ imgHeight,
+ rows = [],
+ $gallery = $( this );
+
+ $gallery.children( 'li' ).each( function () {
+ // Math.floor to be paranoid if things are off by 0.00000000001
+ var top = Math.floor( $( this ).position().top ),
+ $this = $( this );
+
+ if ( top !== lastTop ) {
+ rows[rows.length] = [];
+ lastTop = top;
+ }
+
+ $img = $this.find( 'div.thumb a.image img' );
+ if ( $img.length && $img[0].height ) {
+ imgHeight = $img[0].height;
+ imgWidth = $img[0].width;
+ } else {
+ // If we don't have a real image, get the containing divs width/height.
+ // Note that if we do have a real image, using this method will generally
+ // give the same answer, but can be different in the case of a very
+ // narrow image where extra padding is added.
+ imgHeight = $this.children().children( 'div:first' ).height();
+ imgWidth = $this.children().children( 'div:first' ).width();
+ }
+
+ // Hack to make an edge case work ok
+ if ( imgHeight < 30 ) {
+ // Don't try and resize this item.
+ imgHeight = 0;
+ }
+
+ rows[rows.length - 1][rows[rows.length - 1].length] = {
+ $elm: $this,
+ width: $this.outerWidth(),
+ imgWidth: imgWidth,
+ // XXX: can divide by 0 ever happen?
+ aspect: imgWidth / imgHeight,
+ captionWidth: $this.children().children( 'div.gallerytextwrapper' ).width(),
+ height: imgHeight
+ };
+ } );
+
+ ( function () {
+ var maxWidth,
+ combinedAspect,
+ combinedPadding,
+ curRow,
+ curRowHeight,
+ wantedWidth,
+ preferredHeight,
+ newWidth,
+ padding,
+ $outerDiv,
+ $innerDiv,
+ $imageDiv,
+ $imageElm,
+ imageElm,
+ $caption,
+ i,
+ j,
+ avgZoom,
+ totalZoom = 0;
+
+ for ( i = 0; i < rows.length; i++ ) {
+ maxWidth = $gallery.width();
+ combinedAspect = 0;
+ combinedPadding = 0;
+ curRow = rows[i];
+ curRowHeight = 0;
+
+ for ( j = 0; j < curRow.length; j++ ) {
+ if ( curRowHeight === 0 ) {
+ if ( isFinite( curRow[j].height ) ) {
+ // Get the height of this row, by taking the first
+ // non-out of bounds height
+ curRowHeight = curRow[j].height;
+ }
+ }
+
+ if ( curRow[j].aspect === 0 || !isFinite( curRow[j].aspect ) ) {
+ // One of the dimensions are 0. Probably should
+ // not try to resize.
+ combinedPadding += curRow[j].width;
+ } else {
+ combinedAspect += curRow[j].aspect;
+ combinedPadding += curRow[j].width - curRow[j].imgWidth;
+ }
+ }
+
+ // Add some padding for inter-element spacing.
+ combinedPadding += 5 * curRow.length;
+ wantedWidth = maxWidth - combinedPadding;
+ preferredHeight = wantedWidth / combinedAspect;
+
+ if ( preferredHeight > curRowHeight * 1.5 ) {
+ // Only expand at most 1.5 times current size
+ // As that's as high a resolution as we have.
+ // Also on the off chance there is a bug in this
+ // code, would prevent accidentally expanding to
+ // be 10 billion pixels wide.
+ if ( i === rows.length - 1 ) {
+ // If its the last row, and we can't fit it,
+ // don't make the entire row huge.
+ avgZoom = ( totalZoom / ( rows.length - 1 ) ) * curRowHeight;
+ if ( isFinite( avgZoom ) && avgZoom >= 1 && avgZoom <= 1.5 ) {
+ preferredHeight = avgZoom;
+ } else {
+ // Probably a single row gallery
+ preferredHeight = curRowHeight;
+ }
+ } else {
+ preferredHeight = 1.5 * curRowHeight;
+ }
+ }
+ if ( !isFinite( preferredHeight ) ) {
+ // This *definitely* should not happen.
+ // Skip this row.
+ continue;
+ }
+ if ( preferredHeight < 5 ) {
+ // Well something clearly went wrong...
+ // Skip this row.
+ continue;
+ }
+
+ if ( preferredHeight / curRowHeight > 1 ) {
+ totalZoom += preferredHeight / curRowHeight;
+ } else {
+ // If we shrink, still consider that a zoom of 1
+ totalZoom += 1;
+ }
+
+ for ( j = 0; j < curRow.length; j++ ) {
+ newWidth = preferredHeight * curRow[j].aspect;
+ padding = curRow[j].width - curRow[j].imgWidth;
+ $outerDiv = curRow[j].$elm;
+ $innerDiv = $outerDiv.children( 'div' ).first();
+ $imageDiv = $innerDiv.children( 'div.thumb' );
+ $imageElm = $imageDiv.find( 'img' ).first();
+ imageElm = $imageElm.length ? $imageElm[0] : null;
+ $caption = $outerDiv.find( 'div.gallerytextwrapper' );
+
+ // Since we are going to re-adjust the height, the vertical
+ // centering margins need to be reset.
+ $imageDiv.children( 'div' ).css( 'margin', '0px auto' );
+
+ if ( newWidth < 60 || !isFinite( newWidth ) ) {
+ // Making something skinnier than this will mess up captions,
+ if ( newWidth < 1 || !isFinite( newWidth ) ) {
+ $innerDiv.height( preferredHeight );
+ // Don't even try and touch the image size if it could mean
+ // making it disappear.
+ continue;
+ }
+ } else {
+ $outerDiv.width( newWidth + padding );
+ $innerDiv.width( newWidth + padding );
+ $imageDiv.width( newWidth );
+ $caption.width( curRow[j].captionWidth + ( newWidth - curRow[j].imgWidth ) );
+ }
+
+ if ( imageElm ) {
+ // We don't always have an img, e.g. in the case of an invalid file.
+ imageElm.width = newWidth;
+ imageElm.height = preferredHeight;
+ } else {
+ // Not a file box.
+ $imageDiv.height( preferredHeight );
+ }
+ }
+ }
+ }() );
+ } );
+ } );
+}( jQuery ) );
diff --git a/resources/src/mediawiki.page/mediawiki.page.image.pagination.js b/resources/src/mediawiki.page/mediawiki.page.image.pagination.js
new file mode 100644
index 00000000..622e818d
--- /dev/null
+++ b/resources/src/mediawiki.page/mediawiki.page.image.pagination.js
@@ -0,0 +1,101 @@
+/*!
+ * Implement AJAX navigation for multi-page images so the user may browse without a full page reload.
+ */
+( function ( mw, $ ) {
+ var jqXhr, $multipageimage, $spinner;
+
+ /* Fetch the next page and use jQuery to swap the table.multipageimage contents.
+ * @param {string} url
+ * @param {boolean} [hist=false] Whether this is a load triggered by history navigation (if
+ * true, this function won't push a new history state, for the browser did so already).
+ */
+ function loadPage( url, hist ) {
+ var $tr;
+ if ( jqXhr ) {
+ // Prevent race conditions and piling up pending requests
+ jqXhr.abort();
+ jqXhr = undefined;
+ }
+
+ // Add a new spinner if one doesn't already exist
+ if ( !$spinner ) {
+ $tr = $multipageimage.find( 'tr' );
+ $spinner = $.createSpinner( {
+ size: 'large',
+ type: 'block'
+ } )
+ // Copy the old content dimensions equal so that the current scroll position is not
+ // lost between emptying the table is and receiving the new contents.
+ .css( {
+ height: $tr.outerHeight(),
+ width: $tr.outerWidth()
+ } );
+
+ $multipageimage.empty().append( $spinner );
+ }
+
+ // @todo Don't fetch the entire page. Ideally we'd only fetch the content portion or the data
+ // (thumbnail urls) and update the interface manually.
+ jqXhr = $.ajax( url ).done( function ( data ) {
+ jqXhr = $spinner = undefined;
+
+ // Replace table contents
+ $multipageimage.empty().append( $( data ).find( 'table.multipageimage' ).contents() );
+
+ bindPageNavigation( $multipageimage );
+
+ // Fire hook because the page's content has changed
+ mw.hook( 'wikipage.content' ).fire( $multipageimage );
+
+ // Update browser history and address bar. But not if we came here from a history
+ // event, in which case the url is already updated by the browser.
+ if ( history.pushState && !hist ) {
+ history.pushState( { tag: 'mw-pagination' }, document.title, url );
+ }
+ } );
+ }
+
+ function bindPageNavigation( $container ) {
+ $container.find( '.multipageimagenavbox' ).one( 'click', 'a', function ( e ) {
+ var page, uri;
+
+ // Generate the same URL on client side as the one generated in ImagePage::openShowImage.
+ // We avoid using the URL in the link directly since it could have been manipulated (bug 66608)
+ page = Number( mw.util.getParamValue( 'page', this.href ) );
+ uri = new mw.Uri( mw.util.wikiScript() )
+ .extend( { title: mw.config.get( 'wgPageName' ), page: page } )
+ .toString();
+
+ loadPage( uri );
+ e.preventDefault();
+ } );
+
+ $container.find( 'form[name="pageselector"]' ).one( 'change submit', function ( e ) {
+ loadPage( this.action + '?' + $( this ).serialize() );
+ e.preventDefault();
+ } );
+ }
+
+ $( function () {
+ if ( mw.config.get( 'wgNamespaceNumber' ) !== 6 ) {
+ return;
+ }
+ $multipageimage = $( 'table.multipageimage' );
+ if ( !$multipageimage.length ) {
+ return;
+ }
+
+ bindPageNavigation( $multipageimage );
+
+ // Update the url using the History API (if available)
+ if ( history.pushState && history.replaceState ) {
+ history.replaceState( { tag: 'mw-pagination' }, '' );
+ $( window ).on( 'popstate', function ( e ) {
+ var state = e.originalEvent.state;
+ if ( state && state.tag === 'mw-pagination' ) {
+ loadPage( location.href, true );
+ }
+ } );
+ }
+ } );
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.page/mediawiki.page.patrol.ajax.js b/resources/src/mediawiki.page/mediawiki.page.patrol.ajax.js
new file mode 100644
index 00000000..cc72e168
--- /dev/null
+++ b/resources/src/mediawiki.page/mediawiki.page.patrol.ajax.js
@@ -0,0 +1,65 @@
+/*!
+ * Animate patrol links to use asynchronous API requests to
+ * patrol pages, rather than navigating to a different URI.
+ *
+ * @since 1.21
+ * @author Marius Hoch <hoo@online.de>
+ */
+( function ( mw, $ ) {
+ if ( !mw.user.tokens.exists( 'patrolToken' ) ) {
+ // Current user has no patrol right, or an old cached version of user.tokens
+ // that didn't have patrolToken yet.
+ return;
+ }
+ $( function () {
+ var $patrolLinks = $( '.patrollink a' );
+ $patrolLinks.on( 'click', function ( e ) {
+ var $spinner, href, rcid, apiRequest;
+
+ // Start preloading the notification module (normally loaded by mw.notify())
+ mw.loader.load( ['mediawiki.notification'], null, true );
+
+ // Hide the link and create a spinner to show it inside the brackets.
+ $spinner = $.createSpinner( {
+ size: 'small',
+ type: 'inline'
+ } );
+ $( this ).hide().after( $spinner );
+
+ href = $( this ).attr( 'href' );
+ rcid = mw.util.getParamValue( 'rcid', href );
+ apiRequest = new mw.Api();
+
+ apiRequest.postWithToken( 'patrol', {
+ action: 'patrol',
+ rcid: rcid
+ } )
+ .done( function ( data ) {
+ // Remove all patrollinks from the page (including any spinners inside).
+ $patrolLinks.closest( '.patrollink' ).remove();
+ if ( data.patrol !== undefined ) {
+ // Success
+ var title = new mw.Title( data.patrol.title );
+ mw.notify( mw.msg( 'markedaspatrollednotify', title.toText() ) );
+ } else {
+ // This should never happen as errors should trigger fail
+ mw.notify( mw.msg( 'markedaspatrollederrornotify' ) );
+ }
+ } )
+ .fail( function ( error ) {
+ $spinner.remove();
+ // Restore the patrol link. This allows the user to try again
+ // (or open it in a new window, bypassing this ajax module).
+ $patrolLinks.show();
+ if ( error === 'noautopatrol' ) {
+ // Can't patrol own
+ mw.notify( mw.msg( 'markedaspatrollederror-noautopatrol' ) );
+ } else {
+ mw.notify( mw.msg( 'markedaspatrollederrornotify' ) );
+ }
+ } );
+
+ e.preventDefault();
+ } );
+ } );
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.page/mediawiki.page.ready.js b/resources/src/mediawiki.page/mediawiki.page.ready.js
new file mode 100644
index 00000000..246cc817
--- /dev/null
+++ b/resources/src/mediawiki.page/mediawiki.page.ready.js
@@ -0,0 +1,64 @@
+( function ( mw, $ ) {
+ var supportsPlaceholder = 'placeholder' in document.createElement( 'input' );
+
+ // Break out of framesets
+ if ( mw.config.get( 'wgBreakFrames' ) ) {
+ // Note: In IE < 9 strict comparison to window is non-standard (the standard didn't exist yet)
+ // it works only comparing to window.self or window.window (http://stackoverflow.com/q/4850978/319266)
+ if ( window.top !== window.self ) {
+ // Un-trap us from framesets
+ window.top.location = window.location;
+ }
+ }
+
+ mw.hook( 'wikipage.content' ).add( function ( $content ) {
+ var $sortableTables;
+
+ // Run jquery.placeholder polyfill if placeholder is not supported
+ if ( !supportsPlaceholder ) {
+ $content.find( 'input[placeholder]' ).placeholder();
+ }
+
+ // Run jquery.makeCollapsible
+ $content.find( '.mw-collapsible' ).makeCollapsible();
+
+ // Lazy load jquery.tablesorter
+ $sortableTables = $content.find( 'table.sortable' );
+ if ( $sortableTables.length ) {
+ mw.loader.using( 'jquery.tablesorter', function () {
+ $sortableTables.tablesorter();
+ } );
+ }
+
+ // Run jquery.checkboxShiftClick
+ $content.find( 'input[type="checkbox"]:not(.noshiftselect)' ).checkboxShiftClick();
+ } );
+
+ // Things outside the wikipage content
+ $( function () {
+ var $nodes;
+
+ if ( !supportsPlaceholder ) {
+ // Exclude content to avoid hitting it twice for the (first) wikipage content
+ $( 'input[placeholder]' ).not( '#mw-content-text input' ).placeholder();
+ }
+
+ // Add accesskey hints to the tooltips
+ if ( document.querySelectorAll ) {
+ // If we're running on a browser where we can do this efficiently,
+ // just find all elements that have accesskeys. We can't use jQuery's
+ // polyfill for the selector since looping over all elements on page
+ // load might be too slow.
+ $nodes = $( document.querySelectorAll( '[accesskey]' ) );
+ } else {
+ // Otherwise go through some elements likely to have accesskeys rather
+ // than looping over all of them. Unfortunately this will not fully
+ // work for custom skins with different HTML structures. Input, label
+ // and button should be rare enough that no optimizations are needed.
+ $nodes = $( '#column-one a, #mw-head a, #mw-panel a, #p-logo a, input, label, button' );
+ }
+ $nodes.updateTooltipAccessKeys();
+
+ } );
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.page/mediawiki.page.startup.js b/resources/src/mediawiki.page/mediawiki.page.startup.js
new file mode 100644
index 00000000..4aae6069
--- /dev/null
+++ b/resources/src/mediawiki.page/mediawiki.page.startup.js
@@ -0,0 +1,33 @@
+( function ( mw, $ ) {
+
+ mw.page = {};
+
+ // Client profile classes for <html>
+ // Allows for easy hiding/showing of JS or no-JS-specific UI elements
+ $( 'html' )
+ .addClass( 'client-js' )
+ .removeClass( 'client-nojs' );
+
+ $( function () {
+ mw.util.init();
+
+ /**
+ * Fired when wiki content is being added to the DOM
+ *
+ * It is encouraged to fire it before the main DOM is changed (when $content
+ * is still detatched). However, this order is not defined either way, so you
+ * should only rely on $content itself.
+ *
+ * This includes the ready event on a page load (including post-edit loads)
+ * and when content has been previewed with LivePreview.
+ *
+ * @event wikipage_content
+ * @member mw.hook
+ * @param {jQuery} $content The most appropriate element containing the content,
+ * such as #mw-content-text (regular content root) or #wikiPreview (live preview
+ * root)
+ */
+ mw.hook( 'wikipage.content' ).fire( $( '#mw-content-text' ) );
+ } );
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.page/mediawiki.page.watch.ajax.js b/resources/src/mediawiki.page/mediawiki.page.watch.ajax.js
new file mode 100644
index 00000000..d252f0e4
--- /dev/null
+++ b/resources/src/mediawiki.page/mediawiki.page.watch.ajax.js
@@ -0,0 +1,178 @@
+/**
+ * Animate watch/unwatch links to use asynchronous API requests to
+ * watch pages, rather than navigating to a different URI.
+ *
+ * @class mw.page.watch.ajax
+ */
+( function ( mw, $ ) {
+ // The name of the page to watch or unwatch
+ var title = mw.config.get( 'wgRelevantPageName' );
+
+ /**
+ * Update the link text, link href attribute and (if applicable)
+ * "loading" class.
+ *
+ * @param {jQuery} $link Anchor tag of (un)watch link
+ * @param {string} action One of 'watch', 'unwatch'
+ * @param {string} [state="idle"] 'idle' or 'loading'. Default is 'idle'
+ */
+ function updateWatchLink( $link, action, state ) {
+ var msgKey, $li, otherAction;
+
+ // A valid but empty jQuery object shouldn't throw a TypeError
+ if ( !$link.length ) {
+ return;
+ }
+
+ // Invalid actions shouldn't silently turn the page in an unrecoverable state
+ if ( action !== 'watch' && action !== 'unwatch' ) {
+ throw new Error( 'Invalid action' );
+ }
+
+ // message keys 'watch', 'watching', 'unwatch' or 'unwatching'.
+ msgKey = state === 'loading' ? action + 'ing' : action;
+ otherAction = action === 'watch' ? 'unwatch' : 'watch';
+ $li = $link.closest( 'li' );
+
+ // Trigger a 'watchpage' event for this List item.
+ // Announce the otherAction value as the first param.
+ // Used to monitor the state of watch link.
+ // TODO: Revise when system wide hooks are implemented
+ if ( state === undefined ) {
+ $li.trigger( 'watchpage.mw', otherAction );
+ }
+
+ $link
+ .text( mw.msg( msgKey ) )
+ .attr( 'title', mw.msg( 'tooltip-ca-' + action ) )
+ .updateTooltipAccessKeys()
+ .attr( 'href', mw.util.wikiScript() + '?' + $.param( {
+ title: title,
+ action: action
+ } )
+ );
+
+ // Most common ID style
+ if ( $li.prop( 'id' ) === 'ca-' + otherAction ) {
+ $li.prop( 'id', 'ca-' + action );
+ }
+
+ if ( state === 'loading' ) {
+ $link.addClass( 'loading' );
+ } else {
+ $link.removeClass( 'loading' );
+ }
+ }
+
+ /**
+ * TODO: This should be moved somewhere more accessible.
+ *
+ * @private
+ * @param {string} url
+ * @return {string} The extracted action, defaults to 'view'
+ */
+ function mwUriGetAction( url ) {
+ var action, actionPaths, key, i, m, parts;
+
+ // TODO: Does MediaWiki give action path or query param
+ // precedence? If the former, move this to the bottom
+ action = mw.util.getParamValue( 'action', url );
+ if ( action !== null ) {
+ return action;
+ }
+
+ actionPaths = mw.config.get( 'wgActionPaths' );
+ for ( key in actionPaths ) {
+ if ( actionPaths.hasOwnProperty( key ) ) {
+ parts = actionPaths[key].split( '$1' );
+ for ( i = 0; i < parts.length; i++ ) {
+ parts[i] = $.escapeRE( parts[i] );
+ }
+ m = new RegExp( parts.join( '(.+)' ) ).exec( url );
+ if ( m && m[1] ) {
+ return key;
+ }
+
+ }
+ }
+
+ return 'view';
+ }
+
+ // Expose public methods
+ mw.page.watch = {
+ updateWatchLink: updateWatchLink
+ };
+
+ $( function () {
+ var $links = $( '.mw-watchlink a, a.mw-watchlink, ' +
+ '#ca-watch a, #ca-unwatch a, #mw-unwatch-link1, ' +
+ '#mw-unwatch-link2, #mw-watch-link2, #mw-watch-link1' );
+
+ // Allowing people to add inline animated links is a little scary
+ $links = $links.filter( ':not( #bodyContent *, #content * )' );
+
+ $links.click( function ( e ) {
+ var action, api, $link;
+
+ // Start preloading the notification module (normally loaded by mw.notify())
+ mw.loader.load( ['mediawiki.notification'], null, true );
+
+ action = mwUriGetAction( this.href );
+
+ if ( action !== 'watch' && action !== 'unwatch' ) {
+ // Could not extract target action from link url,
+ // let native browsing handle it further
+ return true;
+ }
+ e.preventDefault();
+ e.stopPropagation();
+
+ $link = $( this );
+
+ if ( $link.hasClass( 'loading' ) ) {
+ return;
+ }
+
+ updateWatchLink( $link, action, 'loading' );
+
+ api = new mw.Api();
+
+ api[action]( title )
+ .done( function ( watchResponse ) {
+ var otherAction = action === 'watch' ? 'unwatch' : 'watch';
+
+ mw.notify( $.parseHTML( watchResponse.message ), {
+ tag: 'watch-self'
+ } );
+
+ // Set link to opposite
+ updateWatchLink( $link, otherAction );
+
+ // Update the "Watch this page" checkbox on action=edit when the
+ // page is watched or unwatched via the tab (bug 12395).
+ $( '#wpWatchthis' ).prop( 'checked', watchResponse.watched !== undefined );
+ } )
+ .fail( function () {
+ var cleanTitle, msg, link;
+
+ // Reset link to non-loading mode
+ updateWatchLink( $link, action );
+
+ // Format error message
+ cleanTitle = title.replace( /_/g, ' ' );
+ link = mw.html.element(
+ 'a', {
+ href: mw.util.getUrl( title ),
+ title: cleanTitle
+ }, cleanTitle
+ );
+ msg = mw.message( 'watcherrortext', link );
+
+ // Report to user about the error
+ mw.notify( msg, { tag: 'watch-self' } );
+ } );
+ } );
+ } );
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.skinning/content.css b/resources/src/mediawiki.skinning/content.css
new file mode 100644
index 00000000..7a417081
--- /dev/null
+++ b/resources/src/mediawiki.skinning/content.css
@@ -0,0 +1,227 @@
+/**
+ * MediaWiki style sheet for general styles on complex content
+ *
+ * Styles for complex things which are a standard part of page content
+ * (ie: the CSS classing built into the system), like the TOC.
+ */
+
+/* Table of Contents */
+#toc,
+.toc,
+.mw-warning {
+ border: 1px solid #aaa;
+ background-color: #f9f9f9;
+ padding: 5px;
+ font-size: 95%;
+}
+
+/**
+ * We want to display the ToC element with intrinsic width in block mode. The fit-content
+ * value for width is however not supported by large groups of browsers.
+ *
+ * We use display:table. Even though it should only contain other table-* display
+ * elements, there are no known problems with using this.
+ *
+ * Because IE < 8, FF 2 and other older browsers don't support display:table, we fallback to
+ * using inline-block mode, which features at least intrinsic width, but won't clear preceding
+ * inline elements. In practice inline elements surrounding the TOC are uncommon enough that
+ * this is an acceptable sacrifice.
+ */
+#toc,
+.toc {
+ display: -moz-inline-block;
+ display: inline-block;
+ display: table;
+
+ /* IE7 and earlier */
+ zoom: 1;
+ *display: inline;
+
+ padding: 7px;
+}
+
+/* CSS for backwards-compatibility with cached page renders and creative uses in wikitext */
+table#toc,
+table.toc {
+ border-collapse: collapse;
+}
+
+/* Remove additional paddings inside table-cells that are not present in <div>s */
+table#toc td,
+table.toc td {
+ padding: 0;
+}
+
+#toc h2,
+.toc h2 {
+ display: inline;
+ border: none;
+ padding: 0;
+ font-size: 100%;
+ font-weight: bold;
+}
+
+#toc #toctitle,
+.toc #toctitle,
+#toc .toctitle,
+.toc .toctitle {
+ text-align: center;
+}
+
+#toc ul,
+.toc ul {
+ list-style-type: none;
+ list-style-image: none;
+ margin-left: 0;
+ padding: 0;
+ text-align: left;
+}
+
+#toc ul ul,
+.toc ul ul {
+ margin: 0 0 0 2em;
+}
+
+#toc .toctoggle,
+.toc .toctoggle {
+ font-size: 94%;
+}
+
+.toccolours {
+ border: 1px solid #aaa;
+ background-color: #f9f9f9;
+ padding: 5px;
+ font-size: 95%;
+}
+
+/* Warning */
+.mw-warning {
+ margin-left: 50px;
+ margin-right: 50px;
+ text-align: center;
+}
+
+/* Images */
+/* @noflip */div.floatright, table.floatright {
+ margin: 0 0 .5em .5em;
+ border: 0;
+}
+
+div.floatright p {
+ font-style: italic;
+}
+
+/* @noflip */div.floatleft, table.floatleft {
+ margin: 0 .5em .5em 0;
+ border: 0;
+}
+
+div.floatleft p {
+ font-style: italic;
+}
+
+/* Thumbnails */
+div.thumb {
+ margin-bottom: .5em;
+ width: auto;
+ background-color: transparent;
+}
+
+div.thumbinner {
+ border: 1px solid #ccc;
+ padding: 3px;
+ background-color: #f9f9f9;
+ font-size: 94%;
+ text-align: center;
+ overflow: hidden;
+}
+
+html .thumbimage {
+ border: 1px solid #ccc;
+}
+
+html .thumbcaption {
+ border: none;
+ line-height: 1.4em;
+ padding: 3px;
+ font-size: 94%;
+ /* Default styles when there's no .mw-content-ltr or .mw-content-rtl, overridden below */
+ text-align: left;
+}
+
+div.magnify {
+ /* Default styles when there's no .mw-content-ltr or .mw-content-rtl, overridden below */
+ float: right;
+ margin-left: 3px;
+}
+
+div.magnify a {
+ display: block;
+ /* Hide the text… */
+ text-indent: 15px;
+ white-space: nowrap;
+ overflow: hidden;
+ /* …and replace it with the image */
+ width: 15px;
+ height: 11px;
+ /* Default styles when there's no .mw-content-ltr or .mw-content-rtl, overridden below */
+ /* @embed */
+ background: url(images/magnify-clip-ltr.png) center center no-repeat;
+ /* Don't annoy people who copy-paste everything too much */
+ -moz-user-select: none;
+ -webkit-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+}
+
+img.thumbborder {
+ border: 1px solid #dddddd;
+}
+
+/* Directionality-specific styles for thumbnails - their positioning depends on content language */
+
+/* @noflip */
+.mw-content-ltr .thumbcaption {
+ text-align: left;
+}
+
+/* @noflip */
+.mw-content-ltr .magnify {
+ float: right;
+ margin-left: 3px;
+ margin-right: 0;
+}
+
+/* @noflip */
+.mw-content-ltr div.magnify a {
+ /* @embed */
+ background-image: url(images/magnify-clip-ltr.png);
+}
+
+/* @noflip */
+.mw-content-rtl .thumbcaption {
+ text-align: right;
+}
+
+/* @noflip */
+.mw-content-rtl .magnify {
+ float: left;
+ margin-left: 0;
+ margin-right: 3px;
+}
+
+/* @noflip */
+.mw-content-rtl div.magnify a {
+ /* @embed */
+ background-image: url(images/magnify-clip-rtl.png);
+}
+
+/* @noflip */
+div.tright {
+ margin: .5em 0 1.3em 1.4em;
+}
+
+/* @noflip */
+div.tleft {
+ margin: .5em 1.4em 1.3em 0;
+}
diff --git a/resources/src/mediawiki.skinning/content.externallinks.css b/resources/src/mediawiki.skinning/content.externallinks.css
new file mode 100644
index 00000000..d23540da
--- /dev/null
+++ b/resources/src/mediawiki.skinning/content.externallinks.css
@@ -0,0 +1,102 @@
+/*!
+ * Icons and colors for external links.
+ */
+
+/* Bug 66091 is blocking is from converting this file to LESS
+ * and using the .background-image-svg mixin. */
+
+/* SVG support using a transparent gradient to guarantee cross-browser
+ * compatibility (browsers able to understand gradient syntax support also SVG).
+ * http://pauginer.tumblr.com/post/36614680636/invisible-gradient-technique */
+
+.mw-body a.external,
+.link-https {
+ background: url(images/external-ltr.png) center right no-repeat;
+ /* @embed */
+ background-image: -webkit-linear-gradient(transparent, transparent), url(images/external-ltr.svg);
+ /* @embed */
+ background-image: linear-gradient(transparent, transparent), url(images/external-ltr.svg);
+ padding-right: 15px;
+}
+
+.mw-body a.external[href^="mailto:"],
+.link-mailto {
+ background: url(images/mail.png) center right no-repeat;
+ /* @embed */
+ background-image: -webkit-linear-gradient(transparent, transparent), url(images/mail.svg);
+ /* @embed */
+ background-image: linear-gradient(transparent, transparent), url(images/mail.svg);
+ padding-right: 15px;
+}
+
+.mw-body a.external[href^="ftp://"],
+.link-ftp {
+ background: url(images/ftp-ltr.png) center right no-repeat;
+ /* @embed */
+ background-image: -webkit-linear-gradient(transparent, transparent), url(images/ftp-ltr.svg);
+ /* @embed */
+ background-image: linear-gradient(transparent, transparent), url(images/ftp-ltr.svg);
+ padding-right: 15px;
+}
+
+.mw-body a.external[href^="irc://"],
+.mw-body a.external[href^="ircs://"],
+.link-irc {
+ background: url(images/chat-ltr.png) center right no-repeat;
+ /* @embed */
+ background-image: -webkit-linear-gradient(transparent, transparent), url(images/chat-ltr.svg);
+ /* @embed */
+ background-image: linear-gradient(transparent, transparent), url(images/chat-ltr.svg);
+ padding-right: 15px;
+}
+
+.mw-body a.external[href$=".ogg"], .mw-body a.external[href$=".OGG"],
+.mw-body a.external[href$=".mid"], .mw-body a.external[href$=".MID"],
+.mw-body a.external[href$=".midi"], .mw-body a.external[href$=".MIDI"],
+.mw-body a.external[href$=".mp3"], .mw-body a.external[href$=".MP3"],
+.mw-body a.external[href$=".wav"], .mw-body a.external[href$=".WAV"],
+.mw-body a.external[href$=".wma"], .mw-body a.external[href$=".WMA"],
+.link-audio {
+ background: url(images/audio-ltr.png) center right no-repeat;
+ /* @embed */
+ background-image: -webkit-linear-gradient(transparent, transparent), url(images/audio-ltr.svg);
+ /* @embed */
+ background-image: linear-gradient(transparent, transparent), url(images/audio-ltr.svg);
+ padding-right: 15px;
+}
+
+.mw-body a.external[href$=".ogm"], .mw-body a.external[href$=".OGM"],
+.mw-body a.external[href$=".avi"], .mw-body a.external[href$=".AVI"],
+.mw-body a.external[href$=".mpeg"], .mw-body a.external[href$=".MPEG"],
+.mw-body a.external[href$=".mpg"], .mw-body a.external[href$=".MPG"],
+.link-video {
+ background: url(images/video.png) center right no-repeat;
+ /* @embed */
+ background-image: -webkit-linear-gradient(transparent, transparent), url(images/video.svg);
+ /* @embed */
+ background-image: linear-gradient(transparent, transparent), url(images/video.svg);
+ padding-right: 15px;
+}
+
+.mw-body a.external[href$=".pdf"], .mw-body a.external[href$=".PDF"],
+.mw-body a.external[href*=".pdf#"], .mw-body a.external[href*=".PDF#"],
+.mw-body a.external[href*=".pdf?"], .mw-body a.external[href*=".PDF?"],
+.link-document {
+ background: url(images/document-ltr.png) center right no-repeat;
+ /* @embed */
+ background-image: -webkit-linear-gradient(transparent, transparent), url(images/document-ltr.svg);
+ /* @embed */
+ background-image: linear-gradient(transparent, transparent), url(images/document-ltr.svg);
+ padding-right: 15px;
+}
+
+/* Interwiki styling */
+.mw-body a.extiw,
+.mw-body a.extiw:active {
+ color: #36b;
+}
+
+/* External link color */
+.mw-body a.external {
+ color: #36b;
+}
diff --git a/resources/src/mediawiki.skinning/content.parsoid.less b/resources/src/mediawiki.skinning/content.parsoid.less
new file mode 100644
index 00000000..a6515d2e
--- /dev/null
+++ b/resources/src/mediawiki.skinning/content.parsoid.less
@@ -0,0 +1,131 @@
+/**
+ * Style Parsoid HTML+RDFa output consistent with wikitext from PHP parser.
+ */
+
+/*csslint regex-selectors:false */
+
+/*
+ * Auto-numbered external links
+ * Parsoid renders those as link without content, and lets CSS do the
+ * counting. This way the counting style can be customized, and counts update
+ * automatically when content is modified.
+ */
+.mw-body-content {
+ counter-reset: mw-NumberedExtLink;
+}
+
+.mw-body-content a[rel~="mw:ExtLink"]:empty:after {
+ content: "[" counter(mw-NumberedExtLink) "]";
+ counter-increment: mw-NumberedExtLink;
+}
+
+/**
+ * References
+ *
+ * Parser and Extension:Cite output reference numbers for <sup>[1]</sup> for <ref> tags.
+ *
+ * Markup:
+ * Cake is good<sup>[2]</sup>
+ * The cake is a lie<span class="reference">[1]</span>
+ *
+ * Styleguide 1.1.
+ */
+span.reference {
+ font-size: 80%;
+ line-height: 1;
+ vertical-align: super;
+ unicode-bidi: -moz-isolate;
+ unicode-bidi: -webkit-isolate;
+ unicode-bidi: isolate;
+}
+
+sup, sub {
+ line-height: 1;
+}
+
+/**
+ * Block media items
+ */
+figure[typeof*='mw:Image'] {
+ margin: 0;
+
+ a {
+ border: 0;
+ }
+
+ &.mw-halign-right {
+ /* @noflip */
+ margin: .5em 0 1.3em 1.4em;
+ /* @noflip */
+ clear: right;
+ /* @noflip */
+ float: right;
+ }
+
+ &.mw-halign-left {
+ /* @noflip */
+ margin: .5em 1.4em 1.3em 0;
+ /* @noflip */
+ clear: left;
+ /* @noflip */
+ float: left;
+ }
+
+ &.mw-halign-none {
+ margin: 0;
+ clear: none;
+ float: none;
+ }
+
+ &.mw-halign-center {
+ margin: 0 auto .5em auto;
+ display: table;
+ clear: none;
+ float: none;
+ }
+
+ > figcaption {
+ display: table-caption;
+ caption-side: bottom;
+ /* In mw-core the font-size is duplicated, 94% in thumbiner
+ and again 94% in thumbcaption. 88% for font size of the
+ caption results in the same behavior. */
+ font-size: 88%;
+ line-height: 1.4em;
+ text-align: left;
+
+ border: 1px solid #ccc;
+ border-top: 0;
+
+ /* taken from .thumbcaption, plus .thumbinner */
+ padding: 1px 5px 5px;
+ background-color: #f9f9f9;
+ }
+}
+
+figure[typeof~='mw:Image/Thumb'],
+figure[typeof~='mw:Image/Frame'] {
+ display: table;
+ overflow: auto;
+ text-align: center;
+ border: 1px solid #ccc;
+ border-bottom: 0; // No border to caption
+ border-collapse: collapse;
+ background-color: #f9f9f9;
+ // Default to right alignment. This is needed since Parsoid only specifies the
+ // alignment class when the alignment is explicitly set.
+ margin: .5em 0 1.3em 1.4em;
+ clear: right;
+ float: right;
+}
+
+figure[typeof~='mw:Image/Thumb'] > *:first-child > img,
+figure[typeof~='mw:Image/Frame'] > *:first-child > img,
+.mw-image-border > *:first-child > img {
+ border: 1px solid #cccccc;
+ margin: 3px;
+}
+
+/* Hide the caption for frameless and plain floated images */
+figure[typeof~="mw:Image/Frameless"] > figcaption,
+figure[typeof~="mw:Image"] > figcaption { display: none }
diff --git a/resources/src/mediawiki.skinning/elements.css b/resources/src/mediawiki.skinning/elements.css
new file mode 100644
index 00000000..392a2a66
--- /dev/null
+++ b/resources/src/mediawiki.skinning/elements.css
@@ -0,0 +1,273 @@
+/**
+ * MediaWiki style sheet for general styles on basic content elements
+ *
+ * Styles for basic elements: links, lists, etc...
+ *
+ * This style sheet is used by the Monobook and Vector skins.
+ */
+
+/* Links */
+a {
+ text-decoration: none;
+ color: #0645ad;
+ background: none;
+}
+
+a:visited {
+ color: #0b0080;
+}
+
+a:active {
+ color: #faa700;
+}
+
+a:hover, a:focus {
+ text-decoration: underline;
+}
+
+a.stub {
+ color: #772233;
+}
+
+a.new, #p-personal a.new {
+ color: #ba0000;
+}
+
+a.new:visited, #p-personal a.new:visited {
+ color: #a55858;
+}
+
+/* Interwiki Styling */
+.mw-body a.extiw,
+.mw-body a.extiw:active {
+ color: #36b;
+}
+
+.mw-body a.extiw:visited {
+ color: #636;
+}
+
+.mw-body a.extiw:active {
+ color: #b63;
+}
+
+/* External links */
+.mw-body a.external {
+ color: #36b;
+}
+
+.mw-body a.external:visited {
+ color: #636; /* bug 3112 */
+}
+
+.mw-body a.external:active {
+ color: #b63;
+}
+
+/* Inline Elements */
+img {
+ border: none;
+ vertical-align: middle;
+}
+
+hr {
+ height: 1px;
+ color: #aaa;
+ background-color: #aaa;
+ border: 0;
+ margin: .2em 0;
+}
+
+/* Structural Elements */
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ color: black;
+ background: none;
+ font-weight: normal;
+ margin: 0;
+ overflow: hidden;
+ padding-top: .5em;
+ padding-bottom: .17em;
+ border-bottom: 1px solid #aaa;
+}
+
+h1 {
+ font-size: 188%;
+}
+
+h2 {
+ font-size: 150%;
+}
+
+h3,
+h4,
+h5,
+h6 {
+ border-bottom: none;
+ font-weight: bold;
+}
+
+h3 {
+ font-size: 132%;
+}
+
+h4 {
+ font-size: 116%;
+}
+
+h5 {
+ font-size: 108%;
+}
+
+h6 {
+ font-size: 100%;
+}
+
+/* Some space under the headers in the content area */
+h1,
+h2 {
+ margin-bottom: .6em;
+}
+
+h3,
+h4,
+h5 {
+ margin-bottom: .3em;
+}
+
+p {
+ margin: .4em 0 .5em 0;
+ line-height: 1.5em;
+}
+
+p img {
+ margin: 0;
+}
+
+ul {
+ line-height: 1.5em;
+ list-style-type: square;
+ margin: .3em 0 0 1.6em;
+ padding: 0;
+}
+
+ol {
+ line-height: 1.5em;
+ margin: .3em 0 0 3.2em;
+ padding: 0;
+ list-style-image: none;
+}
+
+li {
+ margin-bottom: .1em;
+}
+
+dt {
+ font-weight: bold;
+ margin-bottom: .1em;
+}
+
+dl {
+ margin-top: .2em;
+ margin-bottom: .5em;
+}
+
+dd {
+ line-height: 1.5em;
+ margin-left: 1.6em;
+ margin-bottom: .1em;
+}
+
+/* IE 6 and 7 lack support for quotes aroud the <q> element ('::before' and '::after'
+ pseudoelements, 'quotes' property). Let's italicize it instead (using the star hack). */
+q {
+ *font-style: italic;
+}
+
+pre, code, tt, kbd, samp, .mw-code {
+ /*
+ * Some browsers will render the monospace text too small, namely Firefox, Chrome and Safari.
+ * Specifying any valid, second value will trigger correct behavior without forcing a different font.
+ */
+ font-family: monospace, Courier;
+}
+
+code {
+ color: black;
+ background-color: #f9f9f9;
+ border: 1px solid #ddd;
+ border-radius: 2px;
+ padding: 1px 4px;
+}
+
+pre, .mw-code {
+ color: black;
+ background-color: #f9f9f9;
+ border: 1px solid #ddd;
+ padding: 1em;
+}
+
+/* Tables */
+table {
+ font-size: 100%;
+}
+
+/* Forms */
+fieldset {
+ border: 1px solid #2f6fab;
+ margin: 1em 0 1em 0;
+ padding: 0 1em 1em;
+ line-height: 1.5em;
+}
+
+fieldset.nested {
+ margin: 0 0 0.5em 0;
+ padding: 0 0.5em 0.5em;
+}
+
+legend {
+ padding: .5em;
+ font-size: 95%;
+}
+
+form {
+ border: none;
+ margin: 0;
+}
+
+textarea {
+ width: 100%;
+ padding: .1em;
+ display: block;
+ -moz-box-sizing: border-box;
+ -webkit-box-sizing: border-box;
+ box-sizing: border-box;
+}
+
+select {
+ vertical-align: top;
+}
+
+/* Emulate Center */
+.center {
+ width: 100%;
+ text-align: center;
+}
+
+*.center * {
+ margin-left: auto;
+ margin-right: auto;
+}
+
+/* Small for tables and similar */
+.small {
+ font-size: 94%;
+}
+
+table.small {
+ font-size: 100%;
+}
diff --git a/resources/src/mediawiki.skinning/images/audio-ltr.png b/resources/src/mediawiki.skinning/images/audio-ltr.png
new file mode 100644
index 00000000..8efc4f2d
--- /dev/null
+++ b/resources/src/mediawiki.skinning/images/audio-ltr.png
Binary files differ
diff --git a/resources/src/mediawiki.skinning/images/audio-ltr.svg b/resources/src/mediawiki.skinning/images/audio-ltr.svg
new file mode 100644
index 00000000..e27a5f53
--- /dev/null
+++ b/resources/src/mediawiki.skinning/images/audio-ltr.svg
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="13" width="13" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
+<g transform="translate(-36.467808,-258.39005)">
+<path style="opacity:1;fill:#3366bb;" d="m43.47,259.4-3,3-3,0,0,4,3,0,3,3zm-1,2.5,0,5-1.5-1.5-2.5,0,0-2,2.5,0z"/>
+<path style="opacity:1;fill:#15a5ea;" d="m43.9,262.5c0-0.6213,0.6213-1.243,1.243-0.6213,0,0,0.6213,0.6213,0.6213,2.485s-0.6213,2.485-0.6213,2.485c-0.6213,0.6213-1.243,0-1.243-0.6213,0,0,0.6213-0.6213,0.6213-1.864s-0.6213-1.864-0.6213-1.864z"/>
+<path style="opacity:1;fill:#15a5ea;" d="m45.76,261.2c0-0.6213,0.6213-1.243,1.243-0.6213,0,0,1.243,1.243,1.243,3.728s-1.243,3.728-1.243,3.728c-0.6213,0.6213-1.243,0-1.243-0.6213,0,0,1.243-1.243,1.243-3.107s-1.243-3.107-1.243-3.107z"/>
+</g>
+</svg>
diff --git a/resources/src/mediawiki.skinning/images/audio-rtl.png b/resources/src/mediawiki.skinning/images/audio-rtl.png
new file mode 100644
index 00000000..1afdf404
--- /dev/null
+++ b/resources/src/mediawiki.skinning/images/audio-rtl.png
Binary files differ
diff --git a/resources/src/mediawiki.skinning/images/audio-rtl.svg b/resources/src/mediawiki.skinning/images/audio-rtl.svg
new file mode 100644
index 00000000..683bbcd7
--- /dev/null
+++ b/resources/src/mediawiki.skinning/images/audio-rtl.svg
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="13" width="13" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
+<g transform="translate(-36.467808,-258.39005)">
+<path style="opacity:1;fill:#3366bb;" d="m42.47,259.4,3,3,3,0,0,4-3,0-3,3zm1,2.5,0,5,1.5-1.5,2.5,0,0-2-2.5,0z"/>
+<path style="opacity:1;fill:#15a5ea;" d="m42.04,262.5c0-0.6213-0.6213-1.243-1.243-0.6213,0,0-0.6213,0.6213-0.6213,2.485s0.6213,2.485,0.6213,2.485c0.6213,0.6213,1.243,0,1.243-0.6213,0,0-0.6213-0.6213-0.6213-1.864s0.6213-1.864,0.6213-1.864z"/>
+<path style="opacity:1;fill:#15a5ea;" d="m40.17,261.2c0-0.6213-0.6213-1.243-1.243-0.6213,0,0-1.243,1.243-1.243,3.728s1.243,3.728,1.243,3.728c0.6213,0.6213,1.243,0,1.243-0.6213,0,0-1.243-1.243-1.243-3.107s1.243-3.107,1.243-3.107z"/>
+</g>
+</svg>
diff --git a/resources/src/mediawiki.skinning/images/chat-ltr.png b/resources/src/mediawiki.skinning/images/chat-ltr.png
new file mode 100644
index 00000000..624ecec3
--- /dev/null
+++ b/resources/src/mediawiki.skinning/images/chat-ltr.png
Binary files differ
diff --git a/resources/src/mediawiki.skinning/images/chat-ltr.svg b/resources/src/mediawiki.skinning/images/chat-ltr.svg
new file mode 100644
index 00000000..bd5329e0
--- /dev/null
+++ b/resources/src/mediawiki.skinning/images/chat-ltr.svg
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="13" width="13" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
+<g transform="translate(-36.467808,-258.39005)">
+<path style="opacity:1;fill:#3366bb;" d="m38.09,260.4-0.6213,0.6213,0,5.757,0.6213,0.6213,1.689,0-0.6213,2.728,4.311-2.728,4.379,0,0.6213-0.6213,0-5.757-0.6213-0.6213zm0.3787,1,9,0,0,5-4,0-2.902,1.897,0.9021-1.897-3,0z"/>
+</g>
+</svg>
diff --git a/resources/src/mediawiki.skinning/images/chat-rtl.png b/resources/src/mediawiki.skinning/images/chat-rtl.png
new file mode 100644
index 00000000..f90fa33a
--- /dev/null
+++ b/resources/src/mediawiki.skinning/images/chat-rtl.png
Binary files differ
diff --git a/resources/src/mediawiki.skinning/images/chat-rtl.svg b/resources/src/mediawiki.skinning/images/chat-rtl.svg
new file mode 100644
index 00000000..b86218f3
--- /dev/null
+++ b/resources/src/mediawiki.skinning/images/chat-rtl.svg
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="13" width="13" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
+<g transform="translate(-36.467808,-258.39005)">
+<path style="opacity:1;fill:#3366bb;" d="m47.85,260.4,0.6213,0.6213,0,5.757-0.6213,0.6213-1.689,0,0.6213,2.728-4.311-2.728-4.379,0-0.6213-0.6213,0-5.757,0.6213-0.6213zm-0.3787,1-9,0,0,5,4,0,2.902,1.897-0.9021-1.897,3,0z"/>
+</g>
+</svg>
diff --git a/resources/src/mediawiki.skinning/images/document-ltr.png b/resources/src/mediawiki.skinning/images/document-ltr.png
new file mode 100644
index 00000000..4ea9373f
--- /dev/null
+++ b/resources/src/mediawiki.skinning/images/document-ltr.png
Binary files differ
diff --git a/resources/src/mediawiki.skinning/images/document-ltr.svg b/resources/src/mediawiki.skinning/images/document-ltr.svg
new file mode 100644
index 00000000..43960980
--- /dev/null
+++ b/resources/src/mediawiki.skinning/images/document-ltr.svg
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="13" width="13" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
+<path style="opacity:1;fill:#15a5ea;" d="m7.5,2,0,3,2.5,0,1-1-2.5,0,0-3z"/>
+<path style="opacity:1;fill:#3366bb;" d="m3,1,0,10,8,0,0-7-2.5-3zm1,1,4,0,2,2.5,0,5.5-6,0z"/>
+</svg>
diff --git a/resources/src/mediawiki.skinning/images/document-rtl.png b/resources/src/mediawiki.skinning/images/document-rtl.png
new file mode 100644
index 00000000..c281677a
--- /dev/null
+++ b/resources/src/mediawiki.skinning/images/document-rtl.png
Binary files differ
diff --git a/resources/src/mediawiki.skinning/images/document-rtl.svg b/resources/src/mediawiki.skinning/images/document-rtl.svg
new file mode 100644
index 00000000..c37dadca
--- /dev/null
+++ b/resources/src/mediawiki.skinning/images/document-rtl.svg
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="13" width="13" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
+<path style="opacity:1;fill:#15a5ea;" d="m5.5,2,0,3-2.5,0-1-1,2.5,0,0-3z"/>
+<path style="opacity:1;fill:#3366bb;" d="m10,1,0,10-8,0,0-7,2.5-3zm-1,1-4,0-2,2.5,0,5.5,6,0z"/>
+</svg>
diff --git a/resources/src/mediawiki.skinning/images/external link icons.svg b/resources/src/mediawiki.skinning/images/external link icons.svg
new file mode 100644
index 00000000..6a67993d
--- /dev/null
+++ b/resources/src/mediawiki.skinning/images/external link icons.svg
@@ -0,0 +1,697 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="13"
+ height="110"
+ id="svg2"
+ version="1.1"
+ inkscape:version="0.48.5 r10040"
+ sodipodi:docname="external link icons.svg">
+ <defs
+ id="defs4" />
+ <sodipodi:namedview
+ id="base"
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1.0"
+ inkscape:pageopacity="0.0"
+ inkscape:pageshadow="2"
+ inkscape:zoom="15.999999"
+ inkscape:cx="10.40536"
+ inkscape:cy="65.686256"
+ inkscape:document-units="px"
+ inkscape:current-layer="layer5"
+ showgrid="true"
+ fit-margin-top="0"
+ fit-margin-left="0"
+ fit-margin-right="0"
+ fit-margin-bottom="0"
+ inkscape:window-width="1283"
+ inkscape:window-height="711"
+ inkscape:window-x="1790"
+ inkscape:window-y="-6"
+ inkscape:window-maximized="0">
+ <inkscape:grid
+ type="xygrid"
+ id="grid3246"
+ empspacing="4"
+ visible="true"
+ enabled="true"
+ snapvisiblegridlinesonly="true"
+ originx="0px"
+ originy="-27.999997px" />
+ </sodipodi:namedview>
+ <metadata
+ id="metadata7">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <g
+ inkscape:groupmode="layer"
+ id="layer2"
+ inkscape:label="base"
+ style="display:none"
+ transform="translate(-505,-869.36218)">
+ <rect
+ style="fill:#b3b3b3;fill-opacity:1;fill-rule:evenodd;stroke:none"
+ id="rect4646"
+ width="13"
+ height="12.999996"
+ x="505"
+ y="885.36218" />
+ <rect
+ style="fill:#b3b3b3;fill-opacity:1;fill-rule:evenodd;stroke:none"
+ id="rect4646-4"
+ width="13"
+ height="12.999998"
+ x="505"
+ y="901.36218" />
+ <rect
+ style="fill:#b3b3b3;fill-opacity:1;fill-rule:evenodd;stroke:none"
+ id="rect4646-4-6"
+ width="13"
+ height="12.999996"
+ x="505"
+ y="917.36218"
+ inkscape:export-filename="/home/rahah/elvidishu/steak/unreal/dev/skins/external link icons/mail.png"
+ inkscape:export-xdpi="90"
+ inkscape:export-ydpi="90" />
+ <rect
+ style="fill:#b3b3b3;fill-opacity:1;fill-rule:evenodd;stroke:none"
+ id="rect4646-4-6-9"
+ width="13"
+ height="12.999996"
+ x="505"
+ y="933.36218" />
+ <rect
+ style="fill:#b3b3b3;fill-opacity:1;fill-rule:evenodd;stroke:none"
+ id="rect4646-4-6-6"
+ width="13"
+ height="12.999996"
+ x="505"
+ y="950.36218" />
+ <rect
+ style="fill:#b3b3b3;fill-opacity:1;fill-rule:evenodd;stroke:none"
+ id="rect4646-4-6-2"
+ width="13"
+ height="12.999998"
+ x="505"
+ y="966.36218" />
+ <rect
+ style="fill:#b3b3b3;fill-opacity:1;fill-rule:evenodd;stroke:none;display:inline"
+ id="rect4646-44"
+ width="13"
+ height="12.999996"
+ x="505"
+ y="869.36218" />
+ </g>
+ <g
+ inkscape:label="sketch 1"
+ inkscape:groupmode="layer"
+ id="layer1"
+ transform="translate(-505,-869.36218)"
+ style="display:none"
+ sodipodi:insensitive="true">
+ <path
+ style="fill:none;stroke:#0066ff;stroke-width:1.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+ d="m 507,870.36218 0,5 3,0 4,4 0,-13 -4,4 z"
+ id="path3194"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccccc" />
+ <path
+ style="fill:none;stroke:#5b9dff;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+ d="m 517,869.36218 c 1,2 1,5 0,7"
+ id="path3196"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;stroke:#5b9dff;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+ d="m 520,867.36218 c 2,2 2,9 0,11"
+ id="path3198"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;stroke:#0066ff;stroke-width:1.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+ d="m 507.93861,989.90562 0,15.99988 13,0 0,-10.99988 -5,-5 z"
+ id="path3200"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccc" />
+ <path
+ style="fill:none;stroke:#0066ff;stroke-width:1.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+ d="m 506.93861,918.90546 0,11.5 15,0 0,-11.5 z"
+ id="path3202"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccc" />
+ <path
+ style="fill:none;stroke:#0066ff;stroke-width:1.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+ d="m 506.93861,918.90546 7.5,6 7.5,-6"
+ id="path3204"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccc" />
+ <path
+ style="fill:none;stroke:#5b9dff;stroke-width:1.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+ d="m 507.93861,890.90546 3,0"
+ id="path3212"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;stroke:#5b9dff;stroke-width:1.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+ d="m 507.93861,893.90546 3,0"
+ id="path3214"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;stroke:#5b9dff;stroke-width:1.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+ d="m 507.93861,899.90546 3,0"
+ id="path3218"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;stroke:#5b9dff;stroke-width:1.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+ d="m 507.93861,902.90546 3,0"
+ id="path3220"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;stroke:#5b9dff;stroke-width:1.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+ d="m 517.93861,890.90546 3,0"
+ id="path3222"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;stroke:#5b9dff;stroke-width:1.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+ d="m 507.93861,896.90546 13,0"
+ id="path3224"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;stroke:#5b9dff;stroke-width:1.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+ d="m 517.93861,893.90546 3,0"
+ id="path3226"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;stroke:#5b9dff;stroke-width:1.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+ d="m 517.93861,899.90546 3,0"
+ id="path3230"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;stroke:#5b9dff;stroke-width:1.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+ d="m 517.93861,902.90546 3,0"
+ id="path3232"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;stroke:#0066ff;stroke-width:1.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+ d="m 510.93861,890.90546 0,12 7,0 0,-12 z"
+ id="path3206"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccc" />
+ <path
+ style="fill:none;stroke:#0066ff;stroke-width:1.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+ d="m 507.93861,888.90546 0,16"
+ id="path3208"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;stroke:#0066ff;stroke-width:1.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+ d="m 520.93861,888.90546 0,16"
+ id="path3210"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cc" />
+ <path
+ style="fill:none;stroke:#0066ff;stroke-width:1.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+ d="m 515.93861,989.90562 0,5 5,0"
+ id="path3234"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccc" />
+ <path
+ style="fill:none;stroke:#0066ff;stroke-width:1.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+ d="m 513.93861,969.40546 c -2,0 -5,0 -7,0 l 0,10.99995 11,5e-5 c 0,-2.33332 0,-4.66668 0,-7"
+ id="path3236"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccc" />
+ <path
+ style="fill:none;stroke:#0066ff;stroke-width:1.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;marker-start:none"
+ d="m 513.93861,976.40546 5,-4 3,3 0,-10 -10,0 3,3 -4,5"
+ id="path3242"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccccc" />
+ <path
+ style="fill:none;stroke:#0066ff;stroke-width:1.5;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+ d="m 506.93861,940.40546 15,0 0,10 -6,0 -6,4 1,-4 -4,0 z"
+ id="path3244"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccc" />
+ <path
+ style="fill:none;stroke:#5b9dff;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+ d="m 509.93861,972.40546 c 2,1 4,3 5,5"
+ id="path4641"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cc" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer4"
+ inkscape:label="sketch 2"
+ style="display:none"
+ transform="translate(0,-6.0000106)">
+ <path
+ style="fill:#3366bb;fill-opacity:1;stroke:none;display:inline"
+ d="m 8.4921988,1.0623917 -4.0641234,4.064123 -3.2512987,0 0,5.6897733 3.2512987,0 4.0641234,4.064124 z m -1.6256494,4.064124 0,5.6897733 -1.6256493,-1.6256493 -2.438474,-1e-6 0,-2.438474 2.438474,1e-6 z"
+ id="path4755-9"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccccccccc" />
+ <path
+ style="fill:#15a5ea;fill-opacity:1;stroke:none;display:inline"
+ d="m 9.3050235,5.1265157 c 0,-0.812824 0.8128245,-1.625649 1.6256495,-0.812824 0,0 0.812825,0.812824 0.812825,3.251298 0,2.4384743 -0.812825,3.2512993 -0.812825,3.2512993 -0.812825,0.812825 -1.6256495,0 -1.6256495,-0.812825 0,0 0.8128245,-0.8128243 0.8128245,-2.4384743 0,-1.625649 -0.8128245,-2.438474 -0.8128245,-2.438474 z"
+ id="path4760-1"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccsccsc" />
+ <path
+ style="fill:#15a5ea;fill-opacity:1;stroke:none;display:inline"
+ d="m 11.743498,3.5008667 c 0,-0.812825 0.812824,-1.625649 1.625649,-0.812825 0,0 1.625649,1.62565 1.625649,4.876948 0,3.2512993 -1.625649,4.8769483 -1.625649,4.8769483 -0.812825,0.812825 -1.625649,0 -1.625649,-0.812824 0,0 1.625649,-1.62565 1.625649,-4.0641243 0,-2.438474 -1.625649,-4.064123 -1.625649,-4.064123 z"
+ id="path4762-8"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccsccsc" />
+ <path
+ style="fill:#3366bb;fill-opacity:1;stroke:none;display:inline"
+ d="m 2.8024261,23.008658 0,0.812825 10.5667209,0 0,-0.812825 z"
+ id="path4772"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccc" />
+ <path
+ style="fill:#3366bb;fill-opacity:1;stroke:none;display:inline"
+ d="m 2.8024261,33.575379 10.5667209,0 0,-0.812825 -10.5667209,0 z"
+ id="path4774"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccc" />
+ <path
+ style="fill:#3366bb;fill-opacity:1;stroke:none;display:inline"
+ d="m 2.8024261,25.447132 0,0.812824 2.438474,0 0,-0.812824 z m 8.1282469,0 0,0.812824 2.438474,0 0,-0.812824 z"
+ id="path4782"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccccc" />
+ <path
+ style="fill:#3366bb;fill-opacity:1;stroke:none;display:inline"
+ d="m 2.8024261,30.32408 0,0.812824 2.438474,0 0,-0.812824 z m 8.1282469,0 0,0.812824 2.438474,0 0,-0.812824 z"
+ id="path4778"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccccc" />
+ <path
+ style="fill:#3366bb;fill-opacity:1;stroke:none;display:inline"
+ d="m 2.8024261,28.698431 10.5667209,0 0,-0.812825 -10.5667209,0 z"
+ id="path4780"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccc" />
+ <path
+ style="fill:#15a5ea;fill-opacity:1;stroke:none;display:inline"
+ d="m 5.2409001,27.479194 0,1.625649 5.6897729,0 0,-1.625649 z"
+ id="path4793"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccc" />
+ <path
+ style="fill:#3366bb;fill-opacity:1;stroke:none;display:inline"
+ d="m 4.4280754,23.008658 0,10.56672 7.3154226,0 0,-10.56672 z m 1.6256494,1.625649 4.0641232,0 0,7.315422 -4.0641232,0 z"
+ id="path4768"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccccc" />
+ <path
+ style="fill:#3366bb;fill-opacity:1;stroke:none;display:inline"
+ d="m 1.9896014,21.383009 1.6256493,0 0,13.818019 -1.6256493,0 z"
+ id="path4764"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccc" />
+ <path
+ style="fill:#3366bb;fill-opacity:1;stroke:none;display:inline"
+ d="m 12.556322,21.383009 1.62565,0 0,13.818019 -1.62565,0 z"
+ id="path4766"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccc" />
+ <path
+ style="fill:#15a5ea;fill-opacity:1;stroke:none;display:inline"
+ d="m 1.9896014,44.954923 6.096185,5.689773 6.0961856,-5.689773 -0.812825,-0.812825 -1.625649,0.813196 -3.6577116,3.251298 -3.657711,-3.251298 -1.6256493,-0.813196 z"
+ id="path4800-4"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccccccc" />
+ <path
+ style="fill:#3366bb;fill-opacity:1;stroke:none;display:inline"
+ d="m 1.1767767,43.329273 0,11.379545 13.8180193,0 0,-11.379545 z m 1.6256494,1.625649 10.5667209,0 0,8.128247 -10.5667209,0 z"
+ id="path4795-8"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccccc" />
+ <path
+ style="fill:#3366bb;fill-opacity:1;stroke:none;display:inline"
+ d="m 1.9896014,62.024243 -0.8128247,0.812824 0,8.128247 0.8128247,0.812825 2.438474,0 -0.8128247,3.251297 5.6897728,-3.251297 4.8769485,0 0.812824,-0.812825 0,-8.128247 -0.812824,-0.812824 z m 0.8128247,1.625649 10.5667209,0 0,6.502598 -4.0641235,0 -3.5815088,2.133664 0.9144278,-2.133664 -3.8355164,0 z"
+ id="path4802-8"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccccccccccccccc" />
+ <path
+ style="fill:#3366bb;fill-opacity:1;stroke:none;display:inline"
+ d="m 4.4280754,82.344856 10.5667206,0 0,10.56672 -3.251298,-3.25129 -3.2512992,2.43847 -0.8128247,-0.81282 4.0641239,-3.2513 1.625649,1.62565 0,-5.68978 -5.6897729,0 1.6256494,1.62565 -3.2512987,4.06413 -0.8128247,-0.81283 2.438474,-3.2513 z"
+ id="path4807-5"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccccccccccccc" />
+ <path
+ style="fill:#3366bb;fill-opacity:1;stroke:none;display:inline"
+ d="m 8.4921988,85.596156 -7.3154221,0 0,10.56672 10.5667213,0 0,-7.31542 -1.62565,0.81283 0,4.87694 -7.3154219,0 0,-7.31542 4.876948,0 z"
+ id="path4809-7"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccccccccc" />
+ <path
+ style="fill:#15a5ea;fill-opacity:1;stroke:none;display:inline"
+ d="m 8.4921988,103.47831 0,4.87694 4.8769482,0 0,-1.62565 -3.251299,0 0,-3.25129 z"
+ id="path4818-67"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccccc" />
+ <path
+ style="fill:#3366bb;fill-opacity:1;stroke:none;display:inline"
+ d="m 1.9896014,102.66548 0,13.81802 12.1923706,0 0,-10.13799 -3.657711,-3.68003 z m 1.6256493,1.62565 6.5025973,0 2.438474,2.43847 0,8.12825 -8.9410713,0 z"
+ id="path4813-2"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccccccc" />
+ <path
+ style="fill:#15a5ea;fill-opacity:1;stroke:none;display:inline"
+ d="m 3.6152507,88.847456 c 0,-0.81282 0.8128247,-0.81282 0.8128247,-0.81282 2.438474,0.81282 4.0641234,2.43847 4.8769481,4.87694 0,0 0,0.81283 -0.8128247,0.81283 -1.6256494,-2.43847 -2.438474,-3.2513 -4.8769481,-4.87695 z"
+ id="path4822-8"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccc" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer3"
+ inkscape:label="Layer"
+ style="display:none"
+ transform="translate(-10,-26.000007)">
+ <path
+ style="fill:#3366bb;fill-opacity:1;stroke:none"
+ d="m 19,20.999995 -4,4 -3,0 0,6 3,0 4,4 z m -1,2.5 0,9 -2.6,-2.5 -2.4,0 0,-4 2.5,0 z"
+ id="path4755"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccccccccc" />
+ <path
+ style="fill:#15a5ea;fill-opacity:1;stroke:none"
+ d="m 19.75,24.999995 c 0,-1 0.75,-1 0.75,-1 0,0 1.5,1.029412 1.5,3.5 0,2.470588 -1.5,3.5 -1.5,3.5 0,0 -0.75,0 -0.75,-1 0,0 1,-0.852941 1,-2.5 0,-1.647059 -1,-2.5 -1,-2.5 z"
+ id="path4760"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccsccsc" />
+ <path
+ style="fill:#15a5ea;fill-opacity:1;stroke:none"
+ d="m 22.25,23.999995 c 0,-1 0.75,-1 0.75,-1 0,0 2,1.205882 2,4.5 0,3.294118 -2,4.5 -2,4.5 0,0 -0.75,0 -0.75,-1 0,0 1.5,-1.029412 1.5,-3.5 0,-2.470588 -1.5,-3.5 -1.5,-3.5 z"
+ id="path4762"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccsccsc" />
+ <path
+ style="fill:#3366bb;fill-opacity:1;stroke:none"
+ d="m 31.4375,79.875 -1,1 0,10 1,1 3,0 -1,4 7,-4 6,0 1,-1 0,-10 -1,-1 z m 1,2 13,0 0,8 -5,0 -4.40625,2.625 1.125,-2.625 -4.71875,0 z"
+ id="path4802"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccccccccccccccc" />
+ <path
+ style="fill:#3366bb;fill-opacity:1;stroke:none"
+ d="m 35.125,98.8125 13,0 0,13 -4,-4 -4,3 -1,-1 5,-4 2,2 0,-7 -7,0 2,2 -4,5 -1,-1 3,-4 z"
+ id="path4807"
+ inkscape:connector-curvature="0" />
+ <path
+ style="fill:#3366bb;fill-opacity:1;stroke:none"
+ d="m 40.125,102.8125 -9,0 0,13 13,0 0,-9 -2,1 0,6 -9,0 0,-9 6,0 z"
+ id="path4809"
+ inkscape:connector-curvature="0" />
+ <path
+ style="fill:#15a5ea;fill-opacity:1;stroke:none"
+ d="m 34.125,106.8125 c 0,-1 1,-1 1,-1 3,1 5,3 6,6 0,0 0,1 -1,1 -2,-3 -3,-4 -6,-6 z"
+ id="path4822"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccc" />
+ <path
+ style="fill:#15a5ea;fill-opacity:1;fill-rule:evenodd;stroke:none"
+ d="m 12,50.499995 0,1 3,0 0,-1 -3,0 z m 9,0 0,1 3,0 0,-1 -3,0 z"
+ id="rect4841"
+ inkscape:connector-curvature="0" />
+ <path
+ style="fill:#15a5ea;fill-opacity:1;fill-rule:evenodd;stroke:none"
+ d="m 12,44.499995 0,1 3,0 0,-1 -3,0 z m 9,0 0,1 3,0 0,-1 -3,0 z"
+ id="rect4843"
+ inkscape:connector-curvature="0" />
+ <path
+ inkscape:connector-curvature="0"
+ style="fill:#15a5ea;fill-opacity:1;fill-rule:evenodd;stroke:none;display:inline"
+ d="m 12,52.999995 0,1 3,0 0,-1 z m 9,0 0,1 3,0 0,-1 z"
+ id="rect4843-1"
+ sodipodi:nodetypes="cccccccccc" />
+ <path
+ inkscape:connector-curvature="0"
+ style="fill:#15a5ea;fill-opacity:1;fill-rule:evenodd;stroke:none;display:inline"
+ d="m 12,41.999995 0,1 3,0 0,-1 z m 9,0 0,1 3,0 0,-1 z"
+ id="rect4843-17"
+ sodipodi:nodetypes="cccccccccc" />
+ <path
+ style="fill:#15a5ea;fill-opacity:1;fill-rule:evenodd;stroke:none;display:inline"
+ d="m 12,47.499995 0,1 3,0 0,-1 z m 9,0 0,1 3,0 0,-1 z"
+ id="rect4841-9"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccccc" />
+ <rect
+ style="fill:#15a5ea;fill-opacity:1;fill-rule:evenodd;stroke:none"
+ id="rect4835"
+ width="7"
+ height="2"
+ x="4"
+ y="27"
+ transform="translate(10,19.999995)" />
+ <path
+ style="fill:#3366bb;fill-opacity:1;stroke:none"
+ d="m 2,21 1,0 0,14 -1,0 z"
+ id="path4826"
+ inkscape:connector-curvature="0"
+ transform="translate(10,19.999995)"
+ sodipodi:nodetypes="ccccc" />
+ <path
+ style="fill:#3366bb;fill-opacity:1;stroke:none"
+ d="m 13,21 1,0 0,14 -1,0 z"
+ id="path4828"
+ inkscape:connector-curvature="0"
+ transform="translate(10,19.999995)"
+ sodipodi:nodetypes="ccccc" />
+ <path
+ style="fill:#3366bb;fill-opacity:1;stroke:none"
+ d="m 4,22 0,12 8,0 0,-12 z m 1,1 6,0 0,10 -6,0 z"
+ transform="translate(10,19.999995)"
+ id="path4830"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccccc" />
+ <rect
+ style="fill:#3366bb;fill-opacity:1;fill-rule:evenodd;stroke:none"
+ id="rect4837"
+ width="8"
+ height="1"
+ x="4"
+ y="22"
+ transform="translate(10,19.999995)" />
+ <rect
+ style="fill:#3366bb;fill-opacity:1;fill-rule:evenodd;stroke:none"
+ id="rect4839"
+ width="8"
+ height="1"
+ x="4"
+ y="33"
+ transform="translate(10,19.999995)" />
+ <path
+ style="fill:#15a5ea;fill-opacity:1;stroke:none"
+ d="m 12,64.999995 6,5 6,-5 0.53033,-1.45299 -1.28033,0.45299 -5.25,4.5 -5.25,-4.5 -1.236136,-0.53033 z"
+ id="path4910"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccccccc" />
+ <path
+ style="fill:#3366bb;fill-opacity:1;stroke:none"
+ d="m 11,62.999995 0,10 14,0 0,-10 z m 1,1 12,0 0,8 -12,0 z"
+ id="path4905"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccccc" />
+ <path
+ style="fill:#15a5ea;fill-opacity:1;stroke:none;display:inline"
+ d="m 19,121 0,5 5,0 -1,-1 -3,0 0,-3 z"
+ id="path4818-6"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccccc" />
+ <path
+ style="fill:#3366bb;fill-opacity:1;stroke:none;display:inline"
+ d="m 12,121 0,14 12,0 0,-10 -4,-4 z m 1,1 6.5,0 3.5,3.5 0,8.5 -10,0 z"
+ id="path4813-3"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccccccc" />
+ </g>
+ <g
+ inkscape:groupmode="layer"
+ id="layer5"
+ inkscape:label="Layer#1"
+ style="opacity:0.98999999"
+ transform="translate(0,-6.0000106)">
+ <path
+ style="fill:#3366bb;fill-opacity:1;stroke:none;display:inline"
+ d="m 7,23.000004 -3,3 -3,0 0,4 3,0 3,3 z m -1,2.500001 0,5 -1.5,-1.500001 -2.5,0 0,-2 2.5,0 z"
+ id="path4755-9-5"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccccccccc" />
+ <path
+ style="fill:#15a5ea;fill-opacity:1;stroke:none;display:inline"
+ d="m 7.4319858,26.075368 c 0,-0.621323 0.6213237,-1.242647 1.2426477,-0.621323 0,0 0.6213228,0.621323 0.6213228,2.485294 0,1.863971 -0.6213228,2.485294 -0.6213228,2.485294 -0.621324,0.621324 -1.2426477,0 -1.2426477,-0.621323 0,0 0.6213237,-0.621324 0.6213237,-1.863971 0,-1.242648 -0.6213237,-1.863971 -0.6213237,-1.863971 z"
+ id="path4760-1-7"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccsccsc" />
+ <path
+ style="fill:#15a5ea;fill-opacity:1;stroke:none;display:inline"
+ d="m 9.2959563,24.832721 c 0,-0.621324 0.6213228,-1.242647 1.2426477,-0.621324 0,0 1.242646,1.242648 1.242646,3.727942 0,2.485294 -1.242646,3.727941 -1.242646,3.727941 -0.6213249,0.621324 -1.2426477,0 -1.2426477,-0.621323 0,0 1.2426477,-1.242647 1.2426477,-3.106618 0,-1.863971 -1.2426477,-3.106618 -1.2426477,-3.106618 z"
+ id="path4762-8-3"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccsccsc" />
+ <path
+ style="fill:#15a5ea;fill-opacity:0.99215686;stroke:none;display:inline"
+ d="m 2.4916056,40.000004 0,0.621323 8.0979164,0 0,-0.621323 z"
+ id="path4772-4"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccc" />
+ <path
+ style="fill:#15a5ea;fill-opacity:0.99215686;stroke:none;display:inline"
+ d="m 2.4916056,48.000004 8.0979164,0 0,-0.621324 -8.0979164,0 z"
+ id="path4774-6"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccc" />
+ <path
+ style="fill:#15a5ea;fill-opacity:0.99215686;stroke:none;display:inline"
+ d="m 2.4916056,41.919118 0,0.621323 1.8687499,0 0,-0.621323 z m 6.2291665,0 0,0.621323 1.8687499,0 0,-0.621323 z"
+ id="path4782-3"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccccc" />
+ <path
+ style="fill:#15a5ea;fill-opacity:0.99215686;stroke:none;display:inline"
+ d="m 2.4916056,45.589344 0,0.621322 1.8687499,0 0,-0.621322 z m 6.2291665,0 0,0.621322 1.8687499,0 0,-0.621322 z"
+ id="path4778-9"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccccc" />
+ <path
+ style="fill:#15a5ea;fill-opacity:0.99215686;stroke:none;display:inline"
+ d="m 2.4916056,44.404412 8.0979164,0 0,-0.621323 -8.0979164,0 z"
+ id="path4780-0"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccc" />
+ <path
+ style="fill:#15a5ea;fill-opacity:1;stroke:none;display:inline"
+ d="m 4.3603555,43.472427 0,1.242647 4.3604166,0 0,-1.242647 z"
+ id="path4793-0"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccc" />
+ <path
+ style="fill:#3366bb;fill-opacity:1;stroke:none;display:inline"
+ d="m 4,40.000004 0,8 5,0 0,-8 z m 1,1 3,0 0,6 -3,0 z"
+ id="path4768-5"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccccc" />
+ <path
+ style="fill:#3366bb;fill-opacity:1;stroke:none;display:inline"
+ d="m 2,39.000004 1,0 0,10 -1,0 z"
+ id="path4764-1"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccc" />
+ <path
+ style="fill:#3366bb;fill-opacity:1;stroke:none;display:inline"
+ d="m 10,39.000004 1,0 0,10 -1,0 z"
+ id="path4766-6"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccc" />
+ <path
+ style="fill:#15a5ea;fill-opacity:1;stroke:none;display:inline"
+ d="M 1.8400738,58.055766 6.5000005,62.405031 11.159927,58.055766 10.538604,57.434442 9.2959563,58.056049 6.5000005,60.541343 3.7040445,58.056049 2.4613977,57.434442 z"
+ id="path4800-4-9"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccccccc"
+ inkscape:export-filename="/home/rahah/elvidishu/steak/unreal/dev/skins/external link icons/mail.png"
+ inkscape:export-xdpi="90"
+ inkscape:export-ydpi="90" />
+ <path
+ style="fill:#3366bb;fill-opacity:1;stroke:none;display:inline"
+ d="m 1,57.000008 0,8 11,0 0,-8 z m 1,1 9,0 0,6 -9,0 z"
+ id="path4795-8-2"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccccc"
+ inkscape:export-filename="/home/rahah/elvidishu/steak/unreal/dev/skins/external link icons/mail.png"
+ inkscape:export-xdpi="90"
+ inkscape:export-ydpi="90" />
+ <path
+ style="fill:#3366bb;fill-opacity:1;stroke:none;display:inline"
+ d="M 1.6213238,72.000004 1,72.621328 l 0,5.757352 0.6213238,0.621324 1.6894529,0 L 2.6894528,81.727943 7,79.000004 l 4.378677,0 L 12,78.37868 12,72.621328 11.378677,72.000004 z m 0.3786762,1 9,0 0,5 -4,0 L 4.0978858,79.896603 5,78.000004 l -3,0 z"
+ id="path4802-8-9"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccccccccccccccc" />
+ <path
+ style="fill:#3366bb;fill-opacity:1;stroke:none;display:inline"
+ d="m 5,88.000004 7,0 0,7 -2,-2 -3,2 0,-1 3,-2.249999 1,1 0,-3.750001 -3.75,0 1,1 -2.25,3 -1,0 2,-3 z"
+ id="path4807-5-6"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccccccccccccc" />
+ <path
+ style="fill:#3366bb;fill-opacity:1;stroke:none;display:inline"
+ d="m 7,90.000004 -5,0 0,8 8,0 0,-5 -1,0 0,4 -6,0 0,-6 4,0 z"
+ id="path4809-7-3"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccccccccc" />
+ <path
+ style="fill:#15a5ea;fill-opacity:1;stroke:none;display:inline"
+ d="m 7.5,105.00001 0,3 2.5,0 1,-1 -2.5,0 0,-3 z"
+ id="path4818-67-7"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccccc" />
+ <path
+ style="fill:#3366bb;fill-opacity:1;stroke:none;display:inline"
+ d="m 3,104 0,10 8,1e-5 0,-7 -2.5,-3 z m 1,1 4,1e-5 2,2.50001 0,5.49999 -6,0 z"
+ id="path4813-2-2"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccccccc" />
+ <path
+ style="fill:#15a5ea;fill-opacity:1;stroke:none;display:inline"
+ d="m 4.0827206,92.533089 c 0,-0.621319 0.6213239,-0.621319 0.6213239,-0.621319 1.8639706,0.621319 3.1066175,1.863968 3.7279413,3.727935 0,0 0,0.621328 -0.6213238,0.621328 C 6.5680151,94.397065 5.9466913,93.775738 4.0827206,92.533089 z"
+ id="path4822-8-2"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccc" />
+ <path
+ style="fill:#3465ba;fill-opacity:1;stroke:none;display:inline"
+ d="m 1,8.500006 0,7.5001 1,-0.9872 0,-6.0129 2,0 2,1 4,-2e-4 0,2.0002 1,0 0,-2.5 -0.5,-0.5002 -4.5,2e-4 -2,-1 -2.5,0 z"
+ id="path3209"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccccccccccccc" />
+ <path
+ style="fill:#16a4e8;fill-opacity:1;stroke:none;display:inline"
+ d="m 6,11.000106 -1,-10e-5 -2.5,0 -0.5,0.5 -1,4.5001 10,-10e-5 0,-4.4998 -0.5,-0.5 z m 0,1 4,-10e-5 0,3 -7.75,-1e-4 0.75,-3.0001 2,0 z"
+ id="path3215"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cccccccccccccccc" />
+ <path
+ style="fill:#3465ba;fill-opacity:1;stroke:none;display:inline"
+ d="m 2,17.000006 0,1 8,0 0,-1 z"
+ id="path3247"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccc" />
+ <path
+ style="fill:#3465ba;fill-opacity:1;stroke:none;display:inline"
+ d="m 5,15.500006 0,2 2,0 0,-2 c 0,-0.5 -2,-0.5 -2,0 z"
+ id="path3249"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccc" />
+ </g>
+</svg>
diff --git a/resources/src/mediawiki.skinning/images/external-ltr.png b/resources/src/mediawiki.skinning/images/external-ltr.png
new file mode 100644
index 00000000..75750ef9
--- /dev/null
+++ b/resources/src/mediawiki.skinning/images/external-ltr.png
Binary files differ
diff --git a/resources/src/mediawiki.skinning/images/external-ltr.svg b/resources/src/mediawiki.skinning/images/external-ltr.svg
new file mode 100644
index 00000000..e914b7da
--- /dev/null
+++ b/resources/src/mediawiki.skinning/images/external-ltr.svg
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="13" width="13" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
+<g transform="translate(-36.467808,-258.39005)">
+<path style="opacity:1;fill:#3366bb;" d="m41.47,259.4,7,0,0,7-2-2-3,2,0-1,3-2.25,1,1,0-3.75-3.75,0,1,1-2.25,3-1,0,2-3z"/>
+<path style="opacity:1;fill:#3366bb;" d="m43.47,261.4-5,0,0,8,8,0,0-5-1,0,0,4-6,0,0-6,4,0z"/>
+<path style="opacity:1;fill:#15a5ea;" d="m40.55,263.9c0-0.6213,0.6213-0.6213,0.6213-0.6213,1.864,0.6213,3.107,1.864,3.728,3.728,0,0,0,0.6213-0.6213,0.6213-1.243-1.864-1.864-2.485-3.728-3.728z"/>
+</g>
+</svg>
diff --git a/resources/src/mediawiki.skinning/images/external-rtl.png b/resources/src/mediawiki.skinning/images/external-rtl.png
new file mode 100644
index 00000000..7b56aaa7
--- /dev/null
+++ b/resources/src/mediawiki.skinning/images/external-rtl.png
Binary files differ
diff --git a/resources/src/mediawiki.skinning/images/external-rtl.svg b/resources/src/mediawiki.skinning/images/external-rtl.svg
new file mode 100644
index 00000000..698c6163
--- /dev/null
+++ b/resources/src/mediawiki.skinning/images/external-rtl.svg
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="13" width="13" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
+<g transform="translate(-36.467808,-258.39005)">
+<path style="opacity:1;fill:#3366bb;" d="m44.47,259.4-7,0,0,7,2-2,3,2,0-1-3-2.25-1,1,0-3.75,3.75,0-1,1,2.25,3,1,0-2-3z"/>
+<path style="opacity:1;fill:#3366bb;" d="m42.47,261.4,5,0,0,8-8,0,0-5,1,0,0,4,6,0,0-6-4,0z"/>
+<path style="opacity:1;fill:#15a5ea;" d="m45.39,263.9c0-0.6213-0.6213-0.6213-0.6213-0.6213-1.864,0.6213-3.107,1.864-3.728,3.728l0.6213,0.6213c1.243-1.864,1.864-2.485,3.728-3.728z"/>
+</g>
+</svg>
diff --git a/resources/src/mediawiki.skinning/images/ftp-ltr.png b/resources/src/mediawiki.skinning/images/ftp-ltr.png
new file mode 100644
index 00000000..f940b067
--- /dev/null
+++ b/resources/src/mediawiki.skinning/images/ftp-ltr.png
Binary files differ
diff --git a/resources/src/mediawiki.skinning/images/ftp-ltr.svg b/resources/src/mediawiki.skinning/images/ftp-ltr.svg
new file mode 100644
index 00000000..3a90c311
--- /dev/null
+++ b/resources/src/mediawiki.skinning/images/ftp-ltr.svg
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="13" width="13" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
+<g transform="translate(-136.35715,-374.43362)">
+<path style="opacity:1;fill:#3465ba;" d="m137.4,376.9,0,7.5,1-0.9872,0-6.013,2,0,2,1,4-0.0002,0,2,1,0,0-2.5-0.5-0.5002-4.5,0.0002-2-1-2.5,0z"/>
+<path style="opacity:1;fill:#16a4e8;" d="m142.4,379.4-1-0.0001-2.5,0-0.5,0.5-1,4.5,10-0.0001,0-4.5-0.5-0.5zm0,1,4-0.0001,0,3-7.75-0.0001,0.75-3,2,0z"/>
+<path style="opacity:1;fill:#3465ba;" d="m138.4,385.4,0,1,8,0,0-1z"/>
+<path style="opacity:1;fill:#3465ba;" d="m141.4,383.9,0,2,2,0,0-2c0-0.5-2-0.5-2,0z"/>
+</g>
+</svg>
diff --git a/resources/src/mediawiki.skinning/images/ftp-rtl.png b/resources/src/mediawiki.skinning/images/ftp-rtl.png
new file mode 100644
index 00000000..ff763048
--- /dev/null
+++ b/resources/src/mediawiki.skinning/images/ftp-rtl.png
Binary files differ
diff --git a/resources/src/mediawiki.skinning/images/ftp-rtl.svg b/resources/src/mediawiki.skinning/images/ftp-rtl.svg
new file mode 100644
index 00000000..29e4b445
--- /dev/null
+++ b/resources/src/mediawiki.skinning/images/ftp-rtl.svg
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="13" width="13" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
+<g transform="translate(-136.35715,-374.43362)">
+<path style="opacity:1;fill:#3465ba;" d="m148.4,376.9,0,7.5-1-0.9872,0-6.013-2,0-2,1-4-0.0002,0,2-1,0,0-2.5,0.5-0.5002,4.5,0.0002,2-1,2.5,0z"/>
+<path style="opacity:1;fill:#16a4e8;" d="m143.4,379.4,1-0.0001,2.5,0,0.5,0.5,1,4.5-10-0.0001,0-4.5,0.5-0.5zm0,1-4-0.0001,0,3,7.75-0.0001-0.75-3-2,0z"/>
+<path style="opacity:1;fill:#3465ba;" d="m147.4,385.4,0,1-8,0,0-1z"/>
+<path style="opacity:1;fill:#3465ba;" d="m144.4,383.9,0,2-2,0,0-2c0-0.5,2-0.5,2,0z"/>
+</g>
+</svg>
diff --git a/resources/src/mediawiki.skinning/images/magnify-clip-ltr.png b/resources/src/mediawiki.skinning/images/magnify-clip-ltr.png
new file mode 100644
index 00000000..00a9cee1
--- /dev/null
+++ b/resources/src/mediawiki.skinning/images/magnify-clip-ltr.png
Binary files differ
diff --git a/resources/src/mediawiki.skinning/images/magnify-clip-rtl.png b/resources/src/mediawiki.skinning/images/magnify-clip-rtl.png
new file mode 100644
index 00000000..ff85c077
--- /dev/null
+++ b/resources/src/mediawiki.skinning/images/magnify-clip-rtl.png
Binary files differ
diff --git a/resources/src/mediawiki.skinning/images/mail.png b/resources/src/mediawiki.skinning/images/mail.png
new file mode 100644
index 00000000..5085611c
--- /dev/null
+++ b/resources/src/mediawiki.skinning/images/mail.png
Binary files differ
diff --git a/resources/src/mediawiki.skinning/images/mail.svg b/resources/src/mediawiki.skinning/images/mail.svg
new file mode 100644
index 00000000..5e534fed
--- /dev/null
+++ b/resources/src/mediawiki.skinning/images/mail.svg
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="13" width="13" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
+<g transform="translate(-36.47,-257.4)">
+<path style="fill:#15a5ea;" d="m38.31,261.4,4.66,4.349,4.66-4.349-0.6213-0.6213-1.243,0.6216-2.796,2.485-2.796-2.485-1.243-0.6216z"/>
+<path style="fill:#3366bb;" d="m37.47,260.4,0,8,11,0,0-8zm1,1,9,0,0,6-9,0z"/>
+</g>
+</svg>
diff --git a/resources/src/mediawiki.skinning/images/video.png b/resources/src/mediawiki.skinning/images/video.png
new file mode 100644
index 00000000..12f65453
--- /dev/null
+++ b/resources/src/mediawiki.skinning/images/video.png
Binary files differ
diff --git a/resources/src/mediawiki.skinning/images/video.svg b/resources/src/mediawiki.skinning/images/video.svg
new file mode 100644
index 00000000..d52d0db1
--- /dev/null
+++ b/resources/src/mediawiki.skinning/images/video.svg
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="13" width="13" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
+<g transform="translate(-36.4678,-258.39)">
+<path style="opacity:1;fill-opacity:1;fill:#15a5ea;" d="m38.96,260.4,0,0.6213,8.098,0,0-0.6213z"/>
+<path style="opacity:1;fill-opacity:1;fill:#15a5ea;" d="m38.96,268.4,8.098,0,0-0.6213-8.098,0z"/>
+<path style="opacity:1;fill-opacity:1;fill:#15a5ea;" d="m38.96,262.3,0,0.6213,1.869,0,0-0.6213zm6.229,0,0,0.6213,1.869,0,0-0.6213z"/>
+<path style="opacity:1;fill-opacity:1;fill:#15a5ea;" d="m38.96,266,0,0.6213,1.869,0,0-0.6213zm6.229,0,0,0.6213,1.869,0,0-0.6213z"/>
+<path style="opacity:1;fill-opacity:1;fill:#15a5ea;" d="m38.96,264.8,8.098,0,0-0.6213-8.098,0z"/>
+<path style="opacity:1;fill:#15a5ea;" d="m40.83,263.9,0,1.243,4.36,0,0-1.243z"/>
+<path style="opacity:1;fill:#3366bb;" d="m40.47,260.4,0,8,5,0,0-8zm1,1,3,0,0,6-3,0z"/>
+<path style="opacity:1;fill:#3366bb;" d="m38.47,259.4,1,0,0,10-1,0z"/>
+<path style="opacity:1;fill:#3366bb;" d="m46.47,259.4,1,0,0,10-1,0z"/>
+</g>
+</svg>
diff --git a/resources/src/mediawiki.skinning/interface.css b/resources/src/mediawiki.skinning/interface.css
new file mode 100644
index 00000000..398a132d
--- /dev/null
+++ b/resources/src/mediawiki.skinning/interface.css
@@ -0,0 +1,68 @@
+/**
+ * MediaWiki style sheet for common core styles on interfaces
+ *
+ * Styles for the Monobook/Vector pattern of laying out common interfaces.
+ * These ids/classes are not built into the system,
+ * they are outputted by the actual MonoBook/Vector code by convention.
+ */
+
+/* Categories */
+.catlinks {
+ border: 1px solid #aaa;
+ background-color: #f9f9f9;
+ padding: 5px;
+ margin-top: 1em;
+ clear: both;
+}
+
+.usermessage {
+ background-color: #ffce7b;
+ border: 1px solid #ffa500;
+ color: black;
+ font-weight: bold;
+ margin: 2em 0 1em;
+ padding: .5em 1em;
+ vertical-align: middle;
+}
+
+#siteNotice {
+ position: relative;
+ text-align: center;
+ margin: 0;
+}
+
+#localNotice {
+ margin-bottom: 0.9em;
+}
+
+.firstHeading {
+ margin-bottom: .1em;
+ /* These two rules hack around bug 2013 (fix for more limited bug 11325).
+ * When bug 2013 is fixed properly, they should be removed. */
+ line-height: 1.2em;
+ padding-bottom: 0;
+}
+
+/* Sub-navigation */
+#siteSub {
+ display: none;
+}
+
+#jump-to-nav {
+ /* Negate #contentSub's margin and replicate it so that the jump to links don't affect the spacing */
+ margin-top: -1.4em;
+ margin-bottom: 1.4em;
+}
+
+#contentSub,
+#contentSub2 {
+ font-size: 84%;
+ line-height: 1.2em;
+ margin: 0 0 1.4em 1em;
+ color: #545454;
+ width: auto;
+}
+
+span.subpages {
+ display: block;
+}
diff --git a/resources/src/mediawiki.special/images/glyph-people-large.png b/resources/src/mediawiki.special/images/glyph-people-large.png
new file mode 100644
index 00000000..0578be0b
--- /dev/null
+++ b/resources/src/mediawiki.special/images/glyph-people-large.png
Binary files differ
diff --git a/resources/src/mediawiki.special/images/icon-contributors.png b/resources/src/mediawiki.special/images/icon-contributors.png
new file mode 100644
index 00000000..f933aa69
--- /dev/null
+++ b/resources/src/mediawiki.special/images/icon-contributors.png
Binary files differ
diff --git a/resources/src/mediawiki.special/images/icon-edits.png b/resources/src/mediawiki.special/images/icon-edits.png
new file mode 100644
index 00000000..39f4f2de
--- /dev/null
+++ b/resources/src/mediawiki.special/images/icon-edits.png
Binary files differ
diff --git a/resources/src/mediawiki.special/images/icon-lock.png b/resources/src/mediawiki.special/images/icon-lock.png
new file mode 100644
index 00000000..03f0eecd
--- /dev/null
+++ b/resources/src/mediawiki.special/images/icon-lock.png
Binary files differ
diff --git a/resources/src/mediawiki.special/images/icon-pages.png b/resources/src/mediawiki.special/images/icon-pages.png
new file mode 100644
index 00000000..59513db2
--- /dev/null
+++ b/resources/src/mediawiki.special/images/icon-pages.png
Binary files differ
diff --git a/resources/src/mediawiki.special/mediawiki.special.block.css b/resources/src/mediawiki.special/mediawiki.special.block.css
new file mode 100644
index 00000000..a30a15df
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.block.css
@@ -0,0 +1,11 @@
+/*!
+ * Styling for Special:Block
+ */
+
+label[for="mw-input-wpConfirm"] {
+ font-weight: bold;
+}
+
+tr.mw-block-hideuser {
+ font-weight: bold;
+}
diff --git a/resources/src/mediawiki.special/mediawiki.special.block.js b/resources/src/mediawiki.special/mediawiki.special.block.js
new file mode 100644
index 00000000..8579e054
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.block.js
@@ -0,0 +1,45 @@
+/*!
+ * JavaScript for Special:Block
+ */
+( function ( mw, $ ) {
+ $( function () {
+ var $blockTarget = $( '#mw-bi-target' ),
+ $anonOnlyRow = $( '#mw-input-wpHardBlock' ).closest( 'tr' ),
+ $enableAutoblockRow = $( '#mw-input-wpAutoBlock' ).closest( 'tr' ),
+ $hideUser = $( '#mw-input-wpHideUser' ).closest( 'tr' ),
+ $watchUser = $( '#mw-input-wpWatch' ).closest( 'tr' );
+
+ function updateBlockOptions( instant ) {
+ var blocktarget = $.trim( $blockTarget.val() ),
+ isEmpty = blocktarget === '',
+ isIp = mw.util.isIPv4Address( blocktarget, true ) || mw.util.isIPv6Address( blocktarget, true ),
+ isIpRange = isIp && blocktarget.match( /\/\d+$/ );
+
+ if ( isIp && !isEmpty ) {
+ $enableAutoblockRow.goOut( instant );
+ $hideUser.goOut( instant );
+ } else {
+ $enableAutoblockRow.goIn( instant );
+ $hideUser.goIn( instant );
+ }
+ if ( !isIp && !isEmpty ) {
+ $anonOnlyRow.goOut( instant );
+ } else {
+ $anonOnlyRow.goIn( instant );
+ }
+ if ( isIpRange && !isEmpty ) {
+ $watchUser.goOut( instant );
+ } else {
+ $watchUser.goIn( instant );
+ }
+ }
+
+ if ( $blockTarget.length ) {
+ // Bind functions so they're checked whenever stuff changes
+ $blockTarget.keyup( updateBlockOptions );
+
+ // Call them now to set initial state (ie. Special:Block/Foobar?wpBlockExpiry=2+hours)
+ updateBlockOptions( /* instant= */ true );
+ }
+ } );
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.special/mediawiki.special.changeemail.css b/resources/src/mediawiki.special/mediawiki.special.changeemail.css
new file mode 100644
index 00000000..92983dfa
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.changeemail.css
@@ -0,0 +1,19 @@
+#mw-emailaddress-validity {
+ padding: 2px 1em;
+}
+#mw-emailaddress-validity {
+ border-bottom-right-radius: 0.8em;
+ border-top-right-radius: 0.8em;
+}
+
+/* Colors also used in mediawiki.special.preferences.css */
+#mw-emailaddress-validity.valid {
+ border: 1px solid #80FF80;
+ background-color: #C0FFC0;
+ color: black;
+}
+#mw-emailaddress-validity.invalid {
+ border: 1px solid #FF8080;
+ background-color: #FFC0C0;
+ color: black;
+}
diff --git a/resources/src/mediawiki.special/mediawiki.special.changeemail.js b/resources/src/mediawiki.special/mediawiki.special.changeemail.js
new file mode 100644
index 00000000..67531f78
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.changeemail.js
@@ -0,0 +1,52 @@
+/*!
+ * JavaScript for Special:ChangeEmail
+ */
+( function ( mw, $ ) {
+ /**
+ * Given an email validity status (true, false, null) update the label CSS class
+ * @ignore
+ */
+ function updateMailValidityLabel( mail ) {
+ var isValid = mw.util.validateEmail( mail ),
+ $label = $( '#mw-emailaddress-validity' );
+
+ // Set up the validity notice if it doesn't already exist
+ if ( $label.length === 0 ) {
+ $label = $( '<label for="wpNewEmail" id="mw-emailaddress-validity"></label>' )
+ .insertAfter( '#wpNewEmail' );
+ }
+
+ // We allow empty address
+ if ( isValid === null ) {
+ $label.text( '' ).removeClass( 'valid invalid' );
+
+ // Valid
+ } else if ( isValid ) {
+ $label.text( mw.msg( 'email-address-validity-valid' ) ).addClass( 'valid' ).removeClass( 'invalid' );
+
+ // Not valid
+ } else {
+ $label.text( mw.msg( 'email-address-validity-invalid' ) ).addClass( 'invalid' ).removeClass( 'valid' );
+ }
+ }
+
+ $( function () {
+ $( '#wpNewEmail' )
+ // Lame tip to let user know if its email is valid. See bug 22449.
+ // Only bind once for 'blur' so that the user can fill it in without errors;
+ // after that, look at every keypress for immediate feedback.
+ .one( 'blur', function () {
+ var $this = $( this );
+ updateMailValidityLabel( $this.val() );
+ $this.keyup( function () {
+ updateMailValidityLabel( $this.val() );
+ } );
+ } )
+ // Supress built-in validation notice and just call updateMailValidityLabel(),
+ // to avoid double notice. See bug 40909.
+ .on( 'invalid', function ( e ) {
+ e.preventDefault();
+ updateMailValidityLabel( $( this ).val() );
+ } );
+ } );
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.special/mediawiki.special.changeslist.css b/resources/src/mediawiki.special/mediawiki.special.changeslist.css
new file mode 100644
index 00000000..c92db167
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.changeslist.css
@@ -0,0 +1,7 @@
+/*!
+ * Styling for Special:Watchlist and Special:RecentChanges
+ */
+
+.mw-changeslist-line-watched .mw-title {
+ font-weight: bold;
+}
diff --git a/resources/src/mediawiki.special/mediawiki.special.changeslist.enhanced.css b/resources/src/mediawiki.special/mediawiki.special.changeslist.enhanced.css
new file mode 100644
index 00000000..0e026aff
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.changeslist.enhanced.css
@@ -0,0 +1,61 @@
+/*!
+ * Styling for Special:Watchlist and Special:RecentChanges when preference 'usenewrc'
+ * a.k.a. Enhanced Recent Changes is enabled.
+ */
+
+table.mw-enhanced-rc {
+ border: 0;
+ border-spacing: 0;
+}
+
+table.mw-enhanced-rc th,
+table.mw-enhanced-rc td {
+ padding: 0;
+ vertical-align: top;
+}
+
+td.mw-enhanced-rc {
+ white-space: nowrap;
+ font-family: monospace;
+}
+
+.mw-enhanced-rc-time {
+ font-family: monospace;
+}
+
+table.mw-enhanced-rc td.mw-enhanced-rc-nested {
+ padding-left: 1em;
+}
+
+/* Show/hide arrows in enhanced changeslist */
+.mw-enhanced-rc .collapsible-expander {
+ float: none;
+}
+
+/* If JS is disabled, the arrows or the placeholder space shouldn't be shown */
+.client-nojs .mw-enhancedchanges-arrow-space {
+ display: none;
+}
+
+/*
+ * And if it's enabled, let's optimize the collapsing a little: hide the rows
+ * that would be hidden by jquery.makeCollapsible with CSS to save us some
+ * reflows and repaints. This doesn't work on browsers that don't fully support
+ * CSS2 (IE6), but it's okay, this will be done in JavaScript with old degraded
+ * performance instead.
+ */
+.client-js table.mw-enhanced-rc.mw-collapsed tr + tr {
+ display: none;
+}
+
+.mw-enhancedchanges-arrow-space {
+ display: inline-block;
+ *display: inline; /* IE7 and below */
+ zoom: 1;
+ width: 15px;
+ height: 15px;
+}
+
+.mw-enhanced-watched .mw-enhanced-rc-time {
+ font-weight: bold;
+}
diff --git a/resources/src/mediawiki.special/mediawiki.special.changeslist.legend.css b/resources/src/mediawiki.special/mediawiki.special.changeslist.legend.css
new file mode 100644
index 00000000..6b0bf991
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.changeslist.legend.css
@@ -0,0 +1,29 @@
+/*!
+ * Styling for changes list legend
+ */
+
+.mw-changeslist-legend {
+ float: right;
+ margin-left: 1em;
+ margin-bottom: 0.5em;
+ clear: right;
+ font-size: 85%;
+ line-height: 1.2em;
+ padding: 0.5em;
+ border: 1px solid #ddd;
+}
+
+.mw-changeslist-legend dl {
+ /* Parent element defines sufficient padding */
+ margin-bottom: 0;
+}
+
+.mw-changeslist-legend dt {
+ float: left;
+ margin-right: 0.5em;
+}
+
+.mw-changeslist-legend dd {
+ margin-left: 1.5em;
+ line-height: 1.3em;
+}
diff --git a/resources/src/mediawiki.special/mediawiki.special.changeslist.legend.js b/resources/src/mediawiki.special/mediawiki.special.changeslist.legend.js
new file mode 100644
index 00000000..c9e55111
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.changeslist.legend.js
@@ -0,0 +1,25 @@
+/*!
+ * Script for changes list legend
+ */
+
+/* Remember the collapse state of the legend on recent changes and watchlist pages. */
+jQuery( document ).ready( function ( $ ) {
+ var
+ cookieName = 'changeslist-state',
+ cookieOptions = {
+ expires: 30,
+ path: '/'
+ },
+ isCollapsed = $.cookie( cookieName ) === 'collapsed';
+
+ $( '.mw-changeslist-legend' )
+ .makeCollapsible( {
+ collapsed: isCollapsed
+ } )
+ .on( 'beforeExpand.mw-collapsible', function () {
+ $.cookie( cookieName, 'expanded', cookieOptions );
+ } )
+ .on( 'beforeCollapse.mw-collapsible', function () {
+ $.cookie( cookieName, 'collapsed', cookieOptions );
+ } );
+} );
diff --git a/resources/src/mediawiki.special/mediawiki.special.css b/resources/src/mediawiki.special/mediawiki.special.css
new file mode 100644
index 00000000..0356fc74
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.css
@@ -0,0 +1,120 @@
+/* Special:AllMessages */
+#mw-allmessagestable .allmessages-customised td.am_default {
+ background-color: #fcffc4;
+}
+
+#mw-allmessagestable tr.allmessages-customised:hover td.am_default {
+ background-color: #faff90;
+}
+
+#mw-allmessagestable td.am_actual {
+ background-color: #e2ffe2;
+}
+
+#mw-allmessagestable tr.allmessages-customised:hover + tr.allmessages-customised td.am_actual {
+ background-color: #b1ffb1;
+}
+
+/* Special:Allpages */
+table.mw-allpages-table-form {
+ width: 100%;
+}
+table.mw-allpages-table-form tr {
+ vertical-align: top;
+}
+.mw-allpages-nav {
+ text-align: right;
+ margin-bottom: 1em;
+}
+
+ul.mw-allpages-chunk {
+ margin: 0;
+ padding: 0;
+}
+ul.mw-allpages-chunk li {
+ border-top: 1px solid #ccc;
+ display: inline-block;
+ margin: 0 1% 0 0;
+ padding: .2em 0;
+ vertical-align: top;
+ width: 31%;
+}
+
+/* Special:BlockList */
+table.mw-blocklist span.mw-usertoollinks,
+span.mw-blocklist-actions {
+ white-space: nowrap;
+ font-size: 90%;
+}
+
+/* Special:Contributions */
+.mw-uctop {
+ font-weight: bold;
+}
+
+/* Special:EmailUser */
+td#mw-emailuser-sender,
+td#mw-emailuser-recipient {
+ font-weight: bold;
+}
+
+/* Special:ListGroupRights */
+table.mw-listgrouprights-table tr {
+ vertical-align: top;
+}
+.listgrouprights-revoked {
+ text-decoration: line-through;
+}
+
+/* Special:Prefixindex */
+table.mw-prefixindex-list-table,
+table#mw-prefixindex-nav-table {
+ width: 100%;
+}
+td#mw-prefixindex-nav-form {
+ margin-bottom: 1em;
+ vertical-align: top;
+}
+.mw-prefixindex-nav {
+ text-align: right;
+}
+
+/* Special:Specialpages */
+.mw-specialpagerestricted {
+ font-weight: bold;
+}
+
+.mw-specialpages-table {
+ margin-top: -1em;
+ margin-bottom: 1em;
+}
+
+.mw-specialpages-table td {
+ vertical-align: top;
+}
+
+/* Special:Statistics */
+td.mw-statistics-numbers {
+ text-align: right;
+}
+
+/* Special:ProtectedPages */
+table.mw-protectedpages span.mw-usertoollinks,
+span.mw-protectedpages-length,
+span.mw-protectedpages-actions {
+ white-space: nowrap;
+ font-size: 90%;
+}
+span.mw-protectedpages-unknown {
+ color: grey;
+ font-size: 90%;
+}
+
+/* Special:UserRights */
+.mw-userrights-disabled {
+ color: #888;
+}
+table.mw-userrights-groups * td,
+table.mw-userrights-groups * th {
+ padding-right: 1.5em;
+}
diff --git a/resources/src/mediawiki.special/mediawiki.special.import.js b/resources/src/mediawiki.special/mediawiki.special.import.js
new file mode 100644
index 00000000..a9a985eb
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.import.js
@@ -0,0 +1,35 @@
+/*!
+ * JavaScript for Special:Import
+ */
+( function ( $ ) {
+ function updateImportSubprojectList() {
+ var $projectField = $( '#mw-import-table-interwiki #interwiki' ),
+ $subprojectField = $projectField.parent().find( '#subproject' ),
+ $selected = $projectField.find( ':selected' ),
+ oldValue = $subprojectField.val(),
+ option, options;
+
+ if ( $selected.attr( 'data-subprojects' ) ) {
+ options = $.map( $selected.attr( 'data-subprojects' ).split( ' ' ), function ( el ) {
+ option = document.createElement( 'option' );
+ option.appendChild( document.createTextNode( el ) );
+ option.setAttribute( 'value', el );
+ if ( oldValue === el ) {
+ option.setAttribute( 'selected', 'selected' );
+ }
+ return option;
+ } );
+ $subprojectField.show().empty().append( options );
+ } else {
+ $subprojectField.hide();
+ }
+ }
+
+ $( function () {
+ var $projectField = $( '#mw-import-table-interwiki #interwiki' );
+ if ( $projectField.length ) {
+ $projectField.change( updateImportSubprojectList );
+ updateImportSubprojectList();
+ }
+ } );
+}( jQuery ) );
diff --git a/resources/src/mediawiki.special/mediawiki.special.javaScriptTest.js b/resources/src/mediawiki.special/mediawiki.special.javaScriptTest.js
new file mode 100644
index 00000000..d3e8f299
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.javaScriptTest.js
@@ -0,0 +1,36 @@
+/*!
+ * JavaScript for Special:JavaScriptTest
+ */
+( function ( mw, $ ) {
+ $( function () {
+
+ // Create useskin dropdown menu and reload onchange to the selected skin
+ // (only if a framework was found, not on error pages).
+ $( '#mw-javascripttest-summary.mw-javascripttest-frameworkfound' ).append( function () {
+
+ var $html = $( '<p><label for="useskin">'
+ + mw.message( 'javascripttest-pagetext-skins' ).escaped()
+ + ' '
+ + '</label></p>' ),
+ select = '<select name="useskin" id="useskin">';
+
+ // Build <select> further
+ $.each( mw.config.get( 'wgAvailableSkins' ), function ( id ) {
+ select += '<option value="' + id + '"'
+ + ( mw.config.get( 'skin' ) === id ? ' selected="selected"' : '' )
+ + '>' + mw.message( 'skinname-' + id ).escaped() + '</option>';
+ } );
+ select += '</select>';
+
+ // Bind onchange event handler and append to form
+ $html.append(
+ $( select ).change( function () {
+ window.location = QUnit.url( { useskin: $( this ).val() } );
+ } )
+ );
+
+ return $html;
+ } );
+ } );
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.special/mediawiki.special.js b/resources/src/mediawiki.special/mediawiki.special.js
new file mode 100644
index 00000000..630d1624
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.js
@@ -0,0 +1,9 @@
+/*!
+ * Namespace for mediawiki.special.* modules
+ */
+
+/**
+ * @class mw.special
+ * @singleton
+ */
+mediaWiki.special = {};
diff --git a/resources/src/mediawiki.special/mediawiki.special.movePage.js b/resources/src/mediawiki.special/mediawiki.special.movePage.js
new file mode 100644
index 00000000..7e56050d
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.movePage.js
@@ -0,0 +1,6 @@
+/*!
+ * JavaScript for Special:MovePage
+ */
+jQuery( function ( $ ) {
+ $( '#wpReason, #wpNewTitleMain' ).byteLimit();
+} );
diff --git a/resources/src/mediawiki.special/mediawiki.special.pageLanguage.js b/resources/src/mediawiki.special/mediawiki.special.pageLanguage.js
new file mode 100644
index 00000000..ba7f7342
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.pageLanguage.js
@@ -0,0 +1,9 @@
+( function ( $ ) {
+ $( document ).ready( function () {
+
+ // Select the 'Language select' option if user is trying to select language
+ $( '#mw-pl-languageselector' ).on( 'click', function () {
+ $( '#mw-pl-options-2' ).prop( 'checked', true );
+ } );
+ } );
+} ( jQuery ) );
diff --git a/resources/src/mediawiki.special/mediawiki.special.pagesWithProp.css b/resources/src/mediawiki.special/mediawiki.special.pagesWithProp.css
new file mode 100644
index 00000000..7ef75d0c
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.pagesWithProp.css
@@ -0,0 +1,4 @@
+/* Distinguish actual data from information about it being hidden visually */
+.prop-value-hidden {
+ font-style: italic;
+}
diff --git a/resources/src/mediawiki.special/mediawiki.special.preferences.css b/resources/src/mediawiki.special/mediawiki.special.preferences.css
new file mode 100644
index 00000000..e27e34a0
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.preferences.css
@@ -0,0 +1,21 @@
+/* Reuses colors from mediawiki.special.changeemail.css */
+.mw-email-not-authenticated .mw-input,
+.mw-email-none .mw-input{
+ border: 1px solid #FF8080;
+ background-color: #FFC0C0;
+ color: black;
+}
+/* Authenticated email field has its own class too. Unstyled by default */
+/*
+.mw-email-authenticated .mw-input { }
+*/
+
+/*
+ * Hide, but keep accessible for screen-readers.
+ * Like .mw-jump, #jump-to-nav from shared.css
+ */
+.mw-navigation-hint {
+ overflow: hidden;
+ height: 0;
+ zoom: 1;
+}
diff --git a/resources/src/mediawiki.special/mediawiki.special.preferences.js b/resources/src/mediawiki.special/mediawiki.special.preferences.js
new file mode 100644
index 00000000..1f6429b2
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.preferences.js
@@ -0,0 +1,305 @@
+/*!
+ * JavaScript for Special:Preferences
+ */
+jQuery( function ( $ ) {
+ var $preftoc, $preferences, $fieldsets, $legends,
+ hash, labelFunc,
+ $tzSelect, $tzTextbox, $localtimeHolder, servertime,
+ $checkBoxes, savedWindowOnBeforeUnload;
+
+ labelFunc = function () {
+ return this.id.replace( /^mw-prefsection/g, 'preftab' );
+ };
+
+ $( '#prefsubmit' ).attr( 'id', 'prefcontrol' );
+ $preftoc = $( '<ul id="preftoc"></ul>' )
+ .attr( 'role', 'tablist' );
+ $preferences = $( '#preferences' )
+ .addClass( 'jsprefs' )
+ .before( $preftoc );
+ $fieldsets = $preferences.children( 'fieldset' )
+ .hide()
+ .attr( {
+ role: 'tabpanel',
+ 'aria-hidden': 'true',
+ 'aria-labelledby': labelFunc
+ } )
+ .addClass( 'prefsection' );
+ $legends = $fieldsets
+ .children( 'legend' )
+ .addClass( 'mainLegend' );
+
+ // Make sure the accessibility tip is selectable so that screen reader users take notice,
+ // but hide it per default to reduce interface clutter. Also make sure it becomes visible
+ // when selected. Similar to jquery.mw-jump
+ $( '<div>' ).addClass( 'mw-navigation-hint' )
+ .text( mediaWiki.msg( 'prefs-tabs-navigation-hint' ) )
+ .attr( 'tabIndex', 0 )
+ .on( 'focus blur', function ( e ) {
+ if ( e.type === 'blur' || e.type === 'focusout' ) {
+ $( this ).css( 'height', '0' );
+ } else {
+ $( this ).css( 'height', 'auto' );
+ }
+ } ).insertBefore( $preftoc );
+
+ /**
+ * It uses document.getElementById for security reasons (HTML injections in $()).
+ *
+ * @ignore
+ * @param String name: the name of a tab without the prefix ("mw-prefsection-")
+ * @param String mode: [optional] A hash will be set according to the current
+ * open section. Set mode 'noHash' to surpress this.
+ */
+ function switchPrefTab( name, mode ) {
+ var $tab, scrollTop;
+ // Handle hash manually to prevent jumping,
+ // therefore save and restore scrollTop to prevent jumping.
+ scrollTop = $( window ).scrollTop();
+ if ( mode !== 'noHash' ) {
+ window.location.hash = '#mw-prefsection-' + name;
+ }
+ $( window ).scrollTop( scrollTop );
+
+ $preftoc.find( 'li' ).removeClass( 'selected' )
+ .find( 'a' ).attr( {
+ tabIndex: -1,
+ 'aria-selected': 'false'
+ } );
+
+ $tab = $( document.getElementById( 'preftab-' + name ) );
+ if ( $tab.length ) {
+ $tab.attr( {
+ tabIndex: 0,
+ 'aria-selected': 'true'
+ } )
+ .focus()
+ .parent().addClass( 'selected' );
+
+ $preferences.children( 'fieldset' ).hide().attr( 'aria-hidden', 'true' );
+ $( document.getElementById( 'mw-prefsection-' + name ) ).show().attr( 'aria-hidden', 'false' );
+ }
+ }
+
+ // Populate the prefToc
+ $legends.each( function ( i, legend ) {
+ var $legend = $( legend ),
+ ident, $li, $a;
+ if ( i === 0 ) {
+ $legend.parent().show();
+ }
+ ident = $legend.parent().attr( 'id' );
+
+ $li = $( '<li>' )
+ .attr( 'role', 'presentation' )
+ .addClass( i === 0 ? 'selected' : '' );
+ $a = $( '<a>' )
+ .attr( {
+ id: ident.replace( 'mw-prefsection', 'preftab' ),
+ href: '#' + ident,
+ role: 'tab',
+ tabIndex: i === 0 ? 0 : -1,
+ 'aria-selected': i === 0 ? 'true' : 'false',
+ 'aria-controls': ident
+ } )
+ .text( $legend.text() );
+ $li.append( $a );
+ $preftoc.append( $li );
+ } );
+
+ // Enable keyboard users to use left and right keys to switch tabs
+ $preftoc.on( 'keydown', function ( event ) {
+ var keyLeft = 37,
+ keyRight = 39,
+ $el;
+
+ if ( event.keyCode === keyLeft ) {
+ $el = $( '#preftoc li.selected' ).prev().find( 'a' );
+ } else if ( event.keyCode === keyRight ) {
+ $el = $( '#preftoc li.selected' ).next().find( 'a' );
+ } else {
+ return;
+ }
+ if ( $el.length > 0 ) {
+ switchPrefTab( $el.attr( 'href' ).replace( '#mw-prefsection-', '' ) );
+ }
+ } );
+
+ // If we've reloaded the page or followed an open-in-new-window,
+ // make the selected tab visible.
+ hash = window.location.hash;
+ if ( hash.match( /^#mw-prefsection-[\w\-]+/ ) ) {
+ switchPrefTab( hash.replace( '#mw-prefsection-', '' ) );
+ }
+
+ // In browsers that support the onhashchange event we will not bind click
+ // handlers and instead let the browser do the default behavior (clicking the
+ // <a href="#.."> will naturally set the hash, handled by onhashchange.
+ // But other things that change the hash will also be catched (e.g. using
+ // the Back and Forward browser navigation).
+ // Note the special check for IE "compatibility" mode.
+ if ( 'onhashchange' in window &&
+ ( document.documentMode === undefined || document.documentMode >= 8 )
+ ) {
+ $( window ).on( 'hashchange', function () {
+ var hash = window.location.hash;
+ if ( hash.match( /^#mw-prefsection-[\w\-]+/ ) ) {
+ switchPrefTab( hash.replace( '#mw-prefsection-', '' ) );
+ } else if ( hash === '' ) {
+ switchPrefTab( 'personal', 'noHash' );
+ }
+ } );
+ // In older browsers we'll bind a click handler as fallback.
+ // We must not have onhashchange *and* the click handlers, other wise
+ // the click handler calls switchPrefTab() which sets the hash value,
+ // which triggers onhashcange and calls switchPrefTab() again.
+ } else {
+ $preftoc.on( 'click', 'li a', function ( e ) {
+ switchPrefTab( $( this ).attr( 'href' ).replace( '#mw-prefsection-', '' ) );
+ e.preventDefault();
+ } );
+ }
+
+ // Timezone functions.
+ // Guesses Timezone from browser and updates fields onchange.
+
+ $tzSelect = $( '#mw-input-wptimecorrection' );
+ $tzTextbox = $( '#mw-input-wptimecorrection-other' );
+ $localtimeHolder = $( '#wpLocalTime' );
+ servertime = parseInt( $( 'input[name="wpServerTime"]' ).val(), 10 );
+
+ function minutesToHours( min ) {
+ var tzHour = Math.floor( Math.abs( min ) / 60 ),
+ tzMin = Math.abs( min ) % 60,
+ tzString = ( ( min >= 0 ) ? '' : '-' ) + ( ( tzHour < 10 ) ? '0' : '' ) + tzHour +
+ ':' + ( ( tzMin < 10 ) ? '0' : '' ) + tzMin;
+ return tzString;
+ }
+
+ function hoursToMinutes( hour ) {
+ var minutes,
+ arr = hour.split( ':' );
+
+ arr[0] = parseInt( arr[0], 10 );
+
+ if ( arr.length === 1 ) {
+ // Specification is of the form [-]XX
+ minutes = arr[0] * 60;
+ } else {
+ // Specification is of the form [-]XX:XX
+ minutes = Math.abs( arr[0] ) * 60 + parseInt( arr[1], 10 );
+ if ( arr[0] < 0 ) {
+ minutes *= -1;
+ }
+ }
+ // Gracefully handle non-numbers.
+ if ( isNaN( minutes ) ) {
+ return 0;
+ } else {
+ return minutes;
+ }
+ }
+
+ function updateTimezoneSelection() {
+ var minuteDiff, localTime,
+ type = $tzSelect.val();
+
+ if ( type === 'guess' ) {
+ // Get browser timezone & fill it in
+ minuteDiff = -( new Date().getTimezoneOffset() );
+ $tzTextbox.val( minutesToHours( minuteDiff ) );
+ $tzSelect.val( 'other' );
+ $tzTextbox.prop( 'disabled', false );
+ } else if ( type === 'other' ) {
+ // Grab data from the textbox, parse it.
+ minuteDiff = hoursToMinutes( $tzTextbox.val() );
+ } else {
+ // Grab data from the $tzSelect value
+ minuteDiff = parseInt( type.split( '|' )[1], 10 ) || 0;
+ $tzTextbox.val( minutesToHours( minuteDiff ) );
+ }
+
+ // Determine local time from server time and minutes difference, for display.
+ localTime = servertime + minuteDiff;
+
+ // Bring time within the [0,1440) range.
+ while ( localTime < 0 ) {
+ localTime += 1440;
+ }
+ while ( localTime >= 1440 ) {
+ localTime -= 1440;
+ }
+ $localtimeHolder.text( mediaWiki.language.convertNumber( minutesToHours( localTime ) ) );
+ }
+
+ if ( $tzSelect.length && $tzTextbox.length ) {
+ $tzSelect.change( updateTimezoneSelection );
+ $tzTextbox.blur( updateTimezoneSelection );
+ updateTimezoneSelection();
+ }
+
+ // Preserve the tab after saving the preferences
+ // Not using cookies, because their deletion results are inconsistent.
+ // Not using jStorage due to its enormous size (for this feature)
+ if ( window.sessionStorage ) {
+ if ( sessionStorage.getItem( 'mediawikiPreferencesTab' ) !== null ) {
+ switchPrefTab( sessionStorage.getItem( 'mediawikiPreferencesTab' ), 'noHash' );
+ }
+ // Deleting the key, the tab states should be reset until we press Save
+ sessionStorage.removeItem( 'mediawikiPreferencesTab' );
+
+ $( '#mw-prefs-form' ).submit( function () {
+ var storageData = $( $preftoc ).find( 'li.selected a' ).attr( 'id' ).replace( 'preftab-', '' );
+ sessionStorage.setItem( 'mediawikiPreferencesTab', storageData );
+ } );
+ }
+
+ // To disable all 'namespace' checkboxes in Search preferences
+ // when 'Search in all namespaces' checkbox is ticked.
+ $checkBoxes = $( '#mw-htmlform-advancedsearchoptions input[id^=mw-input-wpsearchnamespaces]' );
+ if ( $( '#mw-input-wpsearcheverything' ).prop( 'checked' ) ) {
+ $checkBoxes.prop( 'disabled', true );
+ }
+ $( '#mw-input-wpsearcheverything' ).change( function () {
+ $checkBoxes.prop( 'disabled', $( this ).prop( 'checked' ) );
+ } );
+
+ // Set up a message to notify users if they try to leave the page without
+ // saving.
+ $( '#mw-prefs-form' ).data( 'origdata', $( '#mw-prefs-form' ).serialize() );
+ $( window )
+ .on( 'beforeunload.prefswarning', function () {
+ var retval;
+
+ // Check if anything changed
+ if ( $( '#mw-prefs-form' ).serialize() !== $( '#mw-prefs-form' ).data( 'origdata' ) ) {
+ // Return our message
+ retval = mediaWiki.msg( 'prefswarning-warning', mediaWiki.msg( 'saveprefs' ) );
+ }
+
+ // Unset the onbeforeunload handler so we don't break page caching in Firefox
+ savedWindowOnBeforeUnload = window.onbeforeunload;
+ window.onbeforeunload = null;
+ if ( retval !== undefined ) {
+ // ...but if the user chooses not to leave the page, we need to rebind it
+ setTimeout( function () {
+ window.onbeforeunload = savedWindowOnBeforeUnload;
+ }, 1 );
+ return retval;
+ }
+ } )
+ .on( 'pageshow.prefswarning', function () {
+ // Re-add onbeforeunload handler
+ if ( !window.onbeforeunload ) {
+ window.onbeforeunload = savedWindowOnBeforeUnload;
+ }
+ } );
+ $( '#mw-prefs-form' ).submit( function () {
+ // Unbind our beforeunload handler
+ $( window ).off( '.prefswarning' );
+ } );
+ $( '#mw-prefs-restoreprefs' ).click( function () {
+ // Unbind our beforeunload handler
+ $( window ).off( '.prefswarning' );
+ } );
+} );
diff --git a/resources/src/mediawiki.special/mediawiki.special.recentchanges.js b/resources/src/mediawiki.special/mediawiki.special.recentchanges.js
new file mode 100644
index 00000000..d43b62b0
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.recentchanges.js
@@ -0,0 +1,39 @@
+/*!
+ * JavaScript for Special:RecentChanges
+ */
+( function ( mw, $ ) {
+ var rc, $checkboxes, $select;
+
+ /**
+ * @class mw.special.recentchanges
+ * @singleton
+ */
+ rc = {
+ /**
+ * Handler to disable/enable the namespace selector checkboxes when the
+ * special 'all' namespace is selected/unselected respectively.
+ */
+ updateCheckboxes: function () {
+ // The option element for the 'all' namespace has an empty value
+ var isAllNS = $select.val() === '';
+
+ // Iterates over checkboxes and propagate the selected option
+ $checkboxes.prop( 'disabled', isAllNS );
+ },
+
+ /** */
+ init: function () {
+ $select = $( '#namespace' );
+ $checkboxes = $( '#nsassociated, #nsinvert' );
+
+ // Bind to change event, and trigger once to set the initial state of the checkboxes.
+ rc.updateCheckboxes();
+ $select.change( rc.updateCheckboxes );
+ }
+ };
+
+ $( rc.init );
+
+ mw.special.recentchanges = rc;
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.special/mediawiki.special.search.css b/resources/src/mediawiki.special/mediawiki.special.search.css
new file mode 100644
index 00000000..ef955077
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.search.css
@@ -0,0 +1,173 @@
+/* Special:Search */
+
+/*
+ * Fixes sister projects box moving down the extract
+ * of the first result (bug #16886).
+ * It only happens when the window is small and
+ * This changes slightly the layout for big screens
+ * where there was space for the extracts and the
+ * sister projects and thus it showed like in any
+ * other browser.
+ *
+ * This will only affect IE 7 and lower
+ */
+.searchresult {
+ display: inline !ie;
+}
+.searchresults {
+}
+.searchresults p {
+ margin-left: 0.4em;
+ margin-top: 1em;
+ margin-bottom: 1.2em;
+}
+div.searchresult {
+ font-size: 95%;
+ width: 38em;
+}
+.mw-search-results {
+ margin-left: 0.4em;
+}
+.mw-search-results li {
+ padding-bottom: 1.2em;
+ list-style: none;
+ list-style-image: none;
+}
+.mw-search-results li a {
+ font-size: 108%;
+}
+.mw-search-result-data {
+ color: green;
+ font-size: 97%;
+}
+.mw-search-formheader {
+ background-color: #f3f3f3;
+ margin-top: 1em;
+ border: 1px solid silver;
+}
+.mw-search-formheader div.search-types {
+ float: left;
+ padding-left: 0.25em;
+}
+.mw-search-formheader div.search-types ul {
+ margin: 0 !important;
+ padding: 0 !important;
+ list-style: none !important;
+}
+.mw-search-formheader div.search-types ul li {
+ float: left;
+ margin: 0;
+ padding: 0;
+}
+.mw-search-formheader div.search-types ul li a {
+ display: block;
+ padding: 0.5em;
+}
+.mw-search-formheader div.search-types ul li.current a {
+ color: #333333;
+ cursor: default;
+}
+.mw-search-formheader div.search-types ul li.current a:hover {
+ text-decoration: none;
+}
+#mw-search-top-table div.results-info {
+ float: right;
+ padding: 0.5em;
+ padding-right: 0.75em;
+ color: #666;
+ font-size: 95%;
+}
+
+fieldset#mw-searchoptions {
+ margin: 0;
+ padding: 0.5em 0.75em 0.75em 0.75em !important;
+ border: none;
+ background-color: #f9f9f9;
+ border: 1px solid silver !important;
+ border-top-width: 0 !important;
+}
+fieldset#mw-searchoptions legend {
+ display: none;
+}
+fieldset#mw-searchoptions h4 {
+ padding: 0;
+ margin: 0;
+ float: left;
+}
+fieldset#mw-searchoptions div#mw-search-togglebox {
+ float: right;
+}
+fieldset#mw-searchoptions div#mw-search-togglebox label {
+ margin-right: 0.25em;
+}
+fieldset#mw-searchoptions div#mw-search-togglebox input {
+ margin-left: 0.25em;
+}
+fieldset#mw-searchoptions table {
+ float: left;
+ margin-right: 3em;
+}
+fieldset#mw-searchoptions table td {
+ padding-right: 1em;
+ white-space: nowrap;
+}
+fieldset#mw-searchoptions div.divider {
+ clear: both;
+ border-bottom: 1px solid #DDDDDD;
+ padding-top: 0.5em;
+ margin-bottom: 0.5em;
+}
+td#mw-search-menu {
+ padding-left:6em;
+ font-size:85%;
+}
+div#mw-search-interwiki {
+ float: right;
+ width: 18em;
+ border: 1px solid #AAAAAA;
+ margin-top: 2ex;
+}
+div#mw-search-interwiki li {
+ font-size: 95%;
+}
+.mw-search-interwiki-more {
+ float: right;
+ font-size: 90%;
+}
+div#mw-search-interwiki-caption {
+ text-align: center;
+ font-weight: bold;
+ font-size: 95%;
+}
+.mw-search-interwiki-project {
+ font-size: 97%;
+ text-align: left;
+ padding: 0.15em 0.15em 0.2em 0.2em;
+ background-color: #ececec;
+ border-top: 1px solid #BBBBBB;
+}
+span.searchalttitle {
+ font-size: 95%;
+}
+div.searchdidyoumean {
+ font-size: 127%;
+ margin-top: 0.8em;
+ /* Note that this color won't affect the link, as desired. */
+ color: #c00;
+}
+div.searchdidyoumean em {
+ font-weight: bold;
+}
+.searchmatch {
+ font-weight: bold;
+}
+/* Advanced PowerSearch box */
+td#mw-search-togglebox {
+ text-align: right;
+}
+table#mw-search-powertable {
+ width: 100%;
+}
+form#powersearch {
+ clear: both;
+}
diff --git a/resources/src/mediawiki.special/mediawiki.special.search.js b/resources/src/mediawiki.special/mediawiki.special.search.js
new file mode 100644
index 00000000..b27fe349
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.search.js
@@ -0,0 +1,58 @@
+/*!
+ * JavaScript for Special:Search
+ */
+( function ( mw, $ ) {
+ $( function () {
+ var $checkboxes, $headerLinks;
+
+ // Emulate HTML5 autofocus behavior in non HTML5 compliant browsers
+ if ( !( 'autofocus' in document.createElement( 'input' ) ) ) {
+ $( 'input[autofocus]' ).eq( 0 ).focus();
+ }
+
+ // Create check all/none button
+ $checkboxes = $( '#powersearch input[id^=mw-search-ns]' );
+ $( '#mw-search-togglebox' ).append(
+ $( '<label>' )
+ .text( mw.msg( 'powersearch-togglelabel' ) )
+ ).append(
+ $( '<input type="button" />' )
+ .attr( 'id', 'mw-search-toggleall' )
+ .prop( 'value', mw.msg( 'powersearch-toggleall' ) )
+ .click( function () {
+ $checkboxes.prop( 'checked', true );
+ } )
+ ).append(
+ $( '<input type="button" />' )
+ .attr( 'id', 'mw-search-togglenone' )
+ .prop( 'value', mw.msg( 'powersearch-togglenone' ) )
+ .click( function () {
+ $checkboxes.prop( 'checked', false );
+ } )
+ );
+
+ // Change the header search links to what user entered
+ $headerLinks = $( '.search-types a' );
+ $( '#searchText, #powerSearchText' ).change( function () {
+ var searchterm = $( this ).val();
+ $headerLinks.each( function () {
+ var parts = $( this ).attr( 'href' ).split( 'search=' ),
+ lastpart = '',
+ prefix = 'search=';
+ if ( parts.length > 1 && parts[1].indexOf( '&' ) !== -1 ) {
+ lastpart = parts[1].slice( parts[1].indexOf( '&' ) );
+ } else {
+ prefix = '&search=';
+ }
+ this.href = parts[0] + prefix + encodeURIComponent( searchterm ) + lastpart;
+ } );
+ } ).trigger( 'change' );
+
+ // When saving settings, use the proper request method (POST instead of GET).
+ $( '#mw-search-powersearch-remember' ).change( function () {
+ this.form.method = this.checked ? 'post' : 'get';
+ } ).trigger( 'change' );
+
+ } );
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.special/mediawiki.special.undelete.js b/resources/src/mediawiki.special/mediawiki.special.undelete.js
new file mode 100644
index 00000000..2a153e88
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.undelete.js
@@ -0,0 +1,11 @@
+/*!
+ * JavaScript for Special:Undelete
+ */
+jQuery( function ( $ ) {
+ $( '#mw-undelete-invert' ).click( function ( e ) {
+ $( '#undelete input[type="checkbox"]' ).prop( 'checked', function ( i, val ) {
+ return !val;
+ } );
+ e.preventDefault();
+ } );
+} );
diff --git a/resources/src/mediawiki.special/mediawiki.special.unwatchedPages.css b/resources/src/mediawiki.special/mediawiki.special.unwatchedPages.css
new file mode 100644
index 00000000..054f45fc
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.unwatchedPages.css
@@ -0,0 +1,9 @@
+.mw-watched-item {
+ text-decoration: line-through;
+}
+
+.mw-watch-link-disabled {
+ pointer-events: none;
+ /* Fallback for older browsers not supporting pointer-events: none */
+ cursor: default;
+}
diff --git a/resources/src/mediawiki.special/mediawiki.special.unwatchedPages.js b/resources/src/mediawiki.special/mediawiki.special.unwatchedPages.js
new file mode 100644
index 00000000..8d3e86ae
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.unwatchedPages.js
@@ -0,0 +1,52 @@
+/*!
+ * JavaScript for Special:UnwatchedPages
+ */
+( function ( mw, $ ) {
+ $( function () {
+ $( 'a.mw-watch-link' ).click( function ( e ) {
+ var promise,
+ api = new mw.Api(),
+ $link = $( this ),
+ $subjectLink = $link.closest( 'li' ).children( 'a' ).eq( 0 ),
+ title = mw.util.getParamValue( 'title', $link.attr( 'href' ) );
+ // nice format
+ title = mw.Title.newFromText( title ).toText();
+ // Disable link whilst we're busy to avoid double handling
+ if ( $link.data( 'mwDisabled' ) ) {
+ // mw-watch-link-disabled disables pointer-events which prevents the click event
+ // from happening in the first place. In older browsers we kill the event here.
+ return false;
+ }
+ $link.data( 'mwDisabled', true ).addClass( 'mw-watch-link-disabled' );
+
+ // Use the class to determine whether to watch or unwatch
+ if ( !$subjectLink.hasClass( 'mw-watched-item' ) ) {
+ $link.text( mw.msg( 'watching' ) );
+ promise = api.watch( title ).done( function () {
+ $subjectLink.addClass( 'mw-watched-item' );
+ $link.text( mw.msg( 'unwatch' ) );
+ mw.notify( mw.msg( 'addedwatchtext-short', title ) );
+ } ).fail( function () {
+ $link.text( mw.msg( 'watch' ) );
+ mw.notify( mw.msg( 'watcherrortext', title ) );
+ } );
+ } else {
+ $link.text( mw.msg( 'unwatching' ) );
+ promise = api.unwatch( title ).done( function () {
+ $subjectLink.removeClass( 'mw-watched-item' );
+ $link.text( mw.msg( 'watch' ) );
+ mw.notify( mw.msg( 'removedwatchtext-short', title ) );
+ } ).fail( function () {
+ $link.text( mw.msg( 'unwatch' ) );
+ mw.notify( mw.msg( 'watcherrortext', title ) );
+ } );
+ }
+
+ promise.always( function () {
+ $link.data( 'mwDisabled', false ).removeClass( 'mw-watch-link-disabled' );
+ } );
+
+ e.preventDefault();
+ } );
+ } );
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.special/mediawiki.special.upload.js b/resources/src/mediawiki.special/mediawiki.special.upload.js
new file mode 100644
index 00000000..286befcc
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.upload.js
@@ -0,0 +1,565 @@
+/**
+ * JavaScript for Special:Upload
+ *
+ * @private
+ * @class mw.special.upload
+ * @singleton
+ */
+( function ( mw, $ ) {
+ var ajaxUploadDestCheck = mw.config.get( 'wgAjaxUploadDestCheck' ),
+ $license = $( '#wpLicense' ), uploadWarning, uploadLicense;
+
+ window.wgUploadWarningObj = uploadWarning = {
+ responseCache: { '': '&nbsp;' },
+ nameToCheck: '',
+ typing: false,
+ delay: 500, // ms
+ timeoutID: false,
+
+ keypress: function () {
+ if ( !ajaxUploadDestCheck ) {
+ return;
+ }
+
+ // Find file to upload
+ if ( !$( '#wpDestFile' ).length || !$( '#wpDestFile-warning' ).length ) {
+ return;
+ }
+
+ this.nameToCheck = $( '#wpDestFile' ).val();
+
+ // Clear timer
+ if ( this.timeoutID ) {
+ clearTimeout( this.timeoutID );
+ }
+ // Check response cache
+ if ( this.responseCache.hasOwnProperty( this.nameToCheck ) ) {
+ this.setWarning( this.responseCache[this.nameToCheck] );
+ return;
+ }
+
+ this.timeoutID = setTimeout( function () {
+ uploadWarning.timeout();
+ }, this.delay );
+ },
+
+ checkNow: function ( fname ) {
+ if ( !ajaxUploadDestCheck ) {
+ return;
+ }
+ if ( this.timeoutID ) {
+ clearTimeout( this.timeoutID );
+ }
+ this.nameToCheck = fname;
+ this.timeout();
+ },
+
+ timeout: function () {
+ var $spinnerDestCheck;
+ if ( !ajaxUploadDestCheck || this.nameToCheck === '' ) {
+ return;
+ }
+ $spinnerDestCheck = $.createSpinner().insertAfter( '#wpDestFile' );
+
+ ( new mw.Api() ).get( {
+ action: 'query',
+ titles: ( new mw.Title( this.nameToCheck, mw.config.get( 'wgNamespaceIds' ).file ) ).getPrefixedText(),
+ prop: 'imageinfo',
+ iiprop: 'uploadwarning',
+ indexpageids: ''
+ } ).done( function ( result ) {
+ var resultOut = '';
+ if ( result.query ) {
+ resultOut = result.query.pages[result.query.pageids[0]].imageinfo[0];
+ }
+ $spinnerDestCheck.remove();
+ uploadWarning.processResult( resultOut, uploadWarning.nameToCheck );
+ } );
+ },
+
+ processResult: function ( result, fileName ) {
+ this.setWarning( result.html );
+ this.responseCache[fileName] = result.html;
+ },
+
+ setWarning: function ( warning ) {
+ $( '#wpDestFile-warning' ).html( warning );
+
+ // Set a value in the form indicating that the warning is acknowledged and
+ // doesn't need to be redisplayed post-upload
+ if ( !warning ) {
+ $( '#wpDestFileWarningAck' ).val( '' );
+ } else {
+ $( '#wpDestFileWarningAck' ).val( '1' );
+ }
+
+ }
+ };
+
+ uploadLicense = {
+
+ responseCache: { '': '' },
+
+ fetchPreview: function ( license ) {
+ var $spinnerLicense;
+ if ( !mw.config.get( 'wgAjaxLicensePreview' ) ) {
+ return;
+ }
+ if ( this.responseCache.hasOwnProperty( license ) ) {
+ this.showPreview( this.responseCache[license] );
+ return;
+ }
+
+ $spinnerLicense = $.createSpinner().insertAfter( '#wpLicense' );
+
+ ( new mw.Api() ).get( {
+ action: 'parse',
+ text: '{{' + license + '}}',
+ title: $( '#wpDestFile' ).val() || 'File:Sample.jpg',
+ prop: 'text',
+ pst: ''
+ } ).done( function ( result ) {
+ $spinnerLicense.remove();
+ uploadLicense.processResult( result, license );
+ } );
+ },
+
+ processResult: function ( result, license ) {
+ this.responseCache[license] = result.parse.text['*'];
+ this.showPreview( this.responseCache[license] );
+ },
+
+ showPreview: function ( preview ) {
+ $( '#mw-license-preview' ).html( preview );
+ }
+
+ };
+
+ $( function () {
+ // Disable URL box if the URL copy upload source type is not selected
+ if ( !$( '#wpSourceTypeurl' ).prop( 'checked' ) ) {
+ $( '#wpUploadFileURL' ).prop( 'disabled', true );
+ }
+
+ // AJAX wpDestFile warnings
+ if ( ajaxUploadDestCheck ) {
+ // Insert an event handler that fetches upload warnings when wpDestFile
+ // has been changed
+ $( '#wpDestFile' ).change( function () {
+ uploadWarning.checkNow( $( this ).val() );
+ } );
+ // Insert a row where the warnings will be displayed just below the
+ // wpDestFile row
+ $( '#mw-htmlform-description tbody' ).append(
+ $( '<tr>' ).append(
+ $( '<td>' )
+ .attr( 'id', 'wpDestFile-warning' )
+ .attr( 'colspan', 2 )
+ )
+ );
+ }
+
+ if ( mw.config.get( 'wgAjaxLicensePreview' ) && $license.length ) {
+ // License selector check
+ $license.change( function () {
+ // We might show a preview
+ uploadLicense.fetchPreview( $license.val() );
+ } );
+
+ // License selector table row
+ $license.closest( 'tr' ).after(
+ $( '<tr>' ).append(
+ $( '<td>' ),
+ $( '<td>' ).attr( 'id', 'mw-license-preview' )
+ )
+ );
+ }
+
+ // fillDestFile setup
+ $.each( mw.config.get( 'wgUploadSourceIds' ), function ( index, sourceId ) {
+ $( '#' + sourceId ).change( function () {
+ var path, slash, backslash, fname;
+ if ( !mw.config.get( 'wgUploadAutoFill' ) ) {
+ return;
+ }
+ // Remove any previously flagged errors
+ $( '#mw-upload-permitted' ).attr( 'class', '' );
+ $( '#mw-upload-prohibited' ).attr( 'class', '' );
+
+ path = $( this ).val();
+ // Find trailing part
+ slash = path.lastIndexOf( '/' );
+ backslash = path.lastIndexOf( '\\' );
+ if ( slash === -1 && backslash === -1 ) {
+ fname = path;
+ } else if ( slash > backslash ) {
+ fname = path.slice( slash + 1 );
+ } else {
+ fname = path.slice( backslash + 1 );
+ }
+
+ // Clear the filename if it does not have a valid extension.
+ // URLs are less likely to have a useful extension, so don't include them in the
+ // extension check.
+ if (
+ mw.config.get( 'wgStrictFileExtensions' ) &&
+ mw.config.get( 'wgFileExtensions' ) &&
+ $( this ).attr( 'id' ) !== 'wpUploadFileURL'
+ ) {
+ if (
+ fname.lastIndexOf( '.' ) === -1 ||
+ $.inArray(
+ fname.slice( fname.lastIndexOf( '.' ) + 1 ).toLowerCase(),
+ $.map( mw.config.get( 'wgFileExtensions' ), function ( element ) {
+ return element.toLowerCase();
+ } )
+ ) === -1
+ ) {
+ // Not a valid extension
+ // Clear the upload and set mw-upload-permitted to error
+ $( this ).val( '' );
+ $( '#mw-upload-permitted' ).attr( 'class', 'error' );
+ $( '#mw-upload-prohibited' ).attr( 'class', 'error' );
+ // Clear wpDestFile as well
+ $( '#wpDestFile' ).val( '' );
+
+ return false;
+ }
+ }
+
+ // Replace spaces by underscores
+ fname = fname.replace( / /g, '_' );
+ // Capitalise first letter if needed
+ if ( mw.config.get( 'wgCapitalizeUploads' ) ) {
+ fname = fname.charAt( 0 ).toUpperCase().concat( fname.slice( 1 ) );
+ }
+
+ // Output result
+ if ( $( '#wpDestFile' ).length ) {
+ // Call decodeURIComponent function to remove possible URL-encoded characters
+ // from the file name (bug 30390). Especially likely with upload-form-url.
+ // decodeURIComponent can throw an exception if input is invalid utf-8
+ try {
+ $( '#wpDestFile' ).val( decodeURIComponent( fname ) );
+ } catch ( err ) {
+ $( '#wpDestFile' ).val( fname );
+ }
+ uploadWarning.checkNow( fname );
+ }
+ } );
+ } );
+ } );
+
+ // Add a preview to the upload form
+ $( function () {
+ /**
+ * Is the FileAPI available with sufficient functionality?
+ */
+ function hasFileAPI() {
+ return window.FileReader !== undefined;
+ }
+
+ /**
+ * Check if this is a recognizable image type...
+ * Also excludes files over 10M to avoid going insane on memory usage.
+ *
+ * TODO: Is there a way we can ask the browser what's supported in `<img>`s?
+ *
+ * TODO: Put SVG back after working around Firefox 7 bug <https://bugzilla.wikimedia.org/show_bug.cgi?id=31643>
+ *
+ * @param {File} file
+ * @return boolean
+ */
+ function fileIsPreviewable( file ) {
+ var known = ['image/png', 'image/gif', 'image/jpeg', 'image/svg+xml'],
+ tooHuge = 10 * 1024 * 1024;
+ return ( $.inArray( file.type, known ) !== -1 ) && file.size > 0 && file.size < tooHuge;
+ }
+
+ /**
+ * Show a thumbnail preview of PNG, JPEG, GIF, and SVG files prior to upload
+ * in browsers supporting HTML5 FileAPI.
+ *
+ * As of this writing, known good:
+ *
+ * - Firefox 3.6+
+ * - Chrome 7.something
+ *
+ * TODO: Check file size limits and warn of likely failures
+ *
+ * @param {File} file
+ */
+ function showPreview( file ) {
+ var $canvas,
+ ctx,
+ meta,
+ previewSize = 180,
+ thumb = $( '<div id="mw-upload-thumbnail" class="thumb tright">' +
+ '<div class="thumbinner">' +
+ '<div class="mw-small-spinner" style="width: 180px; height: 180px"></div>' +
+ '<div class="thumbcaption"><div class="filename"></div><div class="fileinfo"></div></div>' +
+ '</div>' +
+ '</div>' );
+
+ thumb.find( '.filename' ).text( file.name ).end()
+ .find( '.fileinfo' ).text( prettySize( file.size ) ).end();
+
+ $canvas = $( '<canvas width="' + previewSize + '" height="' + previewSize + '" ></canvas>' );
+ ctx = $canvas[0].getContext( '2d' );
+ $( '#mw-htmlform-source' ).parent().prepend( thumb );
+
+ fetchPreview( file, function ( dataURL ) {
+ var img = new Image(),
+ rotation = 0;
+
+ if ( meta && meta.tiff && meta.tiff.Orientation ) {
+ rotation = ( 360 - ( function () {
+ // See includes/media/Bitmap.php
+ switch ( meta.tiff.Orientation.value ) {
+ case 8:
+ return 90;
+ case 3:
+ return 180;
+ case 6:
+ return 270;
+ default:
+ return 0;
+ }
+ }() ) ) % 360;
+ }
+
+ img.onload = function () {
+ var info, width, height, x, y, dx, dy, logicalWidth, logicalHeight;
+
+ // Fit the image within the previewSizexpreviewSize box
+ if ( img.width > img.height ) {
+ width = previewSize;
+ height = img.height / img.width * previewSize;
+ } else {
+ height = previewSize;
+ width = img.width / img.height * previewSize;
+ }
+ // Determine the offset required to center the image
+ dx = ( 180 - width ) / 2;
+ dy = ( 180 - height ) / 2;
+ switch ( rotation ) {
+ // If a rotation is applied, the direction of the axis
+ // changes as well. You can derive the values below by
+ // drawing on paper an axis system, rotate it and see
+ // where the positive axis direction is
+ case 0:
+ x = dx;
+ y = dy;
+ logicalWidth = img.width;
+ logicalHeight = img.height;
+ break;
+ case 90:
+
+ x = dx;
+ y = dy - previewSize;
+ logicalWidth = img.height;
+ logicalHeight = img.width;
+ break;
+ case 180:
+ x = dx - previewSize;
+ y = dy - previewSize;
+ logicalWidth = img.width;
+ logicalHeight = img.height;
+ break;
+ case 270:
+ x = dx - previewSize;
+ y = dy;
+ logicalWidth = img.height;
+ logicalHeight = img.width;
+ break;
+ }
+
+ ctx.clearRect( 0, 0, 180, 180 );
+ ctx.rotate( rotation / 180 * Math.PI );
+ ctx.drawImage( img, x, y, width, height );
+ thumb.find( '.mw-small-spinner' ).replaceWith( $canvas );
+
+ // Image size
+ info = mw.msg( 'widthheight', logicalWidth, logicalHeight ) +
+ ', ' + prettySize( file.size );
+
+ $( '#mw-upload-thumbnail .fileinfo' ).text( info );
+ };
+ img.src = dataURL;
+ }, mw.config.get( 'wgFileCanRotate' ) ? function ( data ) {
+ /*jshint camelcase:false, nomen:false */
+ try {
+ meta = mw.libs.jpegmeta( data, file.fileName );
+ meta._binary_data = null;
+ } catch ( e ) {
+ meta = null;
+ }
+ } : null );
+ }
+
+ /**
+ * Start loading a file into memory; when complete, pass it as a
+ * data URL to the callback function. If the callbackBinary is set it will
+ * first be read as binary and afterwards as data URL. Useful if you want
+ * to do preprocessing on the binary data first.
+ *
+ * @param {File} file
+ * @param {Function} callback
+ * @param {Function} callbackBinary
+ */
+ function fetchPreview( file, callback, callbackBinary ) {
+ var reader = new FileReader();
+ if ( callbackBinary && 'readAsBinaryString' in reader ) {
+ // To fetch JPEG metadata we need a binary string; start there.
+ // todo:
+ reader.onload = function () {
+ callbackBinary( reader.result );
+
+ // Now run back through the regular code path.
+ fetchPreview( file, callback );
+ };
+ reader.readAsBinaryString( file );
+ } else if ( callbackBinary && 'readAsArrayBuffer' in reader ) {
+ // readAsArrayBuffer replaces readAsBinaryString
+ // However, our JPEG metadata library wants a string.
+ // So, this is going to be an ugly conversion.
+ reader.onload = function () {
+ var i,
+ buffer = new Uint8Array( reader.result ),
+ string = '';
+ for ( i = 0; i < buffer.byteLength; i++ ) {
+ string += String.fromCharCode( buffer[i] );
+ }
+ callbackBinary( string );
+
+ // Now run back through the regular code path.
+ fetchPreview( file, callback );
+ };
+ reader.readAsArrayBuffer( file );
+ } else if ( 'URL' in window && 'createObjectURL' in window.URL ) {
+ // Supported in Firefox 4.0 and above <https://developer.mozilla.org/en/DOM/window.URL.createObjectURL>
+ // WebKit has it in a namespace for now but that's ok. ;)
+ //
+ // Lifetime of this URL is until document close, which is fine
+ // for Special:Upload -- if this code gets used on longer-running
+ // pages, add a revokeObjectURL() when it's no longer needed.
+ //
+ // Prefer this over readAsDataURL for Firefox 7 due to bug reading
+ // some SVG files from data URIs <https://bugzilla.mozilla.org/show_bug.cgi?id=694165>
+ callback( window.URL.createObjectURL( file ) );
+ } else {
+ // This ends up decoding the file to base-64 and back again, which
+ // feels horribly inefficient.
+ reader.onload = function () {
+ callback( reader.result );
+ };
+ reader.readAsDataURL( file );
+ }
+ }
+
+ /**
+ * Format a file size attractively.
+ *
+ * TODO: Match numeric formatting
+ *
+ * @param {number} s
+ * @return {string}
+ */
+ function prettySize( s ) {
+ var sizeMsgs = ['size-bytes', 'size-kilobytes', 'size-megabytes', 'size-gigabytes'];
+ while ( s >= 1024 && sizeMsgs.length > 1 ) {
+ s /= 1024;
+ sizeMsgs = sizeMsgs.slice( 1 );
+ }
+ return mw.msg( sizeMsgs[0], Math.round( s ) );
+ }
+
+ /**
+ * Clear the file upload preview area.
+ */
+ function clearPreview() {
+ $( '#mw-upload-thumbnail' ).remove();
+ }
+
+ /**
+ * Check if the file does not exceed the maximum size
+ */
+ function checkMaxUploadSize( file ) {
+ var maxSize, $error;
+
+ function getMaxUploadSize( type ) {
+ var sizes = mw.config.get( 'wgMaxUploadSize' );
+
+ if ( sizes[type] !== undefined ) {
+ return sizes[type];
+ }
+ return sizes['*'];
+ }
+
+ $( '.mw-upload-source-error' ).remove();
+
+ maxSize = getMaxUploadSize( 'file' );
+ if ( file.size > maxSize ) {
+ $error = $( '<p class="error mw-upload-source-error" id="wpSourceTypeFile-error">' +
+ mw.message( 'largefileserver', file.size, maxSize ).escaped() + '</p>' );
+
+ $( '#wpUploadFile' ).after( $error );
+
+ return false;
+ }
+
+ return true;
+ }
+
+ /* Initialization */
+ if ( hasFileAPI() ) {
+ // Update thumbnail when the file selection control is updated.
+ $( '#wpUploadFile' ).change( function () {
+ clearPreview();
+ if ( this.files && this.files.length ) {
+ // Note: would need to be updated to handle multiple files.
+ var file = this.files[0];
+
+ if ( !checkMaxUploadSize( file ) ) {
+ return;
+ }
+
+ if ( fileIsPreviewable( file ) ) {
+ showPreview( file );
+ }
+ }
+ } );
+ }
+ } );
+
+ // Disable all upload source fields except the selected one
+ $( function () {
+ var i, $row,
+ $rows = $( '.mw-htmlform-field-UploadSourceField' );
+
+ /**
+ * @param {jQuery} $currentRow
+ * @return {Function} Handler
+ * @return {jQuery.Event} return.e
+ */
+ function createHandler( $currentRow ) {
+ return function () {
+ $( '.mw-upload-source-error' ).remove();
+ if ( this.checked ) {
+ // Disable all inputs
+ $rows.find( 'input[name!="wpSourceType"]' ).prop( 'disabled', true );
+ // Re-enable the current one
+ $currentRow.find( 'input' ).prop( 'disabled', false );
+ }
+ };
+ }
+
+ for ( i = $rows.length; i; i-- ) {
+ $row = $rows.eq( i - 1 );
+ $row
+ .find( 'input[name="wpSourceType"]' )
+ .change( createHandler( $row ) );
+ }
+ } );
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.special/mediawiki.special.userlogin.common.css b/resources/src/mediawiki.special/mediawiki.special.userlogin.common.css
new file mode 100644
index 00000000..28b14462
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.userlogin.common.css
@@ -0,0 +1,66 @@
+/* Styles for user login and signup forms */
+#mw-userlogin-help {
+ text-align: center;
+}
+
+.mw-ui-vform .mw-secure {
+ /* @embed */
+ background: url(images/icon-lock.png) no-repeat scroll left center transparent;
+ margin: 0 0 0 1px;
+ padding: 0 0 0 11px;
+}
+
+/*
+ * When inside the VForm style, disable the border that Vector and other skins
+ * put on the div surrounding the login/create account form.
+ * Also disable the margin and padding that Vector puts around the form.
+ */
+.mw-ui-container #userloginForm,
+.mw-ui-container #userlogin {
+ border: 0;
+ margin: 0;
+ padding: 0;
+}
+
+/* Reposition and resize language links, which appear on a per-wiki basis */
+.mw-ui-container #languagelinks {
+ margin-bottom: 2em;
+ font-size: 0.8em;
+}
+
+/* Put some space under template's header, which may contain CAPTCHA HTML.*/
+section.mw-form-header {
+ margin-bottom: 10px;
+}
+
+/* shuffled CAPTCHA */
+#wpCaptchaWord {
+ margin-top: 6px;
+}
+
+.mw-createacct-captcha-container {
+ background-color: #f8f8f8;
+ border: 1px solid #c9c9c9;
+ padding: 10px;
+ text-align: center;
+ margin-bottom: 15px;
+}
+
+.mw-createacct-captcha-assisted {
+ display: block;
+ margin-top: 0.5em;
+}
+
+/* Put a border around the fancycaptcha-image-container. */
+.mw-createacct-captcha-and-reload {
+ border: 1px solid #c9c9c9;
+ /* Other display formats end up too wide */
+ display: table-cell;
+ width: 270px;
+ background-color: #FFF;
+}
+
+/* Make the fancycaptcha-image-container full-width within its parent. */
+.fancycaptcha-image-container {
+ width: 100%;
+}
diff --git a/resources/src/mediawiki.special/mediawiki.special.userlogin.common.js b/resources/src/mediawiki.special/mediawiki.special.userlogin.common.js
new file mode 100644
index 00000000..247f8141
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.userlogin.common.js
@@ -0,0 +1,72 @@
+/*!
+ * JavaScript for login and signup forms.
+ */
+( function ( mw, $ ) {
+ // Move the FancyCaptcha image into a more attractive container.
+ // The CAPTCHA is in a <div class="captcha"> at the top of the form. If it's a FancyCaptcha,
+ // then we remove it and insert it lower down, in a customized div with just what we need (e.g.
+ // no 'fancycaptcha-createaccount' message).
+ function adjustFancyCaptcha( $content, buttonSubmit ) {
+ var $submit = $content.find( buttonSubmit ),
+ tabIndex,
+ $captchaStuff,
+ $captchaImageContainer,
+ // JavaScript can't yet parse the message 'createacct-imgcaptcha-help' when it
+ // contains a MediaWiki transclusion, so PHP parses it and sends the HTML.
+ // This is only set for the signup form (and undefined for login).
+ helpMsg = mw.config.get( 'wgCreateacctImgcaptchaHelp' ),
+ helpHtml = '';
+
+ if ( !$submit.length ) {
+ return;
+ }
+ tabIndex = $submit.prop( 'tabindex' ) - 1;
+ $captchaStuff = $content.find( '.captcha' );
+
+ if ( $captchaStuff.length ) {
+ // The FancyCaptcha has this class in the ConfirmEdit extension since 2013-04-18.
+ $captchaImageContainer = $captchaStuff.find( '.fancycaptcha-image-container' );
+ if ( $captchaImageContainer.length !== 1 ) {
+ return;
+ }
+
+ $captchaStuff.remove();
+
+ if ( helpMsg ) {
+ helpHtml = '<small class="mw-createacct-captcha-assisted">' + helpMsg + '</small>';
+ }
+
+ // Insert another div before the submit button that will include the
+ // repositioned FancyCaptcha div, an input field, and possible help.
+ $submit.closest( 'div' ).before( [
+ '<div>',
+ '<label for="wpCaptchaWord">' + mw.message( 'createacct-captcha' ).escaped() + '</label>',
+ '<div class="mw-createacct-captcha-container">',
+ '<div class="mw-createacct-captcha-and-reload" />',
+ '<input id="wpCaptchaWord" class="mw-ui-input" name="wpCaptchaWord" type="text" placeholder="' +
+ mw.message( 'createacct-imgcaptcha-ph' ).escaped() +
+ '" tabindex="' + tabIndex + '" autocapitalize="off" autocorrect="off">',
+ helpHtml,
+ '</div>',
+ '</div>'
+ ].join( '' ) );
+
+ // Stick the FancyCaptcha container inside our bordered and framed parents.
+ $captchaImageContainer
+ .prependTo( $content.find( '.mw-createacct-captcha-and-reload' ) );
+
+ // Find the input field, add the text (if any) of the existing CAPTCHA
+ // field (although usually it's blanked out on every redisplay),
+ // and after it move over the hidden field that tells the CAPTCHA
+ // what to do.
+ $content.find( '#wpCaptchaWord' )
+ .val( $captchaStuff.find( '#wpCaptchaWord' ).val() )
+ .after( $captchaStuff.find( '#wpCaptchaId' ) );
+ }
+ }
+
+ $( function () {
+ // Work with both login and signup form
+ adjustFancyCaptcha( $( '#mw-content-text' ), '#wpCreateaccount, #wpLoginAttempt' );
+ } );
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.special/mediawiki.special.userlogin.login.css b/resources/src/mediawiki.special/mediawiki.special.userlogin.login.css
new file mode 100644
index 00000000..64471b27
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.userlogin.login.css
@@ -0,0 +1,22 @@
+/* The login form invites users to create an account */
+#mw-createaccount-cta {
+ width: 20em;
+ height: 10em;
+ /* @embed */
+ background: url(images/glyph-people-large.png) no-repeat 50%;
+ margin: 0 auto;
+ padding-top: 4em;
+}
+
+#mw-createaccount-cta,
+#mw-createaccount-another {
+ font-size: 0.9em;
+ font-weight: normal;
+ text-align: center;
+}
+
+#mw-createaccount-join {
+ margin-left: 0.75em;
+ width: auto;
+ display: inline-block;
+}
diff --git a/resources/src/mediawiki.special/mediawiki.special.userlogin.signup.css b/resources/src/mediawiki.special/mediawiki.special.userlogin.signup.css
new file mode 100644
index 00000000..0998d4ca
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.userlogin.signup.css
@@ -0,0 +1,66 @@
+/* Disable the underline that Vector puts on h2 headings, and bold them. */
+.mw-ui-container h2 {
+ border: 0;
+ font-weight: bold;
+}
+
+/* Benefits column CSS to the right (if it fits) of the form. */
+.mw-ui-container #userloginForm {
+ float: left;
+ /* Override the right margin of the form to give space in case a benefits
+ * column appears to the side. */
+ margin-right: 100px;
+}
+
+div.mw-createacct-benefits-container {
+ /* Keeps this column compact and close to the form, but tends to squish contents. */
+ float: left;
+}
+
+div.mw-createacct-benefits-container h2 {
+ margin-bottom: 30px;
+}
+
+.mw-number-text.icon-edits {
+ /* @embed */
+ background: url(images/icon-edits.png) no-repeat left center;
+}
+
+.mw-number-text.icon-pages {
+ /* @embed */
+ background: url(images/icon-pages.png) no-repeat left center;
+}
+
+.mw-number-text.icon-contributors {
+ /* @embed */
+ background: url(images/icon-contributors.png) no-repeat left center;
+}
+
+/*
+ * Special font for numbers in benefits, same as Vector's @content-heading-font-family.
+ * Needs an ID so that it's more specific than Vector's div#content h3.
+ */
+#bodyContent div.mw-number-text h3 {
+ top: 0;
+ margin: 0;
+ padding: 0;
+ color: #252525;
+ font-family: "Linux Libertine", Georgia, Times, serif;
+ font-weight: normal;
+ font-size: 2.2em;
+ line-height: 1.2;
+ text-align: center;
+}
+
+/* Contains a number and explanatory text, with space for an icon */
+div.mw-number-text {
+ display: block;
+ font-size: 1.2em;
+ color: #444;
+ margin-top: 1em;
+ /* 80px wide icon plus "margin" */
+ padding: 0 0 0 95px;
+ /* Matches max icon height, ensures icon emblem is visible */
+ min-height: 75px;
+ text-align: center;
+}
diff --git a/resources/src/mediawiki.special/mediawiki.special.userlogin.signup.js b/resources/src/mediawiki.special/mediawiki.special.userlogin.signup.js
new file mode 100644
index 00000000..68d3f61b
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.userlogin.signup.js
@@ -0,0 +1,140 @@
+/*!
+ * JavaScript for signup form.
+ */
+( function ( mw, $ ) {
+ // When sending password by email, hide the password input fields.
+ $( function () {
+ // Always required if checked, otherwise it depends, so we use the original
+ var $emailLabel = $( 'label[for="wpEmail"]' ),
+ originalText = $emailLabel.text(),
+ requiredText = mw.message( 'createacct-emailrequired' ).text(),
+ $createByMailCheckbox = $( '#wpCreateaccountMail' ),
+ $beforePwds = $( '.mw-row-password:first' ).prev(),
+ $pwds;
+
+ function updateForCheckbox() {
+ var checked = $createByMailCheckbox.prop( 'checked' );
+ if ( checked ) {
+ $pwds = $( '.mw-row-password' ).detach();
+ $emailLabel.text( requiredText );
+ } else {
+ if ( $pwds ) {
+ $beforePwds.after( $pwds );
+ $pwds = null;
+ }
+ $emailLabel.text( originalText );
+ }
+ }
+
+ $createByMailCheckbox.on( 'change', updateForCheckbox );
+ updateForCheckbox();
+ } );
+
+ // Check if the username is invalid or already taken
+ $( function () {
+ var
+ // We need to hook to all of these events to be sure we are notified of all changes to the
+ // value of an <input type=text> field.
+ events = 'keyup keydown change mouseup cut paste focus blur',
+ $input = $( '#wpName2' ),
+ $statusContainer = $( '#mw-createacct-status-area' ),
+ api = new mw.Api(),
+ currentRequest;
+
+ // Hide any present status messages.
+ function clearStatus() {
+ $statusContainer.slideUp( function () {
+ $statusContainer
+ .removeAttr( 'class' )
+ .empty();
+ } );
+ }
+
+ // Returns a promise receiving a { state:, username: } object, where:
+ // * 'state' is one of 'invalid', 'taken', 'ok'
+ // * 'username' is the validated username if 'state' is 'ok', null otherwise (if it's not
+ // possible to register such an account)
+ function checkUsername( username ) {
+ // We could just use .then() if we didn't have to pass on .abort()…
+ var d, apiPromise;
+
+ d = $.Deferred();
+ apiPromise = api.get( {
+ action: 'query',
+ list: 'users',
+ ususers: username // '|' in usernames is handled below
+ } )
+ .done( function ( resp ) {
+ var userinfo = resp.query.users[0];
+
+ if ( resp.query.users.length !== 1 ) {
+ // Happens if the user types '|' into the field
+ d.resolve( { state: 'invalid', username: null } );
+ } else if ( userinfo.invalid !== undefined ) {
+ d.resolve( { state: 'invalid', username: null } );
+ } else if ( userinfo.userid !== undefined ) {
+ d.resolve( { state: 'taken', username: null } );
+ } else {
+ d.resolve( { state: 'ok', username: username } );
+ }
+ } )
+ .fail( d.reject );
+
+ return d.promise( { abort: apiPromise.abort } );
+ }
+
+ function updateUsernameStatus() {
+ var
+ username = $.trim( $input.val() ),
+ currentRequestInternal;
+
+ // Abort any pending requests.
+ if ( currentRequest ) {
+ currentRequest.abort();
+ }
+
+ if ( username === '' ) {
+ clearStatus();
+ return;
+ }
+
+ currentRequest = currentRequestInternal = checkUsername( username ).done( function ( info ) {
+ var message;
+
+ // Another request was fired in the meantime, the result we got here is no longer current.
+ // This shouldn't happen as we abort pending requests, but you never know.
+ if ( currentRequest !== currentRequestInternal ) {
+ return;
+ }
+ // If we're here, then the current request has finished, avoid calling .abort() needlessly.
+ currentRequest = undefined;
+
+ if ( info.state === 'ok' ) {
+ clearStatus();
+ } else {
+ if ( info.state === 'invalid' ) {
+ message = mw.message( 'noname' ).text();
+ } else if ( info.state === 'taken' ) {
+ message = mw.message( 'userexists' ).text();
+ }
+
+ $statusContainer
+ .attr( 'class', 'errorbox' )
+ .empty()
+ .append(
+ // Ugh…
+ // @todo Change the HTML structure in includes/templates/Usercreate.php
+ $( '<strong>' ).text( mw.message( 'createacct-error' ).text() ),
+ $( '<br>' ),
+ document.createTextNode( message )
+ )
+ .slideDown();
+ }
+ } ).fail( function () {
+ clearStatus();
+ } );
+ }
+
+ $input.on( events, $.debounce( 250, updateUsernameStatus ) );
+ } );
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki.special/mediawiki.special.version.css b/resources/src/mediawiki.special/mediawiki.special.version.css
new file mode 100644
index 00000000..764e3777
--- /dev/null
+++ b/resources/src/mediawiki.special/mediawiki.special.version.css
@@ -0,0 +1,14 @@
+/*!
+ * Styling for Special:Version
+ */
+.mw-version-ext-name {
+ font-weight: bold;
+}
+
+.mw-version-ext-vcs-timestamp {
+ white-space: nowrap;
+}
+
+th.mw-version-ext-col-label {
+ font-size: 0.9em;
+}
diff --git a/resources/src/mediawiki.ui/components/anchors.less b/resources/src/mediawiki.ui/components/anchors.less
new file mode 100644
index 00000000..e1b258dd
--- /dev/null
+++ b/resources/src/mediawiki.ui/components/anchors.less
@@ -0,0 +1,77 @@
+@import "mediawiki.mixins";
+@import "mediawiki.ui/variables";
+@import "mediawiki.ui/mixins";
+
+// Helpers
+.mw-ui-anchor( @mainColor ) {
+ // Make all context classes take the main color in IE6
+ .select-ie6-only& {
+ &:link, &:visited, &:hover, &:focus, &:active {
+ color: @mainColor;
+ }
+ }
+
+ // Hover state
+ &:hover {
+ color: lighten( @mainColor, @colorLightenPercentage );
+ }
+ // Focus and active states
+ &:focus, &:active {
+ color: darken( @mainColor, @colorDarkenPercentage );
+ outline: none; // outline fix
+ }
+
+ color: @mainColor;
+
+ // Quiet mode is gray at first
+ &.mw-ui-quiet {
+ .mw-ui-anchor-quiet( @mainColor );
+ }
+}
+
+.mw-ui-anchor-quiet( @mainColor ) {
+ color: @colorTextLight;
+ text-decoration: none;
+
+ &:hover {
+ color: @mainColor;
+ }
+ &:focus, &:active {
+ color: darken( @mainColor, @colorDarkenPercentage );
+ }
+}
+
+/*
+Text & Anchors
+
+Allows you to give text a context as to the type of action it is indicating.
+
+Styleguide 6.
+*/
+
+/*
+Guidelines
+
+This context should only applied on elements without special behavior (DIV, SPAN, etc.), including A elements. These classes cannot be applied for styling purposes on other elements (such as form elements), except when used in combination with .mw-ui-button to alter a button context.
+
+Markup:
+<a href=# class="mw-ui-progressive {$modifiers}">Progressive</a>
+<a href=# class="mw-ui-constructive {$modifiers}">Constructive</a>
+<a href=# class="mw-ui-destructive {$modifiers}">Destructive</a>
+
+.mw-ui-quiet - Quiet until interaction.
+
+Styleguide 6.1.
+*/
+.mw-ui-progressive {
+ .mw-ui-anchor( @colorProgressive );
+}
+.mw-ui-constructive {
+ .mw-ui-anchor( @colorConstructive );
+}
+.mw-ui-destructive {
+ .mw-ui-anchor( @colorDestructive );
+}
+.mw-ui-quiet {
+ .mw-ui-anchor-quiet( @colorTextLight );
+}
diff --git a/resources/src/mediawiki.ui/components/buttons.less b/resources/src/mediawiki.ui/components/buttons.less
new file mode 100644
index 00000000..f6a44fd4
--- /dev/null
+++ b/resources/src/mediawiki.ui/components/buttons.less
@@ -0,0 +1,276 @@
+@import "mediawiki.mixins";
+@import "mediawiki.ui/variables";
+@import "mediawiki.ui/mixins";
+
+// Buttons
+//
+// All buttons start with mw-ui-button class, modified by other classes.
+// It can be any element. Due to a lack of a CSS reset, the exact styling of
+// the button depends on what type of element is used.
+// There are two kinds of buttons, the default is a "Call to Action" with an obvious border
+// and there is a quiet kind without a border.
+//
+// Styleguide 2.
+
+@transitionDuration: .1s;
+@transitionFunction: ease-in-out;
+
+// Neutral button styling
+//
+// Markup:
+// <div>
+// <button class="mw-ui-button">.mw-ui-button</button>
+// </div>
+// <div>
+// <button class="mw-ui-button" disabled>.mw-ui-button</button>
+// </div>
+//
+// Styleguide 2.1.
+.mw-ui-button {
+ // Inherit the font rather than apply user agent stylesheet (bug 70072)
+ font-family: inherit;
+ font-size: 1em;
+ // Container layout
+ display: inline-block;
+ padding: .5em 1em;
+ margin: 0;
+ .box-sizing(border-box);
+
+ // Disable weird iOS styling
+ -webkit-appearance: none;
+
+ // IE6/IE7 hack
+ // http://stackoverflow.com/a/5838575/365238
+ *display: inline;
+ zoom: 1;
+
+ // Container styling
+ .button-colors(#FFF);
+ border-radius: @borderRadius;
+ min-width: 80px;
+
+ // Ensure that buttons and inputs are nicely aligned when they have differing heights
+ vertical-align: middle;
+
+ // Content styling
+ text-align: center;
+ font-weight: bold;
+
+ // Interaction styling
+ cursor: pointer;
+
+ &:disabled {
+ text-shadow: none;
+ cursor: default;
+ }
+
+ .transition(background @transitionDuration @transitionFunction, color @transitionDuration @transitionFunction, box-shadow @transitionDuration @transitionFunction;);
+
+ // Styling for specific button types
+ // -----------------------------------------
+
+ // Big buttons
+ //
+ // Not all buttons are equal. You can emphasise certain actions over others
+ // using the mw-ui-big class.
+ //
+ // Markup:
+ // <div>
+ // <button class="mw-ui-button mw-ui-big">.mw-ui-button</button>
+ // </div>
+ // <div>
+ // <button class="mw-ui-button mw-ui-progressive mw-ui-big">.mw-ui-progressive</button>
+ // </div>
+ // <div>
+ // <button class="mw-ui-button mw-ui-constructive mw-ui-big">.mw-ui-constructive</button>
+ // </div>
+ // <div>
+ // <button class="mw-ui-button mw-ui-destructive mw-ui-big">.mw-ui-destructive</button>
+ // </div>
+ //
+ // Styleguide 2.1.6.
+ &.mw-ui-big {
+ font-size: 1.3em;
+ }
+
+ // Block buttons
+ //
+ // Some buttons might need to be stacked.
+ //
+ // Markup:
+ // <div>
+ // <button class="mw-ui-button mw-ui-block">.mw-ui-button</button>
+ // </div>
+ // <div>
+ // <button class="mw-ui-button mw-ui-progressive mw-ui-block">.mw-ui-progressive</button>
+ // </div>
+ // <div>
+ // <button class="mw-ui-button mw-ui-constructive mw-ui-block">.mw-ui-constructive</button>
+ // </div>
+ // <div>
+ // <button class="mw-ui-button mw-ui-destructive mw-ui-block">.mw-ui-destructive</button>
+ // </div>
+ //
+ // Styleguide 2.1.5.
+ &.mw-ui-block {
+ display: block;
+ width: 100%;
+ }
+
+ // Progressive buttons
+ //
+ // Use progressive buttons for actions which lead to a next step in the process.
+ // .mw-ui-primary is deprecated, kept for compatibility.
+ //
+ // Markup:
+ // <div>
+ // <button class="mw-ui-button mw-ui-progressive">.mw-ui-progressive</button>
+ // </div>
+ // <div>
+ // <button class="mw-ui-button mw-ui-progressive" disabled>.mw-ui-progressive</button>
+ // </div>
+ //
+ // Styleguide 2.1.1.
+ &.mw-ui-progressive,
+ &.mw-ui-primary {
+ .button-colors(@colorProgressive);
+
+ &.mw-ui-quiet {
+ .button-colors-quiet(@colorProgressive);
+ }
+ }
+
+ // Constructive buttons
+ //
+ // Use constructive buttons for actions which result in a final action in the process that results
+ // in a change of state.
+ // e.g. save changes button
+ //
+ // Markup:
+ // <div>
+ // <button class="mw-ui-button mw-ui-constructive">.mw-ui-constructive</button>
+ // </div>
+ // <div>
+ // <button class="mw-ui-button mw-ui-constructive" disabled>.mw-ui-constructive</button>
+ // </div>
+ //
+ // Styleguide 2.1.2.
+ &.mw-ui-constructive {
+ .button-colors(@colorConstructive);
+
+ &.mw-ui-quiet {
+ .button-colors-quiet(@colorConstructive);
+ }
+ }
+
+ // Destructive buttons
+ //
+ // Use destructive buttons for actions which result in the destruction of data.
+ // e.g. deleting a page.
+ // This should not be used for cancel buttons.
+ //
+ // Markup:
+ // <div>
+ // <button class="mw-ui-button mw-ui-destructive">.mw-ui-destructive</button>
+ // </div>
+ // <div>
+ // <button class="mw-ui-button mw-ui-destructive" disabled>.mw-ui-destructive</button>
+ // </div>
+ //
+ // Styleguide 2.1.3.
+ &.mw-ui-destructive {
+ .button-colors(@colorDestructive);
+
+ &.mw-ui-quiet {
+ .button-colors-quiet(@colorDestructive);
+ }
+ }
+
+ // Quiet buttons
+ //
+ // Use quiet buttons when they are less important and alongisde other progressive/destructive/progressive buttons.
+ //
+ // Markup:
+ // <div>
+ // <button class="mw-ui-button mw-ui-quiet">.mw-ui-button</button>
+ // </div>
+ // <div>
+ // <button class="mw-ui-button mw-ui-constructive mw-ui-quiet">.mw-ui-constructive</button>
+ // </div>
+ // <div>
+ // <button class="mw-ui-button mw-ui-constructive mw-ui-quiet" disabled>.mw-ui-constructive</button>
+ // </div>
+ // <div>
+ // <button class="mw-ui-button mw-ui-destructive mw-ui-quiet">.mw-ui-destructive</button>
+ // </div>
+ // <div>
+ // <button class="mw-ui-button mw-ui-destructive mw-ui-quiet" disabled>.mw-ui-destructive</button>
+ // </div>
+ // <div>
+ // <button class="mw-ui-button mw-ui-progressive mw-ui-quiet">.mw-ui-progressive</button>
+ // </div>
+ // <div>
+ // <button class="mw-ui-button mw-ui-progressive mw-ui-quiet" disabled>.mw-ui-progressive</button>
+ // </div>
+ //
+ // Styleguide 2.1.4.
+ &.mw-ui-quiet {
+ background: transparent;
+ border: none;
+ text-shadow: none;
+ .button-colors-quiet(@colorButtonText);
+
+ &:hover,
+ &:focus {
+ box-shadow: none;
+ }
+
+ &:active,
+ &:disabled {
+ background: transparent;
+ }
+ }
+}
+
+a.mw-ui-button {
+ text-decoration: none;
+
+ // This overrides an underline declaration on a:hover and a:focus in
+ // commonElements.css, which the class alone isn't specific enough to do.
+ &:hover,
+ &:focus {
+ text-decoration: none;
+ }
+}
+
+// Button groups
+//
+// Group of buttons. Make sure you clear the floating after using a mw-ui-button-group.
+//
+// Markup:
+// <div class="mw-ui-button-group">
+// <div class="mw-ui-button">A</div>
+// <div class="mw-ui-button">B</div>
+// <div class="mw-ui-button">C</div>
+// <div class="mw-ui-button">D</div>
+// </div><div style="clear:both"></div>
+//
+// Styleguide 2.2.
+.mw-ui-button-group > * {
+ border-radius: 0;
+ float: left;
+
+ &:first-child {
+ border-top-left-radius: @borderRadius;
+ border-bottom-left-radius: @borderRadius;
+ }
+
+ &:not(:first-child) {
+ border-left: none;
+ }
+
+ &:last-child{
+ border-top-right-radius: @borderRadius;
+ border-bottom-right-radius: @borderRadius;
+ }
+}
diff --git a/resources/src/mediawiki.ui/components/checkbox.less b/resources/src/mediawiki.ui/components/checkbox.less
new file mode 100644
index 00000000..e39646bc
--- /dev/null
+++ b/resources/src/mediawiki.ui/components/checkbox.less
@@ -0,0 +1,100 @@
+@import "mediawiki.mixins";
+@import "mediawiki.ui/variables";
+
+// Checkbox
+//
+// Styling checkboxes in a way that works cross browser is a tricky problem to solve.
+// In MediaWiki UI put a checkbox and label inside a mw-ui-checkbox div.
+// This renders in all browsers except IE6-8 which do not support the :checked selector;
+// these are kept backwards-compatible using the :not(#noop) selector.
+// You should give the checkbox and label matching "id" and "for" attributes, respectively.
+//
+// Markup:
+// <div class="mw-ui-checkbox">
+// <input type="checkbox" id="kss-example-5"><label for="kss-example-5">Standard checkbox</label>
+// </div>
+// <div class="mw-ui-checkbox">
+// <input type="checkbox" id="kss-example-5-2" disabled><label for="kss-example-5-2">Disabled checkbox</label>
+// </div>
+//
+// Styleguide 5.
+.mw-ui-checkbox {
+ display: inline-block;
+ vertical-align: middle;
+}
+
+@checkboxSize: 24px;
+
+// We use the not selector to cancel out styling on IE 8 and below
+.mw-ui-checkbox:not(#noop) {
+ // Position relatively so we can make use of absolute pseudo elements
+ position: relative;
+ line-height: @checkboxSize;
+
+ * {
+ vertical-align: middle;
+ }
+
+ input[type="checkbox"] {
+ // we hide the input element as instead we will style the label that follows
+ // we use opacity so that VoiceOver software can still identify it
+ opacity: 0;
+ // ensure the invisible checkbox takes up the required width
+ width: @checkboxSize;
+ height: @checkboxSize;
+
+ // the pseudo before element of the label after the checkbox now looks like a checkbox
+ & + label {
+ cursor: pointer;
+
+ &::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ display: inline-block;
+ border-radius: @borderRadius;
+ margin-right: 18px;
+ width: @checkboxSize;
+ height: @checkboxSize;
+ background-color: #fff;
+ border: 1px solid grey;
+ }
+ }
+
+ // when the input is checked, style the label pseudo before element that followed as a checked checkbox
+ &:checked {
+ + label {
+ &::before {
+ .background-image-svg('images/checked.svg', 'images/checked.png');
+ background-repeat: no-repeat;
+ background-position: center top;
+ }
+ }
+ }
+
+ @focusBottomBorderSize: 3px;
+ &:active,
+ &:focus {
+ + label {
+ &::after {
+ content: '';
+ position: absolute;
+ width: @checkboxSize;
+ height: @checkboxSize - @focusBottomBorderSize + 1; // offset by bottom border
+ // offset from the checkbox by 1px to account for left border
+ left: 1px;
+ border-bottom: solid @focusBottomBorderSize lightgrey;
+ }
+ }
+ }
+
+ // disabled checked boxes have a gray background
+ &:disabled + label {
+ cursor: default;
+
+ &::before {
+ background-color: lightgrey;
+ }
+ }
+ }
+}
diff --git a/resources/src/mediawiki.ui/components/forms.less b/resources/src/mediawiki.ui/components/forms.less
new file mode 100644
index 00000000..592a3098
--- /dev/null
+++ b/resources/src/mediawiki.ui/components/forms.less
@@ -0,0 +1,166 @@
+// Form elements and layouts
+
+@import "mediawiki.mixins";
+@import "mediawiki.ui/variables";
+@import "mediawiki.ui/mixins";
+
+// --------------------------------------------------------------------------
+// Layouts
+// --------------------------------------------------------------------------
+
+// The FancyCaptcha image CAPTCHA used on WMF wikis drives the width of the
+// 'VForm' design, the form can't be narrower than this.
+@captchaContainerWidth: 290px;
+@defaultFormWidth: @captchaContainerWidth;
+
+// Forms
+//
+// Styleguide 3.
+
+// VForm
+//
+// Style a compact vertical stacked form ("VForm") and the elements in divs
+// within it. See button and inputs section on guidance of how and when to use them.
+//
+// Markup:
+// <form class="mw-ui-vform">
+// <div class="mw-ui-vform-field">This is a form example.</div>
+// <div class="mw-ui-vform-field">
+// <label>Username </label>
+// <input class="mw-ui-input" value="input">
+// </div>
+// <div class="mw-ui-vform-field">
+// <button class="mw-ui-button mw-ui-constructive">Button in vform</button>
+// </div>
+// </form>
+//
+// Styleguide 3.1.
+.mw-ui-vform {
+ .box-sizing(border-box);
+
+ width: @defaultFormWidth;
+
+ // MW currently doesn't use the type attribute everywhere on inputs.
+ select,
+ .mw-ui-button {
+ display: block;
+ .box-sizing(border-box);
+ margin: 0;
+ width: 100%;
+ }
+
+ // Give dropdown lists the same spacing as input fields for consistency.
+ // Values taken from .agora-field-styling() in mixins/form.less
+ select {
+ padding: 0.35em 0.5em 0.35em 0.5em;
+ vertical-align: middle;
+ }
+
+ > label {
+ display: block;
+ .box-sizing(border-box);
+ .agora-label-styling();
+ width: auto;
+ margin: 0 0 0.2em;
+ padding: 0;
+ }
+
+ // Override input styling just for checkboxes and radio inputs.
+ input[type="radio"] {
+ display: inline;
+ .box-sizing(content-box);
+ width: auto;
+ }
+
+
+ // Styles for information boxes
+ //
+ // Regular HTMLForm uses .error class, some special pages like
+ // SpecialUserlogin (login and create account) use .errorbox.
+ //
+ // Markup:
+ // <form class="mw-ui-vform">
+ // <div class="errorbox">An error occurred</div>
+ // <div class="warningbox">A warning to be noted</div>
+ // <div class="successbox">Action successful!</div>
+ // <div class="error">A different kind of error</div>
+ // <div class="error">
+ // <ul><li>There are problems with some of your input.</li></ul>
+ // </div>
+ // <div class="mw-ui-vform-field">
+ // <input type="text" value="input" class="mw-ui-input">
+ // </div>
+ // <div class="mw-ui-vform-field">
+ // <select>
+ // <option value="1">Option 1</option>
+ // <option value="2">Option 2</option>
+ // </select>
+ // <span class="error">The value you specified is not a valid option.</span>
+ // </div>
+ // <div class="mw-ui-vform-field">
+ // <button class="mw-ui-button">Button in vform</button>
+ // </div>
+ // </form>
+ //
+ // Styleguide 3.2.
+ .error,
+ .errorbox,
+ .warningbox,
+ .successbox {
+ .box-sizing(border-box);
+ font-size: 0.9em;
+ margin: 0 0 1em 0;
+ padding: 0.5em;
+ word-wrap: break-word;
+ }
+
+ // Colours taken from those for .errorbox in shared.css
+ .error {
+ color: #cc0000;
+ border: 1px solid #fac5c5;
+ background-color: #fae3e3;
+ text-shadow: 0 1px #fae3e3;
+ }
+
+ // This specifies styling for individual field validation error messages.
+ // Show them below the fields to prevent line break glitches, and leave
+ // some space between the field and the error message box.
+ .mw-ui-vform-field .error {
+ display: block;
+ margin-top: 5px;
+ }
+
+}
+
+// --------------------------------------------------------------------------
+// Elements
+// --------------------------------------------------------------------------
+
+// A wrapper for a single form field: the <input> / <select> / <button> element,
+// help text, labels, associated error/warning/success messages, and so on.
+// Elements with this class are generated by HTMLFormField in core MediaWiki.
+//
+// (We use a broad definition of 'field' here: a purely textual information
+// block is also a "field".)
+.mw-ui-vform-field {
+ display: block;
+ margin: 0 0 15px;
+ padding: 0;
+ width: 100%;
+}
+
+// Apply mw-ui-label to individual elements to style them.
+// You generally don't need to use this class if <label> is within an Agora
+// form container such as mw-ui-vform
+.mw-ui-label {
+ .agora-label-styling(); // mixins/forms.less
+}
+
+// Nesting an input inside a label with this class
+// improves alignment, e.g.
+// <label class="mw-ui-radio-label">
+// <input type="radio">The label text
+// </label>
+.mw-ui-radio-label {
+ .agora-inline-label-styling();
+}
diff --git a/resources/src/mediawiki.ui/components/images/checked.png b/resources/src/mediawiki.ui/components/images/checked.png
new file mode 100644
index 00000000..ce4e6b9a
--- /dev/null
+++ b/resources/src/mediawiki.ui/components/images/checked.png
Binary files differ
diff --git a/resources/src/mediawiki.ui/components/images/checked.svg b/resources/src/mediawiki.ui/components/images/checked.svg
new file mode 100644
index 00000000..aea69db4
--- /dev/null
+++ b/resources/src/mediawiki.ui/components/images/checked.svg
@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><path d="M4 12l5 5 11-12" stroke="#00B78C" stroke-width="3" fill="none"/></svg> \ No newline at end of file
diff --git a/resources/src/mediawiki.ui/components/inputs.less b/resources/src/mediawiki.ui/components/inputs.less
new file mode 100644
index 00000000..1da42a45
--- /dev/null
+++ b/resources/src/mediawiki.ui/components/inputs.less
@@ -0,0 +1,126 @@
+// Inputs
+
+@import "mediawiki.mixins";
+@import "mediawiki.ui/variables";
+@import "mediawiki.ui/mixins";
+
+// Placeholder text styling helper
+.field-placeholder-styling() {
+ font-style: italic;
+ font-weight: normal;
+}
+// Inputs
+//
+// Apply the mw-ui-input class to input and textarea fields.
+//
+// Styleguide 1.
+
+// mw-ui-input
+//
+// Style an input using MediaWiki UI.
+// Currently in draft status and subject to change.
+// When focused a progressive highlight appears to the left of the field.
+//
+// Markup:
+// <input class="mw-ui-input" placeholder="Enter your name">
+// <textarea class="mw-ui-input">Text here</textarea>
+//
+// Styleguide 1.1.
+.mw-ui-input {
+ // turn off default input styling for input[type="search"] fields
+ -webkit-appearance: none;
+ border: 1px solid @colorFieldBorder;
+ .box-sizing(border-box);
+ width: 100%;
+ padding: .4em .3em .2em .6em;
+ display: block;
+ vertical-align: middle;
+ border-radius: @borderRadius;
+ // Override user agent stylesheet properties. Instead use parent element.
+ color: inherit;
+ font-family: inherit;
+ font-size: inherit;
+ line-height: inherit;
+ .transition(~"border linear .2s, box-shadow linear .2s");
+
+ // Placeholder text styling must be set individually for each browser @winter
+ &::-webkit-input-placeholder { // webkit
+ .field-placeholder-styling;
+ }
+ &::-moz-placeholder { // FF 4-18
+ .field-placeholder-styling;
+ }
+ &:-moz-placeholder { // FF >= 19
+ .field-placeholder-styling;
+ }
+ &:-ms-input-placeholder { // IE >= 10
+ .field-placeholder-styling;
+ }
+
+ // Remove red outline from inputs which have required field and invalid content.
+ // This is a Firefox only issue
+ // See https://developer.mozilla.org/en-US/docs/Web/CSS/:invalid
+ // This should be above :focus so focus behaviour takes preference
+ &:invalid {
+ box-shadow: none;
+ }
+
+ &:focus {
+ box-shadow: inset .45em 0 0 @colorProgressive;
+ border-color: @colorGrayDark;
+ // Remove focus glow on input[type="search"]
+ outline: 0;
+ }
+}
+
+textarea.mw-ui-input {
+ min-height: 8em;
+}
+
+// mw-ui-input-inline
+//
+// Use mw-ui-input-inline with mw-ui-input in cases where you want a button to line up with the input.
+//
+// Markup:
+// <input class="mw-ui-input mw-ui-input-inline">
+// <button class="mw-ui-button mw-ui-constructive">go</button>
+//
+// Styleguide 1.2.
+input[type="number"],
+.mw-ui-input-inline {
+ display: inline-block;
+ width: auto;
+}
+
+// mw-ui-input-large
+//
+// Use mw-ui-input-large with mw-ui-input in cases where there are multiple inputs on a screen and you
+// want to draw attention to one instance. For example, replying with a subject line and more text.
+// Currently in draft status and subject to change. When used on an input field, the text is styled
+// in a large font. When used alongside another mw-ui-input large they are pushed together to form one
+// contiguous block.
+//
+// Markup:
+// <input value="input" class="mw-ui-input mw-ui-input-large" value="input" placeholder="Enter subject">
+// <textarea class="mw-ui-input mw-ui-input-large" placeholder="Provide additional details"></textarea>
+//
+// Styleguide 1.3.
+.mw-ui-input-large {
+ margin-top: 0;
+ margin-bottom: 0;
+
+ // When two large inputs are together, we make them flush by hiding one of the borders
+ & + .mw-ui-input-large {
+ margin-top: -1px;
+ }
+ // When focusing, make the input relative to raise it above any attached inputs to unhide its borders
+ &:focus {
+ position: relative;
+ }
+}
+
+input.mw-ui-input-large {
+ font-size: 1.75em;
+ font-weight: bold;
+ line-height: 1.25em;
+}
diff --git a/resources/src/mediawiki.ui/components/utilities.less b/resources/src/mediawiki.ui/components/utilities.less
new file mode 100644
index 00000000..0bbb440f
--- /dev/null
+++ b/resources/src/mediawiki.ui/components/utilities.less
@@ -0,0 +1,47 @@
+// Utilities
+//
+// Other things which effect the behaviour of components
+
+// Flush left
+//
+// Used when you want to push an element to the left of its containing element
+//
+// Markup:
+// <div class="mw-ui-vform-field">
+// <label>Username <a href="#" class="mw-ui-flush-left">?</a></label>
+// <input>
+// </div>
+.mw-ui-flush-left {
+ float: left;
+ margin-left: 0;
+ padding-left: 0;
+}
+
+// Flush right
+//
+// Used when you want to push an element to the right of its containing element
+//
+// Markup:
+// <div class="mw-ui-vform-field">
+// <label>Username <a href="#" class="mw-ui-flush-right">?</a></label>
+// <input>
+// </div>
+.mw-ui-flush-right {
+ float: right;
+ padding-right: 0;
+ margin-right: 0;
+}
+
+// Center block
+//
+// Centers the element in its containing element
+//
+// Markup:
+// <div>
+// <button class="mw-ui-center-block">click me</button>
+// </div>
+.mw-ui-center-block {
+ display: block;
+ margin-left: auto;
+ margin-right: auto;
+}
diff --git a/resources/src/mediawiki.ui/default.less b/resources/src/mediawiki.ui/default.less
new file mode 100644
index 00000000..c9d62087
--- /dev/null
+++ b/resources/src/mediawiki.ui/default.less
@@ -0,0 +1,5 @@
+/**
+ * Provide Agora appearance for mw-ui-* classes.
+ */
+@import "components/forms";
+@import "components/utilities";
diff --git a/resources/src/mediawiki.ui/styleguide.md b/resources/src/mediawiki.ui/styleguide.md
new file mode 100644
index 00000000..61691c79
--- /dev/null
+++ b/resources/src/mediawiki.ui/styleguide.md
@@ -0,0 +1,11 @@
+#mediawiki.ui
+
+This is the living style guide for mediawiki.ui. It is generated from the mediawiki.ui LESS source code programmatically. Please use it as a reference when developing code for MediaWiki to ensure your design is consistent with others across the site. Note this document is a work in progress and subject to change.
+
+##Design Philosophy
+
+###Inviting
+Our aesthetics should be clean and encourage interaction.
+
+###Worldly
+We are thoughtful and are aware of cultural differences. We are careful about words, color usage, and images that might offend. We are also aware of limited connectivity in some areas of the world and are thoughtful of image sizes and loading speed. We work to support accessibility.
diff --git a/resources/src/mediawiki/images/arrow-collapsed-ltr.png b/resources/src/mediawiki/images/arrow-collapsed-ltr.png
new file mode 100644
index 00000000..b17e578b
--- /dev/null
+++ b/resources/src/mediawiki/images/arrow-collapsed-ltr.png
Binary files differ
diff --git a/resources/src/mediawiki/images/arrow-collapsed-ltr.svg b/resources/src/mediawiki/images/arrow-collapsed-ltr.svg
new file mode 100644
index 00000000..6233fd5e
--- /dev/null
+++ b/resources/src/mediawiki/images/arrow-collapsed-ltr.svg
@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12"><path d="M4 1.533v9.671l4.752-4.871z" fill="#797979"/></svg> \ No newline at end of file
diff --git a/resources/src/mediawiki/images/arrow-collapsed-rtl.png b/resources/src/mediawiki/images/arrow-collapsed-rtl.png
new file mode 100644
index 00000000..a834548e
--- /dev/null
+++ b/resources/src/mediawiki/images/arrow-collapsed-rtl.png
Binary files differ
diff --git a/resources/src/mediawiki/images/arrow-collapsed-rtl.svg b/resources/src/mediawiki/images/arrow-collapsed-rtl.svg
new file mode 100644
index 00000000..44d5587a
--- /dev/null
+++ b/resources/src/mediawiki/images/arrow-collapsed-rtl.svg
@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12"><path d="M8 1.533v9.671l-4.752-4.871z" fill="#797979"/></svg> \ No newline at end of file
diff --git a/resources/src/mediawiki/images/arrow-expanded.png b/resources/src/mediawiki/images/arrow-expanded.png
new file mode 100644
index 00000000..2bec798e
--- /dev/null
+++ b/resources/src/mediawiki/images/arrow-expanded.png
Binary files differ
diff --git a/resources/src/mediawiki/images/arrow-expanded.svg b/resources/src/mediawiki/images/arrow-expanded.svg
new file mode 100644
index 00000000..a0d217d2
--- /dev/null
+++ b/resources/src/mediawiki/images/arrow-expanded.svg
@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12"><path d="M1.165 3.624h9.671l-4.871 4.752z" fill="#797979"/></svg> \ No newline at end of file
diff --git a/resources/src/mediawiki/images/arrow-sort-ascending.png b/resources/src/mediawiki/images/arrow-sort-ascending.png
new file mode 100644
index 00000000..f2d339de
--- /dev/null
+++ b/resources/src/mediawiki/images/arrow-sort-ascending.png
Binary files differ
diff --git a/resources/src/mediawiki/images/arrow-sort-ascending.svg b/resources/src/mediawiki/images/arrow-sort-ascending.svg
new file mode 100644
index 00000000..1e7a0943
--- /dev/null
+++ b/resources/src/mediawiki/images/arrow-sort-ascending.svg
@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12"><path d="M1 10h10l-5-8.658z" fill="#00a"/></svg> \ No newline at end of file
diff --git a/resources/src/mediawiki/images/arrow-sort-descending.png b/resources/src/mediawiki/images/arrow-sort-descending.png
new file mode 100644
index 00000000..8afbca96
--- /dev/null
+++ b/resources/src/mediawiki/images/arrow-sort-descending.png
Binary files differ
diff --git a/resources/src/mediawiki/images/arrow-sort-descending.svg b/resources/src/mediawiki/images/arrow-sort-descending.svg
new file mode 100644
index 00000000..cf11adb4
--- /dev/null
+++ b/resources/src/mediawiki/images/arrow-sort-descending.svg
@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12"><path d="M1 2h10l-5 8.658z" fill="#00a"/></svg> \ No newline at end of file
diff --git a/resources/src/mediawiki/images/pager-arrow-disabled-fastforward-ltr.png b/resources/src/mediawiki/images/pager-arrow-disabled-fastforward-ltr.png
new file mode 100644
index 00000000..2a64fd03
--- /dev/null
+++ b/resources/src/mediawiki/images/pager-arrow-disabled-fastforward-ltr.png
Binary files differ
diff --git a/resources/src/mediawiki/images/pager-arrow-disabled-fastforward-rtl.png b/resources/src/mediawiki/images/pager-arrow-disabled-fastforward-rtl.png
new file mode 100644
index 00000000..78a493e6
--- /dev/null
+++ b/resources/src/mediawiki/images/pager-arrow-disabled-fastforward-rtl.png
Binary files differ
diff --git a/resources/src/mediawiki/images/pager-arrow-disabled-forward-ltr.png b/resources/src/mediawiki/images/pager-arrow-disabled-forward-ltr.png
new file mode 100644
index 00000000..aa4fbf8c
--- /dev/null
+++ b/resources/src/mediawiki/images/pager-arrow-disabled-forward-ltr.png
Binary files differ
diff --git a/resources/src/mediawiki/images/pager-arrow-disabled-forward-rtl.png b/resources/src/mediawiki/images/pager-arrow-disabled-forward-rtl.png
new file mode 100644
index 00000000..83df0684
--- /dev/null
+++ b/resources/src/mediawiki/images/pager-arrow-disabled-forward-rtl.png
Binary files differ
diff --git a/resources/src/mediawiki/images/pager-arrow-fastforward-ltr.png b/resources/src/mediawiki/images/pager-arrow-fastforward-ltr.png
new file mode 100644
index 00000000..caf50331
--- /dev/null
+++ b/resources/src/mediawiki/images/pager-arrow-fastforward-ltr.png
Binary files differ
diff --git a/resources/src/mediawiki/images/pager-arrow-fastforward-rtl.png b/resources/src/mediawiki/images/pager-arrow-fastforward-rtl.png
new file mode 100644
index 00000000..52b32a5a
--- /dev/null
+++ b/resources/src/mediawiki/images/pager-arrow-fastforward-rtl.png
Binary files differ
diff --git a/resources/src/mediawiki/images/pager-arrow-forward-ltr.png b/resources/src/mediawiki/images/pager-arrow-forward-ltr.png
new file mode 100644
index 00000000..3f8fee38
--- /dev/null
+++ b/resources/src/mediawiki/images/pager-arrow-forward-ltr.png
Binary files differ
diff --git a/resources/src/mediawiki/images/pager-arrow-forward-rtl.png b/resources/src/mediawiki/images/pager-arrow-forward-rtl.png
new file mode 100644
index 00000000..f363bf66
--- /dev/null
+++ b/resources/src/mediawiki/images/pager-arrow-forward-rtl.png
Binary files differ
diff --git a/resources/src/mediawiki/mediawiki.Title.js b/resources/src/mediawiki/mediawiki.Title.js
new file mode 100644
index 00000000..7ced42fe
--- /dev/null
+++ b/resources/src/mediawiki/mediawiki.Title.js
@@ -0,0 +1,939 @@
+/*!
+ * @author Neil Kandalgaonkar, 2010
+ * @author Timo Tijhof, 2011-2013
+ * @since 1.18
+ */
+( function ( mw, $ ) {
+
+ /**
+ * @class mw.Title
+ *
+ * Parse titles into an object struture. Note that when using the constructor
+ * directly, passing invalid titles will result in an exception. Use #newFromText to use the
+ * logic directly and get null for invalid titles which is easier to work with.
+ *
+ * @constructor
+ * @param {string} title Title of the page. If no second argument given,
+ * this will be searched for a namespace
+ * @param {number} [namespace=NS_MAIN] If given, will used as default namespace for the given title
+ * @throws {Error} When the title is invalid
+ */
+ function Title( title, namespace ) {
+ var parsed = parse( title, namespace );
+ if ( !parsed ) {
+ throw new Error( 'Unable to parse title' );
+ }
+
+ this.namespace = parsed.namespace;
+ this.title = parsed.title;
+ this.ext = parsed.ext;
+ this.fragment = parsed.fragment;
+
+ return this;
+ }
+
+ /* Private members */
+
+ var
+
+ /**
+ * @private
+ * @static
+ * @property NS_MAIN
+ */
+ NS_MAIN = 0,
+
+ /**
+ * @private
+ * @static
+ * @property NS_TALK
+ */
+ NS_TALK = 1,
+
+ /**
+ * @private
+ * @static
+ * @property NS_SPECIAL
+ */
+ NS_SPECIAL = -1,
+
+ /**
+ * @private
+ * @static
+ * @property NS_MEDIA
+ */
+ NS_MEDIA = -2,
+
+ /**
+ * @private
+ * @static
+ * @property NS_FILE
+ */
+ NS_FILE = 6,
+
+ /**
+ * @private
+ * @static
+ * @property FILENAME_MAX_BYTES
+ */
+ FILENAME_MAX_BYTES = 240,
+
+ /**
+ * @private
+ * @static
+ * @property TITLE_MAX_BYTES
+ */
+ TITLE_MAX_BYTES = 255,
+
+ /**
+ * Get the namespace id from a namespace name (either from the localized, canonical or alias
+ * name).
+ *
+ * Example: On a German wiki this would return 6 for any of 'File', 'Datei', 'Image' or
+ * even 'Bild'.
+ *
+ * @private
+ * @static
+ * @method getNsIdByName
+ * @param {string} ns Namespace name (case insensitive, leading/trailing space ignored)
+ * @return {number|boolean} Namespace id or boolean false
+ */
+ getNsIdByName = function ( ns ) {
+ var id;
+
+ // Don't cast non-strings to strings, because null or undefined should not result in
+ // returning the id of a potential namespace called "Null:" (e.g. on null.example.org/wiki)
+ // Also, toLowerCase throws exception on null/undefined, because it is a String method.
+ if ( typeof ns !== 'string' ) {
+ return false;
+ }
+ ns = ns.toLowerCase();
+ id = mw.config.get( 'wgNamespaceIds' )[ns];
+ if ( id === undefined ) {
+ return false;
+ }
+ return id;
+ },
+
+ rUnderscoreTrim = /^_+|_+$/g,
+
+ rSplit = /^(.+?)_*:_*(.*)$/,
+
+ // See Title.php#getTitleInvalidRegex
+ rInvalid = new RegExp(
+ '[^' + mw.config.get( 'wgLegalTitleChars' ) + ']' +
+ // URL percent encoding sequences interfere with the ability
+ // to round-trip titles -- you can't link to them consistently.
+ '|%[0-9A-Fa-f]{2}' +
+ // XML/HTML character references produce similar issues.
+ '|&[A-Za-z0-9\u0080-\uFFFF]+;' +
+ '|&#[0-9]+;' +
+ '|&#x[0-9A-Fa-f]+;'
+ ),
+
+ // From MediaWikiTitleCodec.php#L225 @26fcab1f18c568a41
+ // "Clean up whitespace" in function MediaWikiTitleCodec::splitTitleString()
+ rWhitespace = /[ _\u0009\u00A0\u1680\u180E\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\s]+/g,
+
+ /**
+ * Slightly modified from Flinfo. Credit goes to Lupo and Flominator.
+ * @private
+ * @static
+ * @property sanitationRules
+ */
+ sanitationRules = [
+ // "signature"
+ {
+ pattern: /~{3}/g,
+ replace: '',
+ generalRule: true
+ },
+ // Space, underscore, tab, NBSP and other unusual spaces
+ {
+ pattern: rWhitespace,
+ replace: ' ',
+ generalRule: true
+ },
+ // unicode bidi override characters: Implicit, Embeds, Overrides
+ {
+ pattern: /[\u200E\u200F\u202A-\u202E]/g,
+ replace: '',
+ generalRule: true
+ },
+ // control characters
+ {
+ pattern: /[\x00-\x1f\x7f]/g,
+ replace: '',
+ generalRule: true
+ },
+ // URL encoding (possibly)
+ {
+ pattern: /%([0-9A-Fa-f]{2})/g,
+ replace: '% $1',
+ generalRule: true
+ },
+ // HTML-character-entities
+ {
+ pattern: /&(([A-Za-z0-9\x80-\xff]+|#[0-9]+|#x[0-9A-Fa-f]+);)/g,
+ replace: '& $1',
+ generalRule: true
+ },
+ // slash, colon (not supported by file systems like NTFS/Windows, Mac OS 9 [:], ext4 [/])
+ {
+ pattern: /[:\/#]/g,
+ replace: '-',
+ fileRule: true
+ },
+ // brackets, greater than
+ {
+ pattern: /[\]\}>]/g,
+ replace: ')',
+ generalRule: true
+ },
+ // brackets, lower than
+ {
+ pattern: /[\[\{<]/g,
+ replace: '(',
+ generalRule: true
+ },
+ // everything that wasn't covered yet
+ {
+ pattern: new RegExp( rInvalid.source, 'g' ),
+ replace: '-',
+ generalRule: true
+ },
+ // directory structures
+ {
+ pattern: /^(\.|\.\.|\.\/.*|\.\.\/.*|.*\/\.\/.*|.*\/\.\.\/.*|.*\/\.|.*\/\.\.)$/g,
+ replace: '',
+ generalRule: true
+ }
+ ],
+
+ /**
+ * Internal helper for #constructor and #newFromtext.
+ *
+ * Based on Title.php#secureAndSplit
+ *
+ * @private
+ * @static
+ * @method parse
+ * @param {string} title
+ * @param {number} [defaultNamespace=NS_MAIN]
+ * @return {Object|boolean}
+ */
+ parse = function ( title, defaultNamespace ) {
+ var namespace, m, id, i, fragment, ext;
+
+ namespace = defaultNamespace === undefined ? NS_MAIN : defaultNamespace;
+
+ title = title
+ // Normalise whitespace to underscores and remove duplicates
+ .replace( /[ _\s]+/g, '_' )
+ // Trim underscores
+ .replace( rUnderscoreTrim, '' );
+
+ // Process initial colon
+ if ( title !== '' && title.charAt( 0 ) === ':' ) {
+ // Initial colon means main namespace instead of specified default
+ namespace = NS_MAIN;
+ title = title
+ // Strip colon
+ .slice( 1 )
+ // Trim underscores
+ .replace( rUnderscoreTrim, '' );
+ }
+
+ if ( title === '' ) {
+ return false;
+ }
+
+ // Process namespace prefix (if any)
+ m = title.match( rSplit );
+ if ( m ) {
+ id = getNsIdByName( m[1] );
+ if ( id !== false ) {
+ // Ordinary namespace
+ namespace = id;
+ title = m[2];
+
+ // For Talk:X pages, make sure X has no "namespace" prefix
+ if ( namespace === NS_TALK && ( m = title.match( rSplit ) ) ) {
+ // Disallow titles like Talk:File:x (subject should roundtrip: talk:file:x -> file:x -> file_talk:x)
+ if ( getNsIdByName( m[1] ) !== false ) {
+ return false;
+ }
+ }
+ }
+ }
+
+ // Process fragment
+ i = title.indexOf( '#' );
+ if ( i === -1 ) {
+ fragment = null;
+ } else {
+ fragment = title
+ // Get segment starting after the hash
+ .slice( i + 1 )
+ // Convert to text
+ // NB: Must not be trimmed ("Example#_foo" is not the same as "Example#foo")
+ .replace( /_/g, ' ' );
+
+ title = title
+ // Strip hash
+ .slice( 0, i )
+ // Trim underscores, again (strips "_" from "bar" in "Foo_bar_#quux")
+ .replace( rUnderscoreTrim, '' );
+ }
+
+ // Reject illegal characters
+ if ( title.match( rInvalid ) ) {
+ return false;
+ }
+
+ // Disallow titles that browsers or servers might resolve as directory navigation
+ if (
+ title.indexOf( '.' ) !== -1 && (
+ title === '.' || title === '..' ||
+ title.indexOf( './' ) === 0 ||
+ title.indexOf( '../' ) === 0 ||
+ title.indexOf( '/./' ) !== -1 ||
+ title.indexOf( '/../' ) !== -1 ||
+ title.slice( -2 ) === '/.' ||
+ title.slice( -3 ) === '/..'
+ )
+ ) {
+ return false;
+ }
+
+ // Disallow magic tilde sequence
+ if ( title.indexOf( '~~~' ) !== -1 ) {
+ return false;
+ }
+
+ // Disallow titles exceeding the TITLE_MAX_BYTES byte size limit (size of underlying database field)
+ // Except for special pages, e.g. [[Special:Block/Long name]]
+ // Note: The PHP implementation also asserts that even in NS_SPECIAL, the title should
+ // be less than 512 bytes.
+ if ( namespace !== NS_SPECIAL && $.byteLength( title ) > TITLE_MAX_BYTES ) {
+ return false;
+ }
+
+ // Can't make a link to a namespace alone.
+ if ( title === '' && namespace !== NS_MAIN ) {
+ return false;
+ }
+
+ // Any remaining initial :s are illegal.
+ if ( title.charAt( 0 ) === ':' ) {
+ return false;
+ }
+
+ // For backwards-compatibility with old mw.Title, we separate the extension from the
+ // rest of the title.
+ i = title.lastIndexOf( '.' );
+ if ( i === -1 || title.length <= i + 1 ) {
+ // Extensions are the non-empty segment after the last dot
+ ext = null;
+ } else {
+ ext = title.slice( i + 1 );
+ title = title.slice( 0, i );
+ }
+
+ return {
+ namespace: namespace,
+ title: title,
+ ext: ext,
+ fragment: fragment
+ };
+ },
+
+ /**
+ * Convert db-key to readable text.
+ *
+ * @private
+ * @static
+ * @method text
+ * @param {string} s
+ * @return {string}
+ */
+ text = function ( s ) {
+ if ( s !== null && s !== undefined ) {
+ return s.replace( /_/g, ' ' );
+ } else {
+ return '';
+ }
+ },
+
+ /**
+ * Sanitizes a string based on a rule set and a filter
+ *
+ * @private
+ * @static
+ * @method sanitize
+ * @param {string} s
+ * @param {Array} filter
+ * @return {string}
+ */
+ sanitize = function ( s, filter ) {
+ var i, ruleLength, rule, m, filterLength,
+ rules = sanitationRules;
+
+ for ( i = 0, ruleLength = rules.length; i < ruleLength; ++i ) {
+ rule = rules[i];
+ for ( m = 0, filterLength = filter.length; m < filterLength; ++m ) {
+ if ( rule[filter[m]] ) {
+ s = s.replace( rule.pattern, rule.replace );
+ }
+ }
+ }
+ return s;
+ },
+
+ /**
+ * Cuts a string to a specific byte length, assuming UTF-8
+ * or less, if the last character is a multi-byte one
+ *
+ * @private
+ * @static
+ * @method trimToByteLength
+ * @param {string} s
+ * @param {number} length
+ * @return {string}
+ */
+ trimToByteLength = function ( s, length ) {
+ var byteLength, chopOffChars, chopOffBytes;
+
+ // bytelength is always greater or equal to the length in characters
+ s = s.substr( 0, length );
+ while ( ( byteLength = $.byteLength( s ) ) > length ) {
+ // Calculate how many characters can be safely removed
+ // First, we need to know how many bytes the string exceeds the threshold
+ chopOffBytes = byteLength - length;
+ // A character in UTF-8 is at most 4 bytes
+ // One character must be removed in any case because the
+ // string is too long
+ chopOffChars = Math.max( 1, Math.floor( chopOffBytes / 4 ) );
+ s = s.substr( 0, s.length - chopOffChars );
+ }
+ return s;
+ },
+
+ /**
+ * Cuts a file name to a specific byte length
+ *
+ * @private
+ * @static
+ * @method trimFileNameToByteLength
+ * @param {string} name without extension
+ * @param {string} extension file extension
+ * @return {string} The full name, including extension
+ */
+ trimFileNameToByteLength = function ( name, extension ) {
+ // There is a special byte limit for file names and ... remember the dot
+ return trimToByteLength( name, FILENAME_MAX_BYTES - extension.length - 1 ) + '.' + extension;
+ },
+
+ // Polyfill for ES5 Object.create
+ createObject = Object.create || ( function () {
+ return function ( o ) {
+ function Title() {}
+ if ( o !== Object( o ) ) {
+ throw new Error( 'Cannot inherit from a non-object' );
+ }
+ Title.prototype = o;
+ return new Title();
+ };
+ }() );
+
+ /* Static members */
+
+ /**
+ * Constructor for Title objects with a null return instead of an exception for invalid titles.
+ *
+ * @static
+ * @param {string} title
+ * @param {number} [namespace=NS_MAIN] Default namespace
+ * @return {mw.Title|null} A valid Title object or null if the title is invalid
+ */
+ Title.newFromText = function ( title, namespace ) {
+ var t, parsed = parse( title, namespace );
+ if ( !parsed ) {
+ return null;
+ }
+
+ t = createObject( Title.prototype );
+ t.namespace = parsed.namespace;
+ t.title = parsed.title;
+ t.ext = parsed.ext;
+ t.fragment = parsed.fragment;
+
+ return t;
+ };
+
+ /**
+ * Constructor for Title objects from user input altering that input to
+ * produce a title that MediaWiki will accept as legal
+ *
+ * @static
+ * @param {string} title
+ * @param {number} [defaultNamespace=NS_MAIN]
+ * If given, will used as default namespace for the given title.
+ * @param {Object} [options] additional options
+ * @param {string} [options.fileExtension='']
+ * If the title is about to be created for the Media or File namespace,
+ * ensures the resulting Title has the correct extension. Useful, for example
+ * on systems that predict the type by content-sniffing, not by file extension.
+ * If different from empty string, `forUploading` is assumed.
+ * @param {boolean} [options.forUploading=true]
+ * Makes sure that a file is uploadable under the title returned.
+ * There are pages in the file namespace under which file upload is impossible.
+ * Automatically assumed if the title is created in the Media namespace.
+ * @return {mw.Title|null} A valid Title object or null if the input cannot be turned into a valid title
+ */
+ Title.newFromUserInput = function ( title, defaultNamespace, options ) {
+ var namespace, m, id, ext, parts, normalizeExtension;
+
+ // defaultNamespace is optional; check whether options moves up
+ if ( arguments.length < 3 && $.type( defaultNamespace ) === 'object' ) {
+ options = defaultNamespace;
+ defaultNamespace = undefined;
+ }
+
+ // merge options into defaults
+ options = $.extend( {
+ fileExtension: '',
+ forUploading: true
+ }, options );
+
+ normalizeExtension = function ( extension ) {
+ // Remove only trailing space (that is removed by MW anyway)
+ extension = extension.toLowerCase().replace(/\s*$/, '');
+ return extension;
+ };
+
+ namespace = defaultNamespace === undefined ? NS_MAIN : defaultNamespace;
+
+ // Normalise whitespace and remove duplicates
+ title = $.trim( title.replace( rWhitespace, ' ' ) );
+
+ // Process initial colon
+ if ( title !== '' && title.charAt( 0 ) === ':' ) {
+ // Initial colon means main namespace instead of specified default
+ namespace = NS_MAIN;
+ title = title
+ // Strip colon
+ .substr( 1 )
+ // Trim underscores
+ .replace( rUnderscoreTrim, '' );
+ }
+
+ // Process namespace prefix (if any)
+ m = title.match( rSplit );
+ if ( m ) {
+ id = getNsIdByName( m[1] );
+ if ( id !== false ) {
+ // Ordinary namespace
+ namespace = id;
+ title = m[2];
+ }
+ }
+
+ if ( namespace === NS_MEDIA
+ || ( ( options.forUploading || options.fileExtension ) && ( namespace === NS_FILE ) )
+ ) {
+
+ title = sanitize( title, [ 'generalRule', 'fileRule' ] );
+
+ // Operate on the file extension
+ // Although it is possible having spaces between the name and the ".ext" this isn't nice for
+ // operating systems hiding file extensions -> strip them later on
+ parts = title.split( '.' );
+
+ if ( parts.length > 1 ) {
+
+ // Get the last part, which is supposed to be the file extension
+ ext = parts.pop();
+
+ // Does the supplied file name carry the desired file extension?
+ if ( options.fileExtension
+ && normalizeExtension( ext ) !== normalizeExtension( options.fileExtension )
+ ) {
+
+ // No, push back, whatever there was after the dot
+ parts.push( ext );
+
+ // And add the desired file extension later
+ ext = options.fileExtension;
+ }
+
+ // Remove whitespace of the name part (that W/O extension)
+ title = $.trim( parts.join( '.' ) );
+
+ // Cut, if too long and append file extension
+ title = trimFileNameToByteLength( title, ext );
+
+ } else {
+
+ // Missing file extension
+ title = $.trim( parts.join( '.' ) );
+
+ if ( options.fileExtension ) {
+
+ // Cut, if too long and append the desired file extension
+ title = trimFileNameToByteLength( title, options.fileExtension );
+
+ } else {
+
+ // Name has no file extension and a fallback wasn't provided either
+ return null;
+ }
+ }
+ } else {
+
+ title = sanitize( title, [ 'generalRule' ] );
+
+ // Cut titles exceeding the TITLE_MAX_BYTES byte size limit
+ // (size of underlying database field)
+ if ( namespace !== NS_SPECIAL ) {
+ title = trimToByteLength( title, TITLE_MAX_BYTES );
+ }
+ }
+
+ // Any remaining initial :s are illegal.
+ title = title.replace( /^\:+/, '' );
+
+ return Title.newFromText( title, namespace );
+ };
+
+ /**
+ * Sanitizes a file name as supplied by the user, originating in the user's file system
+ * so it is most likely a valid MediaWiki title and file name after processing.
+ * Returns null on fatal errors.
+ *
+ * @static
+ * @param {string} uncleanName The unclean file name including file extension but
+ * without namespace
+ * @param {string} [fileExtension] the desired file extension
+ * @return {mw.Title|null} A valid Title object or null if the title is invalid
+ */
+ Title.newFromFileName = function ( uncleanName, fileExtension ) {
+
+ return Title.newFromUserInput( 'File:' + uncleanName, {
+ fileExtension: fileExtension,
+ forUploading: true
+ } );
+ };
+
+ /**
+ * Get the file title from an image element
+ *
+ * var title = mw.Title.newFromImg( $( 'img:first' ) );
+ *
+ * @static
+ * @param {HTMLElement|jQuery} img The image to use as a base
+ * @return {mw.Title|null} The file title or null if unsuccessful
+ */
+ Title.newFromImg = function ( img ) {
+ var matches, i, regex, src, decodedSrc,
+
+ // thumb.php-generated thumbnails
+ thumbPhpRegex = /thumb\.php/,
+ regexes = [
+ // Thumbnails
+ /\/[a-f0-9]\/[a-f0-9]{2}\/([^\s\/]+)\/[^\s\/]+-(?:\1|thumbnail)[^\s\/]*$/,
+
+ // Thumbnails in non-hashed upload directories
+ /\/([^\s\/]+)\/[^\s\/]+-(?:\1|thumbnail)[^\s\/]*$/,
+
+ // Full size images
+ /\/[a-f0-9]\/[a-f0-9]{2}\/([^\s\/]+)$/,
+
+ // Full-size images in non-hashed upload directories
+ /\/([^\s\/]+)$/
+ ],
+
+ recount = regexes.length;
+
+ src = img.jquery ? img[0].src : img.src;
+
+ matches = src.match( thumbPhpRegex );
+
+ if ( matches ) {
+ return mw.Title.newFromText( 'File:' + mw.util.getParamValue( 'f', src ) );
+ }
+
+ decodedSrc = decodeURIComponent( src );
+
+ for ( i = 0; i < recount; i++ ) {
+ regex = regexes[i];
+ matches = decodedSrc.match( regex );
+
+ if ( matches && matches[1] ) {
+ return mw.Title.newFromText( 'File:' + matches[1] );
+ }
+ }
+
+ return null;
+ };
+
+ /**
+ * Whether this title exists on the wiki.
+ *
+ * @static
+ * @param {string|mw.Title} title prefixed db-key name (string) or instance of Title
+ * @return {boolean|null} Boolean if the information is available, otherwise null
+ */
+ Title.exists = function ( title ) {
+ var match,
+ type = $.type( title ),
+ obj = Title.exist.pages;
+
+ if ( type === 'string' ) {
+ match = obj[title];
+ } else if ( type === 'object' && title instanceof Title ) {
+ match = obj[title.toString()];
+ } else {
+ throw new Error( 'mw.Title.exists: title must be a string or an instance of Title' );
+ }
+
+ if ( typeof match === 'boolean' ) {
+ return match;
+ }
+
+ return null;
+ };
+
+ /**
+ * Store page existence
+ *
+ * @static
+ * @property {Object} exist
+ * @property {Object} exist.pages Keyed by title. Boolean true value indicates page does exist.
+ *
+ * @property {Function} exist.set The setter function.
+ *
+ * Example to declare existing titles:
+ *
+ * Title.exist.set( ['User:John_Doe', ...] );
+ *
+ * Example to declare titles nonexistent:
+ *
+ * Title.exist.set( ['File:Foo_bar.jpg', ...], false );
+ *
+ * @property {string|Array} exist.set.titles Title(s) in strict prefixedDb title form
+ * @property {boolean} [exist.set.state=true] State of the given titles
+ * @return {boolean}
+ */
+ Title.exist = {
+ pages: {},
+
+ set: function ( titles, state ) {
+ titles = $.isArray( titles ) ? titles : [titles];
+ state = state === undefined ? true : !!state;
+ var pages = this.pages, i, len = titles.length;
+ for ( i = 0; i < len; i++ ) {
+ pages[ titles[i] ] = state;
+ }
+ return true;
+ }
+ };
+
+ /* Public members */
+
+ Title.prototype = {
+ constructor: Title,
+
+ /**
+ * Get the namespace number
+ *
+ * Example: 6 for "File:Example_image.svg".
+ *
+ * @return {number}
+ */
+ getNamespaceId: function () {
+ return this.namespace;
+ },
+
+ /**
+ * Get the namespace prefix (in the content language)
+ *
+ * Example: "File:" for "File:Example_image.svg".
+ * In #NS_MAIN this is '', otherwise namespace name plus ':'
+ *
+ * @return {string}
+ */
+ getNamespacePrefix: function () {
+ return this.namespace === NS_MAIN ?
+ '' :
+ ( mw.config.get( 'wgFormattedNamespaces' )[ this.namespace ].replace( / /g, '_' ) + ':' );
+ },
+
+ /**
+ * Get the page name without extension or namespace prefix
+ *
+ * Example: "Example_image" for "File:Example_image.svg".
+ *
+ * For the page title (full page name without namespace prefix), see #getMain.
+ *
+ * @return {string}
+ */
+ getName: function () {
+ if ( $.inArray( this.namespace, mw.config.get( 'wgCaseSensitiveNamespaces' ) ) !== -1 ) {
+ return this.title;
+ } else {
+ return $.ucFirst( this.title );
+ }
+ },
+
+ /**
+ * Get the page name (transformed by #text)
+ *
+ * Example: "Example image" for "File:Example_image.svg".
+ *
+ * For the page title (full page name without namespace prefix), see #getMainText.
+ *
+ * @return {string}
+ */
+ getNameText: function () {
+ return text( this.getName() );
+ },
+
+ /**
+ * Get the extension of the page name (if any)
+ *
+ * @return {string|null} Name extension or null if there is none
+ */
+ getExtension: function () {
+ return this.ext;
+ },
+
+ /**
+ * Shortcut for appendable string to form the main page name.
+ *
+ * Returns a string like ".json", or "" if no extension.
+ *
+ * @return {string}
+ */
+ getDotExtension: function () {
+ return this.ext === null ? '' : '.' + this.ext;
+ },
+
+ /**
+ * Get the main page name
+ *
+ * Example: "Example_image.svg" for "File:Example_image.svg".
+ *
+ * @return {string}
+ */
+ getMain: function () {
+ return this.getName() + this.getDotExtension();
+ },
+
+ /**
+ * Get the main page name (transformed by #text)
+ *
+ * Example: "Example image.svg" for "File:Example_image.svg".
+ *
+ * @return {string}
+ */
+ getMainText: function () {
+ return text( this.getMain() );
+ },
+
+ /**
+ * Get the full page name
+ *
+ * Example: "File:Example_image.svg".
+ * Most useful for API calls, anything that must identify the "title".
+ *
+ * @return {string}
+ */
+ getPrefixedDb: function () {
+ return this.getNamespacePrefix() + this.getMain();
+ },
+
+ /**
+ * Get the full page name (transformed by #text)
+ *
+ * Example: "File:Example image.svg" for "File:Example_image.svg".
+ *
+ * @return {string}
+ */
+ getPrefixedText: function () {
+ return text( this.getPrefixedDb() );
+ },
+
+ /**
+ * Get the page name relative to a namespace
+ *
+ * Example:
+ *
+ * - "Foo:Bar" relative to the Foo namespace becomes "Bar".
+ * - "Bar" relative to any non-main namespace becomes ":Bar".
+ * - "Foo:Bar" relative to any namespace other than Foo stays "Foo:Bar".
+ *
+ * @param {number} namespace The namespace to be relative to
+ * @return {string}
+ */
+ getRelativeText: function ( namespace ) {
+ if ( this.getNamespaceId() === namespace ) {
+ return this.getMainText();
+ } else if ( this.getNamespaceId() === NS_MAIN ) {
+ return ':' + this.getPrefixedText();
+ } else {
+ return this.getPrefixedText();
+ }
+ },
+
+ /**
+ * Get the fragment (if any).
+ *
+ * Note that this method (by design) does not include the hash character and
+ * the value is not url encoded.
+ *
+ * @return {string|null}
+ */
+ getFragment: function () {
+ return this.fragment;
+ },
+
+ /**
+ * Get the URL to this title
+ *
+ * @see mw.util#getUrl
+ * @param {Object} [params] A mapping of query parameter names to values,
+ * e.g. `{ action: 'edit' }`.
+ * @return {string}
+ */
+ getUrl: function ( params ) {
+ return mw.util.getUrl( this.toString(), params );
+ },
+
+ /**
+ * Whether this title exists on the wiki.
+ *
+ * @see #static-method-exists
+ * @return {boolean|null} Boolean if the information is available, otherwise null
+ */
+ exists: function () {
+ return Title.exists( this );
+ }
+ };
+
+ /**
+ * @alias #getPrefixedDb
+ * @method
+ */
+ Title.prototype.toString = Title.prototype.getPrefixedDb;
+
+ /**
+ * @alias #getPrefixedText
+ * @method
+ */
+ Title.prototype.toText = Title.prototype.getPrefixedText;
+
+ // Expose
+ mw.Title = Title;
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki/mediawiki.Uri.js b/resources/src/mediawiki/mediawiki.Uri.js
new file mode 100644
index 00000000..55663128
--- /dev/null
+++ b/resources/src/mediawiki/mediawiki.Uri.js
@@ -0,0 +1,403 @@
+/**
+ * Library for simple URI parsing and manipulation.
+ *
+ * Intended to be minimal, but featureful; do not expect full RFC 3986 compliance. The use cases we
+ * have in mind are constructing 'next page' or 'previous page' URLs, detecting whether we need to
+ * use cross-domain proxies for an API, constructing simple URL-based API calls, etc. Parsing here
+ * is regex-based, so may not work on all URIs, but is good enough for most.
+ *
+ * You can modify the properties directly, then use the #toString method to extract the full URI
+ * string again. Example:
+ *
+ * var uri = new mw.Uri( 'http://example.com/mysite/mypage.php?quux=2' );
+ *
+ * if ( uri.host == 'example.com' ) {
+ * uri.host = 'foo.example.com';
+ * uri.extend( { bar: 1 } );
+ *
+ * $( 'a#id1' ).attr( 'href', uri );
+ * // anchor with id 'id1' now links to http://foo.example.com/mysite/mypage.php?bar=1&quux=2
+ *
+ * $( 'a#id2' ).attr( 'href', uri.clone().extend( { bar: 3, pif: 'paf' } ) );
+ * // anchor with id 'id2' now links to http://foo.example.com/mysite/mypage.php?bar=3&quux=2&pif=paf
+ * }
+ *
+ * Given a URI like
+ * `http://usr:pwd@www.example.com:81/dir/dir.2/index.htm?q1=0&&test1&test2=&test3=value+%28escaped%29&r=1&r=2#top`
+ * the returned object will have the following properties:
+ *
+ * protocol 'http'
+ * user 'usr'
+ * password 'pwd'
+ * host 'www.example.com'
+ * port '81'
+ * path '/dir/dir.2/index.htm'
+ * query {
+ * q1: '0',
+ * test1: null,
+ * test2: '',
+ * test3: 'value (escaped)'
+ * r: ['1', '2']
+ * }
+ * fragment 'top'
+ *
+ * (N.b., 'password' is technically not allowed for HTTP URIs, but it is possible with other kinds
+ * of URIs.)
+ *
+ * Parsing based on parseUri 1.2.2 (c) Steven Levithan <http://stevenlevithan.com>, MIT License.
+ * <http://stevenlevithan.com/demo/parseuri/js/>
+ *
+ * @class mw.Uri
+ */
+
+( function ( mw, $ ) {
+ /**
+ * Function that's useful when constructing the URI string -- we frequently encounter the pattern
+ * of having to add something to the URI as we go, but only if it's present, and to include a
+ * character before or after if so.
+ *
+ * @private
+ * @static
+ * @param {string|undefined} pre To prepend
+ * @param {string} val To include
+ * @param {string} post To append
+ * @param {boolean} raw If true, val will not be encoded
+ * @return {string} Result
+ */
+ function cat( pre, val, post, raw ) {
+ if ( val === undefined || val === null || val === '' ) {
+ return '';
+ }
+ return pre + ( raw ? val : mw.Uri.encode( val ) ) + post;
+ }
+
+ /**
+ * Regular expressions to parse many common URIs.
+ *
+ * @private
+ * @static
+ * @property {Object} parser
+ */
+ var parser = {
+ strict: /^(?:([^:\/?#]+):)?(?:\/\/(?:(?:([^:@\/?#]*)(?::([^:@\/?#]*))?)?@)?([^:\/?#]*)(?::(\d*))?)?((?:[^?#\/]*\/)*[^?#]*)(?:\?([^#]*))?(?:#(.*))?/,
+ loose: /^(?:(?![^:@]+:[^:@\/]*@)([^:\/?#.]+):)?(?:\/\/)?(?:(?:([^:@\/?#]*)(?::([^:@\/?#]*))?)?@)?([^:\/?#]*)(?::(\d*))?((?:\/(?:[^?#](?![^?#\/]*\.[^?#\/.]+(?:[?#]|$)))*\/?)?[^?#\/]*)(?:\?([^#]*))?(?:#(.*))?/
+ },
+
+ /**
+ * The order here matches the order of captured matches in the `parser` property regexes.
+ *
+ * @private
+ * @static
+ * @property {Array} properties
+ */
+ properties = [
+ 'protocol',
+ 'user',
+ 'password',
+ 'host',
+ 'port',
+ 'path',
+ 'query',
+ 'fragment'
+ ];
+
+ /**
+ * @property {string} protocol For example `http` (always present)
+ */
+ /**
+ * @property {string|undefined} user For example `usr`
+ */
+ /**
+ * @property {string|undefined} password For example `pwd`
+ */
+ /**
+ * @property {string} host For example `www.example.com` (always present)
+ */
+ /**
+ * @property {string|undefined} port For example `81`
+ */
+ /**
+ * @property {string} path For example `/dir/dir.2/index.htm` (always present)
+ */
+ /**
+ * @property {Object} query For example `{ a: '0', b: '', c: 'value' }` (always present)
+ */
+ /**
+ * @property {string|undefined} fragment For example `top`
+ */
+
+ /**
+ * A factory method to create a variation of mw.Uri with a different default location (for
+ * relative URLs, including protocol-relative URLs). Used so the library is still testable &
+ * purely functional.
+ *
+ * @method
+ * @member mw
+ */
+ mw.UriRelative = function ( documentLocation ) {
+ var defaultUri;
+
+ /**
+ * @class mw.Uri
+ * @constructor
+ *
+ * Construct a new URI object. Throws error if arguments are illegal/impossible, or
+ * otherwise don't parse.
+ *
+ * @param {Object|string} [uri] URI string, or an Object with appropriate properties (especially
+ * another URI object to clone). Object must have non-blank `protocol`, `host`, and `path`
+ * properties. If omitted (or set to `undefined`, `null` or empty string), then an object
+ * will be created for the default `uri` of this constructor (`document.location` for
+ * mw.Uri, other values for other instances -- see mw.UriRelative for details).
+ * @param {Object|boolean} [options] Object with options, or (backwards compatibility) a boolean
+ * for strictMode
+ * @param {boolean} [options.strictMode=false] Trigger strict mode parsing of the url.
+ * @param {boolean} [options.overrideKeys=false] Whether to let duplicate query parameters
+ * override each other (`true`) or automagically convert them to an array (`false`).
+ */
+ function Uri( uri, options ) {
+ options = typeof options === 'object' ? options : { strictMode: !!options };
+ options = $.extend( {
+ strictMode: false,
+ overrideKeys: false
+ }, options );
+
+ if ( uri !== undefined && uri !== null && uri !== '' ) {
+ if ( typeof uri === 'string' ) {
+ this.parse( uri, options );
+ } else if ( typeof uri === 'object' ) {
+ // Copy data over from existing URI object
+ for ( var prop in uri ) {
+ // Only copy direct properties, not inherited ones
+ if ( uri.hasOwnProperty( prop ) ) {
+ // Deep copy object properties
+ if ( $.isArray( uri[prop] ) || $.isPlainObject( uri[prop] ) ) {
+ this[prop] = $.extend( true, {}, uri[prop] );
+ } else {
+ this[prop] = uri[prop];
+ }
+ }
+ }
+ if ( !this.query ) {
+ this.query = {};
+ }
+ }
+ } else {
+ // If we didn't get a URI in the constructor, use the default one.
+ return defaultUri.clone();
+ }
+
+ // protocol-relative URLs
+ if ( !this.protocol ) {
+ this.protocol = defaultUri.protocol;
+ }
+ // No host given:
+ if ( !this.host ) {
+ this.host = defaultUri.host;
+ // port ?
+ if ( !this.port ) {
+ this.port = defaultUri.port;
+ }
+ }
+ if ( this.path && this.path.charAt( 0 ) !== '/' ) {
+ // A real relative URL, relative to defaultUri.path. We can't really handle that since we cannot
+ // figure out whether the last path component of defaultUri.path is a directory or a file.
+ throw new Error( 'Bad constructor arguments' );
+ }
+ if ( !( this.protocol && this.host && this.path ) ) {
+ throw new Error( 'Bad constructor arguments' );
+ }
+ }
+
+ /**
+ * Encode a value for inclusion in a url.
+ *
+ * Standard encodeURIComponent, with extra stuff to make all browsers work similarly and more
+ * compliant with RFC 3986. Similar to rawurlencode from PHP and our JS library
+ * mw.util.rawurlencode, except this also replaces spaces with `+`.
+ *
+ * @static
+ * @param {string} s String to encode
+ * @return {string} Encoded string for URI
+ */
+ Uri.encode = function ( s ) {
+ return encodeURIComponent( s )
+ .replace( /!/g, '%21' ).replace( /'/g, '%27' ).replace( /\(/g, '%28' )
+ .replace( /\)/g, '%29' ).replace( /\*/g, '%2A' )
+ .replace( /%20/g, '+' );
+ };
+
+ /**
+ * Decode a url encoded value.
+ *
+ * Reversed #encode. Standard decodeURIComponent, with addition of replacing
+ * `+` with a space.
+ *
+ * @static
+ * @param {string} s String to decode
+ * @return {string} Decoded string
+ */
+ Uri.decode = function ( s ) {
+ return decodeURIComponent( s.replace( /\+/g, '%20' ) );
+ };
+
+ Uri.prototype = {
+
+ /**
+ * Parse a string and set our properties accordingly.
+ *
+ * @private
+ * @param {string} str URI, see constructor.
+ * @param {Object} options See constructor.
+ */
+ parse: function ( str, options ) {
+ var q, matches,
+ uri = this;
+
+ // Apply parser regex and set all properties based on the result
+ matches = parser[ options.strictMode ? 'strict' : 'loose' ].exec( str );
+ $.each( properties, function ( i, property ) {
+ uri[ property ] = matches[ i + 1 ];
+ } );
+
+ // uri.query starts out as the query string; we will parse it into key-val pairs then make
+ // that object the "query" property.
+ // we overwrite query in uri way to make cloning easier, it can use the same list of properties.
+ q = {};
+ // using replace to iterate over a string
+ if ( uri.query ) {
+ uri.query.replace( /(?:^|&)([^&=]*)(?:(=)([^&]*))?/g, function ( $0, $1, $2, $3 ) {
+ var k, v;
+ if ( $1 ) {
+ k = Uri.decode( $1 );
+ v = ( $2 === '' || $2 === undefined ) ? null : Uri.decode( $3 );
+
+ // If overrideKeys, always (re)set top level value.
+ // If not overrideKeys but this key wasn't set before, then we set it as well.
+ if ( options.overrideKeys || q[ k ] === undefined ) {
+ q[ k ] = v;
+
+ // Use arrays if overrideKeys is false and key was already seen before
+ } else {
+ // Once before, still a string, turn into an array
+ if ( typeof q[ k ] === 'string' ) {
+ q[ k ] = [ q[ k ] ];
+ }
+ // Add to the array
+ if ( $.isArray( q[ k ] ) ) {
+ q[ k ].push( v );
+ }
+ }
+ }
+ } );
+ }
+ uri.query = q;
+ },
+
+ /**
+ * Get user and password section of a URI.
+ *
+ * @return {string}
+ */
+ getUserInfo: function () {
+ return cat( '', this.user, cat( ':', this.password, '' ) );
+ },
+
+ /**
+ * Get host and port section of a URI.
+ *
+ * @return {string}
+ */
+ getHostPort: function () {
+ return this.host + cat( ':', this.port, '' );
+ },
+
+ /**
+ * Get the userInfo, host and port section of the URI.
+ *
+ * In most real-world URLs this is simply the hostname, but the definition of 'authority' section is more general.
+ *
+ * @return {string}
+ */
+ getAuthority: function () {
+ return cat( '', this.getUserInfo(), '@' ) + this.getHostPort();
+ },
+
+ /**
+ * Get the query arguments of the URL, encoded into a string.
+ *
+ * Does not preserve the original order of arguments passed in the URI. Does handle escaping.
+ *
+ * @return {string}
+ */
+ getQueryString: function () {
+ var args = [];
+ $.each( this.query, function ( key, val ) {
+ var k = Uri.encode( key ),
+ vals = $.isArray( val ) ? val : [ val ];
+ $.each( vals, function ( i, v ) {
+ if ( v === null ) {
+ args.push( k );
+ } else if ( k === 'title' ) {
+ args.push( k + '=' + mw.util.wikiUrlencode( v ) );
+ } else {
+ args.push( k + '=' + Uri.encode( v ) );
+ }
+ } );
+ } );
+ return args.join( '&' );
+ },
+
+ /**
+ * Get everything after the authority section of the URI.
+ *
+ * @return {string}
+ */
+ getRelativePath: function () {
+ return this.path + cat( '?', this.getQueryString(), '', true ) + cat( '#', this.fragment, '' );
+ },
+
+ /**
+ * Get the entire URI string.
+ *
+ * May not be precisely the same as input due to order of query arguments.
+ *
+ * @return {string} The URI string
+ */
+ toString: function () {
+ return this.protocol + '://' + this.getAuthority() + this.getRelativePath();
+ },
+
+ /**
+ * Clone this URI
+ *
+ * @return {Object} New URI object with same properties
+ */
+ clone: function () {
+ return new Uri( this );
+ },
+
+ /**
+ * Extend the query section of the URI with new parameters.
+ *
+ * @param {Object} parameters Query parameters to add to ours (or to override ours with) as an
+ * object
+ * @return {Object} This URI object
+ */
+ extend: function ( parameters ) {
+ $.extend( this.query, parameters );
+ return this;
+ }
+ };
+
+ defaultUri = new Uri( documentLocation );
+
+ return Uri;
+ };
+
+ // If we are running in a browser, inject the current document location (for relative URLs).
+ if ( document && document.location && document.location.href ) {
+ mw.Uri = mw.UriRelative( document.location.href );
+ }
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki/mediawiki.content.json.css b/resources/src/mediawiki/mediawiki.content.json.css
new file mode 100644
index 00000000..d93e291e
--- /dev/null
+++ b/resources/src/mediawiki/mediawiki.content.json.css
@@ -0,0 +1,53 @@
+/*!
+ * CSS for styling HTML-formatted JSON Schema objects
+ *
+ * @file
+ * @author Munaf Assaf <massaf@wikimedia.org>
+ */
+
+.mw-json {
+ border-collapse: collapse;
+ border-spacing: 0;
+ font-style: normal;
+}
+
+.mw-json th,
+.mw-json td {
+ border: 1px solid gray;
+ font-size: 16px;
+ padding: 0.5em 1em;
+}
+
+.mw-json td {
+ background-color: #eee;
+ font-style: italic;
+}
+
+.mw-json .value {
+ background-color: #dcfae3;
+ font-family: monospace, monospace;
+ white-space: pre-wrap;
+}
+
+.mw-json tr {
+ margin-bottom: 0.5em;
+}
+
+.mw-json th {
+ background-color: #fff;
+ font-weight: normal;
+}
+
+.mw-json caption {
+ /* For stylistic reasons, suppress the caption of the outermost table */
+ display: none;
+}
+
+.mw-json table caption {
+ color: gray;
+ display: inline-block;
+ font-size: 10px;
+ font-style: italic;
+ margin-bottom: 0.5em;
+ text-align: left;
+}
diff --git a/resources/src/mediawiki/mediawiki.cookie.js b/resources/src/mediawiki/mediawiki.cookie.js
new file mode 100644
index 00000000..6f9f0abb
--- /dev/null
+++ b/resources/src/mediawiki/mediawiki.cookie.js
@@ -0,0 +1,126 @@
+( function ( mw, $ ) {
+ 'use strict';
+
+ /**
+ * Provides an API for getting and setting cookies that is
+ * syntactically and functionally similar to the server-side cookie
+ * API (`WebRequest#getCookie` and `WebResponse#setcookie`).
+ *
+ * @author Sam Smith <samsmith@wikimedia.org>
+ * @author Matthew Flaschen <mflaschen@wikimedia.org>
+ * @author Timo Tijhof <krinklemail@gmail.com>
+ *
+ * @class mw.cookie
+ * @singleton
+ */
+ mw.cookie = {
+
+ /**
+ * Sets or deletes a cookie.
+ *
+ * While this is natural in JavaScript, contrary to `WebResponse#setcookie` in PHP, the
+ * default values for the `options` properties only apply if that property isn't set
+ * already in your options object (e.g. passing `{ secure: null }` or `{ secure: undefined }`
+ * overrides the default value for `options.secure`).
+ *
+ * @param {string} key
+ * @param {string|null} value Value of cookie. If `value` is `null` then this method will
+ * instead remove a cookie by name of `key`.
+ * @param {Object|Date} [options] Options object, or expiry date
+ * @param {Date|null} [options.expires=wgCookieExpiration] The expiry date of the cookie.
+ *
+ * Default cookie expiration is based on `wgCookieExpiration`. If `wgCookieExpiration` is
+ * 0, a session cookie is set (expires when the browser is closed). For non-zero values of
+ * `wgCookieExpiration`, the cookie expires `wgCookieExpiration` seconds from now.
+ *
+ * If options.expires is null, then a session cookie is set.
+ * @param {string} [options.prefix=wgCookiePrefix] The prefix of the key
+ * @param {string} [options.domain=wgCookieDomain] The domain attribute of the cookie
+ * @param {string} [options.path=wgCookiePath] The path attribute of the cookie
+ * @param {boolean} [options.secure=false] Whether or not to include the secure attribute.
+ * (Does **not** use the wgCookieSecure configuration variable)
+ */
+ set: function ( key, value, options ) {
+ var config, defaultOptions, date;
+
+ // wgCookieSecure is not used for now, since 'detect' could not work with
+ // ResourceLoaderStartUpModule, as module cache is not fragmented by protocol.
+ config = mw.config.get( [
+ 'wgCookiePrefix',
+ 'wgCookieDomain',
+ 'wgCookiePath',
+ 'wgCookieExpiration'
+ ] );
+
+ defaultOptions = {
+ prefix: config.wgCookiePrefix,
+ domain: config.wgCookieDomain,
+ path: config.wgCookiePath,
+ secure: false
+ };
+
+ // Options argument can also be a shortcut for the expiry
+ // Expiry can be a Date or null
+ if ( $.type( options ) !== 'object' ) {
+ // Also takes care of options = undefined, in which case we also don't need $.extend()
+ defaultOptions.expires = options;
+ options = defaultOptions;
+ } else {
+ options = $.extend( defaultOptions, options );
+ }
+
+ // $.cookie makes session cookies when expiry is omitted,
+ // however our default is to expire wgCookieExpiration seconds from now.
+ // Note: If wgCookieExpiration is 0, that is considered a special value indicating
+ // all cookies should be session cookies by default.
+ if ( options.expires === undefined && config.wgCookieExpiration !== 0 ) {
+ date = new Date();
+ date.setTime( Number( date ) + ( config.wgCookieExpiration * 1000 ) );
+ options.expires = date;
+ } else if ( options.expires === null ) {
+ // $.cookie makes a session cookie when expires is omitted
+ delete options.expires;
+ }
+
+ // Process prefix
+ key = options.prefix + key;
+ delete options.prefix;
+
+ // Process value
+ if ( value !== null ) {
+ value = String( value );
+ }
+
+ // Other options are handled by $.cookie
+ $.cookie( key, value, options );
+ },
+
+ /**
+ * Gets the value of a cookie.
+ *
+ * @param {string} key
+ * @param {string} [prefix=wgCookiePrefix] The prefix of the key. If `prefix` is
+ * `undefined` or `null`, then `wgCookiePrefix` is used
+ * @param {Mixed} [defaultValue=null]
+ * @return {string} If the cookie exists, then the value of the
+ * cookie, otherwise `defaultValue`
+ */
+ get: function ( key, prefix, defaultValue ) {
+ var result;
+
+ if ( prefix === undefined || prefix === null ) {
+ prefix = mw.config.get( 'wgCookiePrefix' );
+ }
+
+ // Was defaultValue omitted?
+ if ( arguments.length < 3 ) {
+ defaultValue = null;
+ }
+
+ result = $.cookie( prefix + key );
+
+ return result !== null ? result : defaultValue;
+ }
+ };
+
+} ( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki/mediawiki.debug.init.js b/resources/src/mediawiki/mediawiki.debug.init.js
new file mode 100644
index 00000000..0f85e80d
--- /dev/null
+++ b/resources/src/mediawiki/mediawiki.debug.init.js
@@ -0,0 +1,3 @@
+jQuery( function () {
+ mediaWiki.Debug.init();
+} );
diff --git a/resources/src/mediawiki/mediawiki.debug.js b/resources/src/mediawiki/mediawiki.debug.js
new file mode 100644
index 00000000..4935984f
--- /dev/null
+++ b/resources/src/mediawiki/mediawiki.debug.js
@@ -0,0 +1,391 @@
+( function ( mw, $ ) {
+ 'use strict';
+
+ var debug,
+ hovzer = $.getFootHovzer();
+
+ /**
+ * Debug toolbar.
+ *
+ * Enabled server-side through `$wgDebugToolbar`.
+ *
+ * @class mw.Debug
+ * @singleton
+ * @author John Du Hart
+ * @since 1.19
+ */
+ debug = mw.Debug = {
+ /**
+ * Toolbar container element
+ *
+ * @property {jQuery}
+ */
+ $container: null,
+
+ /**
+ * Object containing data for the debug toolbar
+ *
+ * @property {Object}
+ */
+ data: {},
+
+ /**
+ * Initialize the debugging pane
+ *
+ * Shouldn't be called before the document is ready
+ * (since it binds to elements on the page).
+ *
+ * @param {Object} [data] Defaults to 'debugInfo' from mw.config
+ */
+ init: function ( data ) {
+
+ this.data = data || mw.config.get( 'debugInfo' );
+ this.buildHtml();
+
+ // Insert the container into the DOM
+ hovzer.$.append( this.$container );
+ hovzer.update();
+
+ $( '.mw-debug-panelink' ).click( this.switchPane );
+ },
+
+ /**
+ * Switch between panes
+ *
+ * Should be called with an HTMLElement as its thisArg,
+ * because it's meant to be an event handler.
+ *
+ * TODO: Store cookie for last pane open.
+ *
+ * @param {jQuery.Event} e
+ */
+ switchPane: function ( e ) {
+ var currentPaneId = debug.$container.data( 'currentPane' ),
+ requestedPaneId = $( this ).prop( 'id' ).slice( 9 ),
+ $currentPane = $( '#mw-debug-pane-' + currentPaneId ),
+ $requestedPane = $( '#mw-debug-pane-' + requestedPaneId ),
+ hovDone = false;
+
+ function updateHov() {
+ if ( !hovDone ) {
+ hovzer.update();
+ hovDone = true;
+ }
+ }
+
+ // Skip hash fragment handling. Prevents screen from jumping.
+ e.preventDefault();
+
+ $( this ).addClass( 'current ' );
+ $( '.mw-debug-panelink' ).not( this ).removeClass( 'current ' );
+
+ // Hide the current pane
+ if ( requestedPaneId === currentPaneId ) {
+ $currentPane.slideUp( updateHov );
+ debug.$container.data( 'currentPane', null );
+ return;
+ }
+
+ debug.$container.data( 'currentPane', requestedPaneId );
+
+ if ( currentPaneId === undefined || currentPaneId === null ) {
+ $requestedPane.slideDown( updateHov );
+ } else {
+ $currentPane.hide();
+ $requestedPane.show();
+ updateHov();
+ }
+ },
+
+ /**
+ * Construct the HTML for the debugging toolbar
+ */
+ buildHtml: function () {
+ var $container, $bits, panes, id, gitInfo;
+
+ $container = $( '<div id="mw-debug-toolbar" class="mw-debug" lang="en" dir="ltr"></div>' );
+
+ $bits = $( '<div class="mw-debug-bits"></div>' );
+
+ /**
+ * Returns a jQuery element for a debug-bit div
+ *
+ * @ignore
+ * @param {string} id
+ * @return {jQuery}
+ */
+ function bitDiv( id ) {
+ return $( '<div>' ).prop( {
+ id: 'mw-debug-' + id,
+ className: 'mw-debug-bit'
+ } )
+ .appendTo( $bits );
+ }
+
+ /**
+ * Returns a jQuery element for a pane link
+ *
+ * @ignore
+ * @param {string} id
+ * @param {string} text
+ * @return {jQuery}
+ */
+ function paneLabel( id, text ) {
+ return $( '<a>' )
+ .prop( {
+ className: 'mw-debug-panelabel',
+ href: '#mw-debug-pane-' + id
+ } )
+ .text( text );
+ }
+
+ /**
+ * Returns a jQuery element for a debug-bit div with a for a pane link
+ *
+ * @ignore
+ * @param {string} id CSS id snippet. Will be prefixed with 'mw-debug-'
+ * @param {string} text Text to show
+ * @param {string} count Optional count to show
+ * @return {jQuery}
+ */
+ function paneTriggerBitDiv( id, text, count ) {
+ if ( count ) {
+ text = text + ' (' + count + ')';
+ }
+ return $( '<div>' ).prop( {
+ id: 'mw-debug-' + id,
+ className: 'mw-debug-bit mw-debug-panelink'
+ } )
+ .append( paneLabel( id, text ) )
+ .appendTo( $bits );
+ }
+
+ paneTriggerBitDiv( 'console', 'Console', this.data.log.length );
+
+ paneTriggerBitDiv( 'querylist', 'Queries', this.data.queries.length );
+
+ paneTriggerBitDiv( 'debuglog', 'Debug log', this.data.debugLog.length );
+
+ paneTriggerBitDiv( 'request', 'Request' );
+
+ paneTriggerBitDiv( 'includes', 'PHP includes', this.data.includes.length );
+
+ paneTriggerBitDiv( 'profile', 'Profile', this.data.profile.length );
+
+ gitInfo = '';
+ if ( this.data.gitRevision !== false ) {
+ gitInfo = '(' + this.data.gitRevision.slice( 0, 7 ) + ')';
+ if ( this.data.gitViewUrl !== false ) {
+ gitInfo = $( '<a>' )
+ .attr( 'href', this.data.gitViewUrl )
+ .text( gitInfo );
+ }
+ }
+
+ bitDiv( 'mwversion' )
+ .append( $( '<a href="//www.mediawiki.org/">MediaWiki</a>' ) )
+ .append( document.createTextNode( ': ' + this.data.mwVersion + ' ' ) )
+ .append( gitInfo );
+
+ if ( this.data.gitBranch !== false ) {
+ bitDiv( 'gitbranch' ).text( 'Git branch: ' + this.data.gitBranch );
+ }
+
+ bitDiv( 'phpversion' )
+ .append( $( this.data.phpEngine === 'HHVM'
+ ? '<a href="http://hhvm.com/">HHVM</a>'
+ : '<a href="https://php.net/">PHP</a>'
+ ) )
+ .append( ': ' + this.data.phpVersion );
+
+ bitDiv( 'time' )
+ .text( 'Time: ' + this.data.time.toFixed( 5 ) );
+
+ bitDiv( 'memory' )
+ .text( 'Memory: ' + this.data.memory + ' (Peak: ' + this.data.memoryPeak + ')' );
+
+ $bits.appendTo( $container );
+
+ panes = {
+ console: this.buildConsoleTable(),
+ querylist: this.buildQueryTable(),
+ debuglog: this.buildDebugLogTable(),
+ request: this.buildRequestPane(),
+ includes: this.buildIncludesPane(),
+ profile: this.buildProfilePane()
+ };
+
+ for ( id in panes ) {
+ if ( !panes.hasOwnProperty( id ) ) {
+ continue;
+ }
+
+ $( '<div>' )
+ .prop( {
+ className: 'mw-debug-pane',
+ id: 'mw-debug-pane-' + id
+ } )
+ .append( panes[id] )
+ .appendTo( $container );
+ }
+
+ this.$container = $container;
+ },
+
+ /**
+ * Build the console panel
+ */
+ buildConsoleTable: function () {
+ var $table, entryTypeText, i, length, entry;
+
+ $table = $( '<table id="mw-debug-console">' );
+
+ $( '<colgroup>' ).css( 'width', /* padding = */ 20 + ( 10 * /* fontSize = */ 11 ) ).appendTo( $table );
+ $( '<colgroup>' ).appendTo( $table );
+ $( '<colgroup>' ).css( 'width', 350 ).appendTo( $table );
+
+ entryTypeText = function ( entryType ) {
+ switch ( entryType ) {
+ case 'log':
+ return 'Log';
+ case 'warn':
+ return 'Warning';
+ case 'deprecated':
+ return 'Deprecated';
+ default:
+ return 'Unknown';
+ }
+ };
+
+ for ( i = 0, length = this.data.log.length; i < length; i += 1 ) {
+ entry = this.data.log[i];
+ entry.typeText = entryTypeText( entry.type );
+
+ $( '<tr>' )
+ .append( $( '<td>' )
+ .text( entry.typeText )
+ .addClass( 'mw-debug-console-' + entry.type )
+ )
+ .append( $( '<td>' ).html( entry.msg ) )
+ .append( $( '<td>' ).text( entry.caller ) )
+ .appendTo( $table );
+ }
+
+ return $table;
+ },
+
+ /**
+ * Build query list pane
+ *
+ * @return {jQuery}
+ */
+ buildQueryTable: function () {
+ var $table, i, length, query;
+
+ $table = $( '<table id="mw-debug-querylist"></table>' );
+
+ $( '<tr>' )
+ .append( $( '<th>#</th>' ).css( 'width', '4em' ) )
+ .append( $( '<th>SQL</th>' ) )
+ .append( $( '<th>Time</th>' ).css( 'width', '8em' ) )
+ .append( $( '<th>Call</th>' ).css( 'width', '18em' ) )
+ .appendTo( $table );
+
+ for ( i = 0, length = this.data.queries.length; i < length; i += 1 ) {
+ query = this.data.queries[i];
+
+ $( '<tr>' )
+ .append( $( '<td>' ).text( i + 1 ) )
+ .append( $( '<td>' ).text( query.sql ) )
+ .append( $( '<td class="stats">' ).text( ( query.time * 1000 ).toFixed( 4 ) + 'ms' ) )
+ .append( $( '<td>' ).text( query['function'] ) )
+ .appendTo( $table );
+ }
+
+ return $table;
+ },
+
+ /**
+ * Build legacy debug log pane
+ *
+ * @return {jQuery}
+ */
+ buildDebugLogTable: function () {
+ var $list, i, length, line;
+ $list = $( '<ul>' );
+
+ for ( i = 0, length = this.data.debugLog.length; i < length; i += 1 ) {
+ line = this.data.debugLog[i];
+ $( '<li>' )
+ .html( mw.html.escape( line ).replace( /\n/g, '<br />\n' ) )
+ .appendTo( $list );
+ }
+
+ return $list;
+ },
+
+ /**
+ * Build request information pane
+ *
+ * @return {jQuery}
+ */
+ buildRequestPane: function () {
+
+ function buildTable( title, data ) {
+ var $unit, $table, key;
+
+ $unit = $( '<div>' ).append( $( '<h2>' ).text( title ) );
+
+ $table = $( '<table>' ).appendTo( $unit );
+
+ $( '<tr>' )
+ .html( '<th>Key</th><th>Value</th>' )
+ .appendTo( $table );
+
+ for ( key in data ) {
+ if ( !data.hasOwnProperty( key ) ) {
+ continue;
+ }
+
+ $( '<tr>' )
+ .append( $( '<th>' ).text( key ) )
+ .append( $( '<td>' ).text( data[key] ) )
+ .appendTo( $table );
+ }
+
+ return $unit;
+ }
+
+ return $( '<div>' )
+ .text( this.data.request.method + ' ' + this.data.request.url )
+ .append( buildTable( 'Headers', this.data.request.headers ) )
+ .append( buildTable( 'Parameters', this.data.request.params ) );
+ },
+
+ /**
+ * Build included files pane
+ *
+ * @return {jQuery}
+ */
+ buildIncludesPane: function () {
+ var $table, i, length, file;
+
+ $table = $( '<table>' );
+
+ for ( i = 0, length = this.data.includes.length; i < length; i += 1 ) {
+ file = this.data.includes[i];
+ $( '<tr>' )
+ .append( $( '<td>' ).text( file.name ) )
+ .append( $( '<td class="nr">' ).text( file.size ) )
+ .appendTo( $table );
+ }
+
+ return $table;
+ },
+
+ buildProfilePane: function () {
+ return mw.Debug.profile.init();
+ }
+ };
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki/mediawiki.debug.less b/resources/src/mediawiki/mediawiki.debug.less
new file mode 100644
index 00000000..949c5586
--- /dev/null
+++ b/resources/src/mediawiki/mediawiki.debug.less
@@ -0,0 +1,189 @@
+.mw-debug {
+ width: 100%;
+ background-color: #eee;
+ border-top: 1px solid #aaa;
+
+ pre {
+ font-size: 11px;
+ padding: 0;
+ margin: 0;
+ background: none;
+ border: none;
+ }
+
+ table {
+ border-spacing: 0;
+ width: 100%;
+ table-layout: fixed;
+
+ td,
+ th {
+ padding: 4px 10px;
+ }
+
+ td {
+ border-bottom: 1px solid #eee;
+ word-wrap: break-word;
+
+ &.nr {
+ text-align: right;
+ }
+
+ span.stats {
+ color: #808080;
+ }
+ }
+
+ tr {
+ background-color: #fff;
+
+ &:nth-child(even) {
+ background-color: #f9f9f9;
+ }
+ }
+ }
+
+ ul {
+ margin: 0;
+ list-style: none;
+ }
+
+ li {
+ padding: 4px 0;
+ width: 100%;
+ }
+}
+
+.mw-debug-bits {
+ text-align: center;
+ border-bottom: 1px solid #aaa;
+}
+
+.mw-debug-bit {
+ display: inline-block;
+ padding: 10px 5px;
+ font-size: 13px;
+ /* IE-hack for display: inline-block */
+ zoom: 1;
+ *display:inline;
+}
+
+.mw-debug-panelink {
+ background-color: #eee;
+ border-right: 1px solid #ccc;
+
+ &:first-child {
+ border-left: 1px solid #ccc;
+ }
+
+ &:hover {
+ background-color: #fefefe;
+ cursor: pointer;
+ }
+
+ &.current {
+ background-color: #dedede;
+ }
+}
+
+a.mw-debug-panelabel,
+a.mw-debug-panelabel:visited {
+ color: #000;
+}
+
+.mw-debug-pane {
+ height: 300px;
+ overflow: scroll;
+ display: none;
+ font-size: 11px;
+ background-color: #e1eff2;
+ box-sizing: border-box;
+}
+
+#mw-debug-pane-debuglog,
+#mw-debug-pane-request {
+ padding: 20px;
+}
+
+#mw-debug-pane-request {
+ table {
+ width: 100%;
+ margin: 10px 0 30px;
+ }
+
+ tr,
+ th,
+ td,
+ table {
+ border: 1px solid #D0DBB3;
+ border-collapse: collapse;
+ margin: 0;
+ }
+
+ th,
+ td {
+ font-size: 12px;
+ padding: 8px 10px;
+ }
+
+ th {
+ background-color: #F1F7E2;
+ font-weight: bold;
+ }
+
+ td {
+ background-color: white;
+ }
+}
+
+#mw-debug-console tr td {
+ &:first-child {
+ font-weight: bold;
+ vertical-align: top;
+ }
+
+ &:last-child {
+ vertical-align: top;
+ }
+}
+
+.mw-debug-backtrace {
+ padding: 5px 10px;
+ margin: 5px;
+ background-color: #dedede;
+
+ span {
+ font-weight: bold;
+ color: #111;
+ }
+
+ ul {
+ padding-left: 10px;
+ }
+
+ li {
+ width: auto;
+ padding: 0;
+ color: #333;
+ font-size: 10px;
+ margin-bottom: 0;
+ line-height: 1em;
+ }
+}
+
+.mw-debug-console-log {
+ background-color: #add8e6;
+}
+
+.mw-debug-console-warn {
+ background-color: #ffa07a;
+}
+
+.mw-debug-console-deprecated {
+ background-color: #ffb6c1;
+}
+
+/* Cheapo hack to hide the first 3 lines of the backtrace */
+.mw-debug-backtrace li:nth-child(-n+3) {
+ display: none;
+}
diff --git a/resources/src/mediawiki/mediawiki.debug.profile.css b/resources/src/mediawiki/mediawiki.debug.profile.css
new file mode 100644
index 00000000..ab27da9d
--- /dev/null
+++ b/resources/src/mediawiki/mediawiki.debug.profile.css
@@ -0,0 +1,45 @@
+.mw-debug-profile-tipsy .tipsy-inner {
+ /* undo max-width from vector on .tipsy-inner */
+ max-width: none;
+ /* needed for some browsers to provide space for the scrollbar without wrapping text */
+ min-width: 100%;
+ max-height: 150px;
+ overflow-y: auto;
+}
+
+.mw-debug-profile-underline {
+ stroke-width: 1;
+ stroke: #dfdfdf;
+}
+
+.mw-debug-profile-period {
+ fill: red;
+}
+
+/* connecting line between endpoints on long events */
+.mw-debug-profile-period line {
+ stroke: red;
+ stroke-width: 2;
+}
+
+.mw-debug-profile-tipsy,
+.mw-debug-profile-timeline text {
+ color: #444;
+ fill: #444;
+ /* using em's causes the two locations to have different sizes */
+ font-size: 12px;
+ font-family: sans-serif;
+}
+
+.mw-debug-profile-meta,
+.mw-debug-profile-timeline tspan {
+ /* using em's causes the two locations to have different sizes */
+ font-size: 10px;
+}
+
+.mw-debug-profile-no-data {
+ text-align: center;
+ padding-top: 5em;
+ font-weight: bold;
+ font-size: 1.2em;
+}
diff --git a/resources/src/mediawiki/mediawiki.debug.profile.js b/resources/src/mediawiki/mediawiki.debug.profile.js
new file mode 100644
index 00000000..04f7acd0
--- /dev/null
+++ b/resources/src/mediawiki/mediawiki.debug.profile.js
@@ -0,0 +1,556 @@
+/*!
+ * JavaScript for the debug toolbar profiler, enabled through $wgDebugToolbar
+ * and StartProfiler.php.
+ *
+ * @author Erik Bernhardson
+ * @since 1.23
+ */
+
+( function ( mw, $ ) {
+ 'use strict';
+
+ /**
+ * @singleton
+ * @class mw.Debug.profile
+ */
+ var profile = mw.Debug.profile = {
+ /**
+ * Object containing data for the debug toolbar
+ *
+ * @property ProfileData
+ */
+ data: null,
+
+ /**
+ * @property DOMElement
+ */
+ container: null,
+
+ /**
+ * Initializes the profiling pane.
+ */
+ init: function ( data, width, mergeThresholdPx, dropThresholdPx ) {
+ data = data || mw.config.get( 'debugInfo' ).profile;
+ profile.width = width || $(window).width() - 20;
+ // merge events from same pixel(some events are very granular)
+ mergeThresholdPx = mergeThresholdPx || 2;
+ // only drop events if requested
+ dropThresholdPx = dropThresholdPx || 0;
+
+ if (
+ !Array.prototype.map ||
+ !Array.prototype.reduce ||
+ !Array.prototype.filter ||
+ !document.createElementNS ||
+ !document.createElementNS.bind
+ ) {
+ profile.container = profile.buildRequiresBrowserFeatures();
+ } else if ( data.length === 0 ) {
+ profile.container = profile.buildNoData();
+ } else {
+ // Initialize createSvgElement (now that we know we have
+ // document.createElementNS and bind)
+ this.createSvgElement = document.createElementNS.bind( document, 'http://www.w3.org/2000/svg' );
+
+ // generate a flyout
+ profile.data = new ProfileData( data, profile.width, mergeThresholdPx, dropThresholdPx );
+ // draw it
+ profile.container = profile.buildSvg( profile.container );
+ profile.attachFlyout();
+ }
+
+ return profile.container;
+ },
+
+ buildRequiresBrowserFeatures: function () {
+ return $( '<div>' )
+ .text( 'Certain browser features, including parts of ECMAScript 5 and document.createElementNS, are required for the profile visualization.' )
+ .get( 0 );
+ },
+
+ buildNoData: function () {
+ return $( '<div>' ).addClass( 'mw-debug-profile-no-data' )
+ .text( 'No events recorded, ensure profiling is enabled in StartProfiler.php.' )
+ .get( 0 );
+ },
+
+ /**
+ * Creates DOM nodes appropriately namespaced for SVG.
+ * Initialized in init after checking support
+ *
+ * @param string tag to create
+ * @return DOMElement
+ */
+ createSvgElement: null,
+
+ /**
+ * @param DOMElement|undefined
+ */
+ buildSvg: function ( node ) {
+ var container, group, i, g,
+ timespan = profile.data.timespan,
+ gapPerEvent = 38,
+ space = 10.5,
+ currentHeight = space,
+ totalHeight = 0;
+
+ profile.ratio = ( profile.width - space * 2 ) / ( timespan.end - timespan.start );
+ totalHeight += gapPerEvent * profile.data.groups.length;
+
+ if ( node ) {
+ $( node ).empty();
+ } else {
+ node = profile.createSvgElement( 'svg' );
+ node.setAttribute( 'version', '1.2' );
+ node.setAttribute( 'baseProfile', 'tiny' );
+ }
+ node.style.height = totalHeight;
+ node.style.width = profile.width;
+
+ // use a container that can be transformed
+ container = profile.createSvgElement( 'g' );
+ node.appendChild( container );
+
+ for ( i = 0; i < profile.data.groups.length; i++ ) {
+ group = profile.data.groups[i];
+ g = profile.buildTimeline( group );
+
+ g.setAttribute( 'transform', 'translate( 0 ' + currentHeight + ' )' );
+ container.appendChild( g );
+
+ currentHeight += gapPerEvent;
+ }
+
+ return node;
+ },
+
+ /**
+ * @param Object group of periods to transform into graphics
+ */
+ buildTimeline: function ( group ) {
+ var text, tspan, line, i,
+ sum = group.timespan.sum,
+ ms = ' ~ ' + ( sum < 1 ? sum.toFixed( 2 ) : sum.toFixed( 0 ) ) + ' ms',
+ timeline = profile.createSvgElement( 'g' );
+
+ timeline.setAttribute( 'class', 'mw-debug-profile-timeline' );
+
+ // draw label
+ text = profile.createSvgElement( 'text' );
+ text.setAttribute( 'x', profile.xCoord( group.timespan.start ) );
+ text.setAttribute( 'y', 0 );
+ text.textContent = group.name;
+ timeline.appendChild( text );
+
+ // draw metadata
+ tspan = profile.createSvgElement( 'tspan' );
+ tspan.textContent = ms;
+ text.appendChild( tspan );
+
+ // draw timeline periods
+ for ( i = 0; i < group.periods.length; i++ ) {
+ timeline.appendChild( profile.buildPeriod( group.periods[i] ) );
+ }
+
+ // full-width line under each timeline
+ line = profile.createSvgElement( 'line' );
+ line.setAttribute( 'class', 'mw-debug-profile-underline' );
+ line.setAttribute( 'x1', 0 );
+ line.setAttribute( 'y1', 28 );
+ line.setAttribute( 'x2', profile.width );
+ line.setAttribute( 'y2', 28 );
+ timeline.appendChild( line );
+
+ return timeline;
+ },
+
+ /**
+ * @param Object period to transform into graphics
+ */
+ buildPeriod: function ( period ) {
+ var node,
+ head = profile.xCoord( period.start ),
+ tail = profile.xCoord( period.end ),
+ g = profile.createSvgElement( 'g' );
+
+ g.setAttribute( 'class', 'mw-debug-profile-period' );
+ $( g ).data( 'period', period );
+
+ if ( head + 16 > tail ) {
+ node = profile.createSvgElement( 'rect' );
+ node.setAttribute( 'x', head );
+ node.setAttribute( 'y', 8 );
+ node.setAttribute( 'width', 2 );
+ node.setAttribute( 'height', 9 );
+ g.appendChild( node );
+
+ node = profile.createSvgElement( 'rect' );
+ node.setAttribute( 'x', head );
+ node.setAttribute( 'y', 8 );
+ node.setAttribute( 'width', ( period.end - period.start ) * profile.ratio || 2 );
+ node.setAttribute( 'height', 6 );
+ g.appendChild( node );
+ } else {
+ node = profile.createSvgElement( 'polygon' );
+ node.setAttribute( 'points', pointList( [
+ [ head, 8 ],
+ [ head, 19 ],
+ [ head + 8, 8 ],
+ [ head, 8]
+ ] ) );
+ g.appendChild( node );
+
+ node = profile.createSvgElement( 'polygon' );
+ node.setAttribute( 'points', pointList( [
+ [ tail, 8 ],
+ [ tail, 19 ],
+ [ tail - 8, 8 ],
+ [ tail, 8 ]
+ ] ) );
+ g.appendChild( node );
+
+ node = profile.createSvgElement( 'line' );
+ node.setAttribute( 'x1', head );
+ node.setAttribute( 'y1', 9 );
+ node.setAttribute( 'x2', tail );
+ node.setAttribute( 'y2', 9 );
+ g.appendChild( node );
+ }
+
+ return g;
+ },
+
+ /**
+ * @param Object
+ */
+ buildFlyout: function ( period ) {
+ var contained, sum, ms, mem, i,
+ node = $( '<div>' );
+
+ for ( i = 0; i < period.contained.length; i++ ) {
+ contained = period.contained[i];
+ sum = contained.end - contained.start;
+ ms = '' + ( sum < 1 ? sum.toFixed( 2 ) : sum.toFixed( 0 ) ) + ' ms';
+ mem = formatBytes( contained.memory );
+
+ $( '<div>' ).text( contained.source.name )
+ .append( $( '<span>' ).text( ' ~ ' + ms + ' / ' + mem ).addClass( 'mw-debug-profile-meta' ) )
+ .appendTo( node );
+ }
+
+ return node;
+ },
+
+ /**
+ * Attach a hover flyout to all .mw-debug-profile-period groups.
+ */
+ attachFlyout: function () {
+ // for some reason addClass and removeClass from jQuery
+ // arn't working on svg elements in chrome <= 33.0 (possibly more)
+ var $container = $( profile.container ),
+ addClass = function ( node, value ) {
+ var current = node.getAttribute( 'class' ),
+ list = current ? current.split( ' ' ) : false,
+ idx = list ? list.indexOf( value ) : -1;
+
+ if ( idx === -1 ) {
+ node.setAttribute( 'class', current ? ( current + ' ' + value ) : value );
+ }
+ },
+ removeClass = function ( node, value ) {
+ var current = node.getAttribute( 'class' ),
+ list = current ? current.split( ' ' ) : false,
+ idx = list ? list.indexOf( value ) : -1;
+
+ if ( idx !== -1 ) {
+ list.splice( idx, 1 );
+ node.setAttribute( 'class', list.join( ' ' ) );
+ }
+ },
+ // hide all tipsy flyouts
+ hide = function () {
+ $container.find( '.mw-debug-profile-period.tipsy-visible' )
+ .each( function () {
+ removeClass( this, 'tipsy-visible' );
+ $( this ).tipsy( 'hide' );
+ } );
+ };
+
+ $container.find( '.mw-debug-profile-period' ).tipsy( {
+ fade: true,
+ gravity: function () {
+ return $.fn.tipsy.autoNS.call( this ) + $.fn.tipsy.autoWE.call( this );
+ },
+ className: 'mw-debug-profile-tipsy',
+ center: false,
+ html: true,
+ trigger: 'manual',
+ title: function () {
+ return profile.buildFlyout( $( this ).data( 'period' ) ).html();
+ }
+ } ).on( 'mouseenter', function () {
+ hide();
+ addClass( this, 'tipsy-visible' );
+ $( this ).tipsy( 'show' );
+ } );
+
+ $container.on( 'mouseleave', function ( event ) {
+ var $from = $( event.relatedTarget ),
+ $to = $( event.target );
+ // only close the tipsy if we are not
+ if ( $from.closest( '.tipsy' ).length === 0 &&
+ $to.closest( '.tipsy' ).length === 0 &&
+ $to.get( 0 ).namespaceURI !== 'http://www.w4.org/2000/svg'
+ ) {
+ hide();
+ }
+ } ).on( 'click', function () {
+ // convenience method for closing
+ hide();
+ } );
+ },
+
+ /**
+ * @return number the x co-ordinate for the specified timestamp
+ */
+ xCoord: function ( msTimestamp ) {
+ return ( msTimestamp - profile.data.timespan.start ) * profile.ratio;
+ }
+ };
+
+ function ProfileData( data, width, mergeThresholdPx, dropThresholdPx ) {
+ // validate input data
+ this.data = data.map( function ( event ) {
+ event.periods = event.periods.filter( function ( period ) {
+ return period.start && period.end
+ && period.start < period.end
+ // period start must be a reasonable ms timestamp
+ && period.start > 1000000;
+ } );
+ return event;
+ } ).filter( function ( event ) {
+ return event.name && event.periods.length > 0;
+ } );
+
+ // start and end time of the data
+ this.timespan = this.data.reduce( function ( result, event ) {
+ return event.periods.reduce( periodMinMax, result );
+ }, periodMinMax.initial() );
+
+ // transform input data
+ this.groups = this.collate( width, mergeThresholdPx, dropThresholdPx );
+
+ return this;
+ }
+
+ /**
+ * There are too many unique events to display a line for each,
+ * so this does a basic grouping.
+ */
+ ProfileData.groupOf = function ( label ) {
+ var pos, prefix = 'Profile section ended by close(): ';
+ if ( label.indexOf( prefix ) === 0 ) {
+ label = label.slice( prefix.length );
+ }
+
+ pos = [ '::', ':', '-' ].reduce( function ( result, separator ) {
+ var pos = label.indexOf( separator );
+ if ( pos === -1 ) {
+ return result;
+ } else if ( result === -1 ) {
+ return pos;
+ } else {
+ return Math.min( result, pos );
+ }
+ }, -1 );
+
+ if ( pos === -1 ) {
+ return label;
+ } else {
+ return label.slice( 0, pos );
+ }
+ };
+
+ /**
+ * @return Array list of objects with `name` and `events` keys
+ */
+ ProfileData.groupEvents = function ( events ) {
+ var group, i,
+ groups = {};
+
+ // Group events together
+ for ( i = events.length - 1; i >= 0; i-- ) {
+ group = ProfileData.groupOf( events[i].name );
+ if ( groups[group] ) {
+ groups[group].push( events[i] );
+ } else {
+ groups[group] = [events[i]];
+ }
+ }
+
+ // Return an array of groups
+ return Object.keys( groups ).map( function ( group ) {
+ return {
+ name: group,
+ events: groups[group]
+ };
+ } );
+ };
+
+ ProfileData.periodSorter = function ( a, b ) {
+ if ( a.start === b.start ) {
+ return a.end - b.end;
+ }
+ return a.start - b.start;
+ };
+
+ ProfileData.genMergePeriodReducer = function ( mergeThresholdMs ) {
+ return function ( result, period ) {
+ if ( result.length === 0 ) {
+ // period is first result
+ return [{
+ start: period.start,
+ end: period.end,
+ contained: [period]
+ }];
+ }
+ var last = result[result.length - 1];
+ if ( period.end < last.end ) {
+ // end is contained within previous
+ result[result.length - 1].contained.push( period );
+ } else if ( period.start - mergeThresholdMs < last.end ) {
+ // neighbors within merging distance
+ result[result.length - 1].end = period.end;
+ result[result.length - 1].contained.push( period );
+ } else {
+ // period is next result
+ result.push( {
+ start: period.start,
+ end: period.end,
+ contained: [period]
+ } );
+ }
+ return result;
+ };
+ };
+
+ /**
+ * Collect all periods from the grouped events and apply merge and
+ * drop transformations
+ */
+ ProfileData.extractPeriods = function ( events, mergeThresholdMs, dropThresholdMs ) {
+ // collect the periods from all events
+ return events.reduce( function ( result, event ) {
+ if ( !event.periods.length ) {
+ return result;
+ }
+ result.push.apply( result, event.periods.map( function ( period ) {
+ // maintain link from period to event
+ period.source = event;
+ return period;
+ } ) );
+ return result;
+ }, [] )
+ // sort combined periods
+ .sort( ProfileData.periodSorter )
+ // Apply merge threshold. Original periods
+ // are maintained in the `contained` property
+ .reduce( ProfileData.genMergePeriodReducer( mergeThresholdMs ), [] )
+ // Apply drop threshold
+ .filter( function ( period ) {
+ return period.end - period.start > dropThresholdMs;
+ } );
+ };
+
+ /**
+ * runs a callback on all periods in the group. Only valid after
+ * groups.periods[0..n].contained are populated. This runs against
+ * un-transformed data and is better suited to summing or other
+ * stat collection
+ */
+ ProfileData.reducePeriods = function ( group, callback, result ) {
+ return group.periods.reduce( function ( result, period ) {
+ return period.contained.reduce( callback, result );
+ }, result );
+ };
+
+ /**
+ * Transforms this.data grouping by labels, merging neighboring
+ * events in the groups, and drops events and groups below the
+ * display threshold. Groups are returned sorted by starting time.
+ */
+ ProfileData.prototype.collate = function ( width, mergeThresholdPx, dropThresholdPx ) {
+ // ms to pixel ratio
+ var ratio = ( this.timespan.end - this.timespan.start ) / width,
+ // transform thresholds to ms
+ mergeThresholdMs = mergeThresholdPx * ratio,
+ dropThresholdMs = dropThresholdPx * ratio;
+
+ return ProfileData.groupEvents( this.data )
+ // generate data about the grouped events
+ .map( function ( group ) {
+ // Cleaned periods from all events
+ group.periods = ProfileData.extractPeriods( group.events, mergeThresholdMs, dropThresholdMs );
+ // min and max timestamp per group
+ group.timespan = ProfileData.reducePeriods( group, periodMinMax, periodMinMax.initial() );
+ // ms from first call to end of last call
+ group.timespan.length = group.timespan.end - group.timespan.start;
+ // collect the un-transformed periods
+ group.timespan.sum = ProfileData.reducePeriods( group, function ( result, period ) {
+ result.push( period );
+ return result;
+ }, [] )
+ // sort by start time
+ .sort( ProfileData.periodSorter )
+ // merge overlapping
+ .reduce( ProfileData.genMergePeriodReducer( 0 ), [] )
+ // sum
+ .reduce( function ( result, period ) {
+ return result + period.end - period.start;
+ }, 0 );
+
+ return group;
+ }, this )
+ // remove groups that have had all their periods filtered
+ .filter( function ( group ) {
+ return group.periods.length > 0;
+ } )
+ // sort events by first start
+ .sort( function ( a, b ) {
+ return ProfileData.periodSorter( a.timespan, b.timespan );
+ } );
+ };
+
+ // reducer to find edges of period array
+ function periodMinMax( result, period ) {
+ if ( period.start < result.start ) {
+ result.start = period.start;
+ }
+ if ( period.end > result.end ) {
+ result.end = period.end;
+ }
+ return result;
+ }
+
+ periodMinMax.initial = function () {
+ return { start: Number.POSITIVE_INFINITY, end: Number.NEGATIVE_INFINITY };
+ };
+
+ function formatBytes( bytes ) {
+ var i, sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
+ if ( bytes === 0 ) {
+ return '0 Bytes';
+ }
+ i = parseInt( Math.floor( Math.log( bytes ) / Math.log( 1024 ) ), 10 );
+ return Math.round( bytes / Math.pow( 1024, i ), 2 ) + ' ' + sizes[i];
+ }
+
+ // turns a 2d array into a point list for svg
+ // polygon points attribute
+ // ex: [[1,2],[3,4],[4,2]] = '1,2 3,4 4,2'
+ function pointList( pairs ) {
+ return pairs.map( function ( pair ) {
+ return pair.join( ',' );
+ } ).join( ' ' );
+ }
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki/mediawiki.feedback.css b/resources/src/mediawiki/mediawiki.feedback.css
new file mode 100644
index 00000000..6bd47bb2
--- /dev/null
+++ b/resources/src/mediawiki/mediawiki.feedback.css
@@ -0,0 +1,9 @@
+.feedback-spinner {
+ display: inline-block;
+ zoom: 1;
+ *display: inline; /* IE7 and below */
+ /* @embed */
+ background: url(mediawiki.feedback.spinner.gif);
+ width: 18px;
+ height: 18px;
+}
diff --git a/resources/src/mediawiki/mediawiki.feedback.js b/resources/src/mediawiki/mediawiki.feedback.js
new file mode 100644
index 00000000..1c0d8332
--- /dev/null
+++ b/resources/src/mediawiki/mediawiki.feedback.js
@@ -0,0 +1,320 @@
+/*!
+ * mediawiki.feedback
+ *
+ * @author Ryan Kaldari, 2010
+ * @author Neil Kandalgaonkar, 2010-11
+ * @since 1.19
+ */
+( function ( mw, $ ) {
+ /**
+ * This is a way of getting simple feedback from users. It's useful
+ * for testing new features -- users can give you feedback without
+ * the difficulty of opening a whole new talk page. For this reason,
+ * it also tends to collect a wider range of both positive and negative
+ * comments. However you do need to tend to the feedback page. It will
+ * get long relatively quickly, and you often get multiple messages
+ * reporting the same issue.
+ *
+ * It takes the form of thing on your page which, when clicked, opens a small
+ * dialog box. Submitting that dialog box appends its contents to a
+ * wiki page that you specify, as a new section.
+ *
+ * This feature works with classic MediaWiki pages
+ * and is not compatible with LiquidThreads or Flow.
+ *
+ * Minimal usage example:
+ *
+ * var feedback = new mw.Feedback();
+ * $( '#myButton' ).click( function () { feedback.launch(); } );
+ *
+ * You can also launch the feedback form with a prefilled subject and body.
+ * See the docs for the #launch() method.
+ *
+ * @class
+ * @constructor
+ * @param {Object} [options]
+ * @param {mw.Api} [options.api] if omitted, will just create a standard API
+ * @param {mw.Title} [options.title="Feedback"] The title of the page where you collect
+ * feedback.
+ * @param {string} [options.dialogTitleMessageKey="feedback-submit"] Message key for the
+ * title of the dialog box
+ * @param {string} [options.bugsLink="//bugzilla.wikimedia.org/enter_bug.cgi"] URL where
+ * bugs can be posted
+ * @param {mw.Uri|string} [options.bugsListLink="//bugzilla.wikimedia.org/query.cgi"]
+ * URL where bugs can be listed
+ */
+ mw.Feedback = function ( options ) {
+ if ( options === undefined ) {
+ options = {};
+ }
+
+ if ( options.api === undefined ) {
+ options.api = new mw.Api();
+ }
+
+ if ( options.title === undefined ) {
+ options.title = new mw.Title( 'Feedback' );
+ }
+
+ if ( options.dialogTitleMessageKey === undefined ) {
+ options.dialogTitleMessageKey = 'feedback-submit';
+ }
+
+ if ( options.bugsLink === undefined ) {
+ options.bugsLink = '//bugzilla.wikimedia.org/enter_bug.cgi';
+ }
+
+ if ( options.bugsListLink === undefined ) {
+ options.bugsListLink = '//bugzilla.wikimedia.org/query.cgi';
+ }
+
+ $.extend( this, options );
+ this.setup();
+ };
+
+ mw.Feedback.prototype = {
+ /**
+ * Sets up interface
+ */
+ setup: function () {
+ var $feedbackPageLink,
+ $bugNoteLink,
+ $bugsListLink,
+ fb = this;
+
+ $feedbackPageLink = $( '<a>' )
+ .attr( {
+ href: fb.title.getUrl(),
+ target: '_blank'
+ } )
+ .css( {
+ whiteSpace: 'nowrap'
+ } );
+
+ $bugNoteLink = $( '<a>' ).attr( { href: '#' } ).click( function () {
+ fb.displayBugs();
+ } );
+
+ $bugsListLink = $( '<a>' ).attr( {
+ href: fb.bugsListLink,
+ target: '_blank'
+ } );
+
+ // TODO: Use a stylesheet instead of these inline styles
+ this.$dialog =
+ $( '<div style="position: relative;"></div>' ).append(
+ $( '<div class="feedback-mode feedback-form"></div>' ).append(
+ $( '<small>' ).append(
+ $( '<p>' ).msg(
+ 'feedback-bugornote',
+ $bugNoteLink,
+ fb.title.getNameText(),
+ $feedbackPageLink.clone()
+ )
+ ),
+ $( '<div style="margin-top: 1em;"></div>' )
+ .msg( 'feedback-subject' )
+ .append(
+ $( '<br>' ),
+ $( '<input type="text" class="feedback-subject" name="subject" maxlength="60" style="width: 100%; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box;"/>' )
+ ),
+ $( '<div style="margin-top: 0.4em;"></div>' )
+ .msg( 'feedback-message' )
+ .append(
+ $( '<br>' ),
+ $( '<textarea name="message" class="feedback-message" rows="5" cols="60"></textarea>' )
+ )
+ ),
+ $( '<div class="feedback-mode feedback-bugs"></div>' ).append(
+ $( '<p>' ).msg( 'feedback-bugcheck', $bugsListLink )
+ ),
+ $( '<div class="feedback-mode feedback-submitting" style="text-align: center; margin: 3em 0;"></div>' )
+ .msg( 'feedback-adding' )
+ .append(
+ $( '<br>' ),
+ $( '<span class="feedback-spinner"></span>' )
+ ),
+ $( '<div class="feedback-mode feedback-thanks" style="text-align: center; margin:1em"></div>' ).msg(
+ 'feedback-thanks', fb.title.getNameText(), $feedbackPageLink.clone()
+ ),
+ $( '<div class="feedback-mode feedback-error" style="position: relative;"></div>' ).append(
+ $( '<div class="feedback-error-msg style="color: #990000; margin-top: 0.4em;"></div>' )
+ )
+ );
+
+ this.$dialog.dialog( {
+ width: 500,
+ autoOpen: false,
+ title: mw.message( this.dialogTitleMessageKey ).escaped(),
+ modal: true,
+ buttons: fb.buttons
+ } );
+
+ this.subjectInput = this.$dialog.find( 'input.feedback-subject' ).get( 0 );
+ this.messageInput = this.$dialog.find( 'textarea.feedback-message' ).get( 0 );
+ },
+
+ /**
+ * Displays a section of the dialog.
+ *
+ * @param {"form"|"bugs"|"submitting"|"thanks"|"error"} s
+ * The section of the dialog to show.
+ */
+ display: function ( s ) {
+ // Hide the buttons
+ this.$dialog.dialog( { buttons: {} } );
+ // Hide everything
+ this.$dialog.find( '.feedback-mode' ).hide();
+ // Show the desired div
+ this.$dialog.find( '.feedback-' + s ).show();
+ },
+
+ /**
+ * Display the submitting section.
+ */
+ displaySubmitting: function () {
+ this.display( 'submitting' );
+ },
+
+ /**
+ * Display the bugs section.
+ */
+ displayBugs: function () {
+ var fb = this,
+ bugsButtons = {};
+
+ this.display( 'bugs' );
+ bugsButtons[ mw.msg( 'feedback-bugnew' ) ] = function () {
+ window.open( fb.bugsLink, '_blank' );
+ };
+ bugsButtons[ mw.msg( 'feedback-cancel' ) ] = function () {
+ fb.cancel();
+ };
+ this.$dialog.dialog( {
+ buttons: bugsButtons
+ } );
+ },
+
+ /**
+ * Display the thanks section.
+ */
+ displayThanks: function () {
+ var fb = this,
+ closeButton = {};
+
+ this.display( 'thanks' );
+ closeButton[ mw.msg( 'feedback-close' ) ] = function () {
+ fb.$dialog.dialog( 'close' );
+ };
+ this.$dialog.dialog( {
+ buttons: closeButton
+ } );
+ },
+
+ /**
+ * Display the feedback form
+ * @param {Object} [contents] Prefilled contents for the feedback form.
+ * @param {string} [contents.subject] The subject of the feedback
+ * @param {string} [contents.message] The content of the feedback
+ */
+ displayForm: function ( contents ) {
+ var fb = this,
+ formButtons = {};
+
+ this.subjectInput.value = ( contents && contents.subject ) ? contents.subject : '';
+ this.messageInput.value = ( contents && contents.message ) ? contents.message : '';
+
+ this.display( 'form' );
+
+ // Set up buttons for dialog box. We have to do it the hard way since the json keys are localized
+ formButtons[ mw.msg( 'feedback-submit' ) ] = function () {
+ fb.submit();
+ };
+ formButtons[ mw.msg( 'feedback-cancel' ) ] = function () {
+ fb.cancel();
+ };
+ this.$dialog.dialog( { buttons: formButtons } ); // put the buttons back
+ },
+
+ /**
+ * Display an error on the form.
+ *
+ * @param {string} message Should be a valid message key.
+ */
+ displayError: function ( message ) {
+ var fb = this,
+ closeButton = {};
+
+ this.display( 'error' );
+ this.$dialog.find( '.feedback-error-msg' ).msg( message );
+ closeButton[ mw.msg( 'feedback-close' ) ] = function () {
+ fb.$dialog.dialog( 'close' );
+ };
+ this.$dialog.dialog( { buttons: closeButton } );
+ },
+
+ /**
+ * Close the feedback form.
+ */
+ cancel: function () {
+ this.$dialog.dialog( 'close' );
+ },
+
+ /**
+ * Submit the feedback form.
+ */
+ submit: function () {
+ var subject, message,
+ fb = this;
+
+ // Get the values to submit.
+ subject = this.subjectInput.value;
+
+ // We used to include "mw.html.escape( navigator.userAgent )" but there are legal issues
+ // with posting this without their explicit consent
+ message = this.messageInput.value;
+ if ( message.indexOf( '~~~' ) === -1 ) {
+ message += ' ~~~~';
+ }
+
+ this.displaySubmitting();
+
+ // Post the message, resolving redirects
+ this.api.newSection(
+ this.title,
+ subject,
+ message,
+ { redirect: true }
+ )
+ .done( function ( result ) {
+ if ( result.edit !== undefined ) {
+ if ( result.edit.result === 'Success' ) {
+ fb.displayThanks();
+ } else {
+ // unknown API result
+ fb.displayError( 'feedback-error1' );
+ }
+ } else {
+ // edit failed
+ fb.displayError( 'feedback-error2' );
+ }
+ } )
+ .fail( function () {
+ // ajax request failed
+ fb.displayError( 'feedback-error3' );
+ } );
+ },
+
+ /**
+ * Modify the display form, and then open it, focusing interface on the subject.
+ * @param {Object} [contents] Prefilled contents for the feedback form.
+ * @param {string} [contents.subject] The subject of the feedback
+ * @param {string} [contents.message] The content of the feedback
+ */
+ launch: function ( contents ) {
+ this.displayForm( contents );
+ this.$dialog.dialog( 'open' );
+ this.subjectInput.focus();
+ }
+ };
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki/mediawiki.feedback.spinner.gif b/resources/src/mediawiki/mediawiki.feedback.spinner.gif
new file mode 100644
index 00000000..aed0ea41
--- /dev/null
+++ b/resources/src/mediawiki/mediawiki.feedback.spinner.gif
Binary files differ
diff --git a/resources/src/mediawiki/mediawiki.hidpi.js b/resources/src/mediawiki/mediawiki.hidpi.js
new file mode 100644
index 00000000..ecee450c
--- /dev/null
+++ b/resources/src/mediawiki/mediawiki.hidpi.js
@@ -0,0 +1,5 @@
+jQuery( function ( $ ) {
+ // Apply hidpi images on DOM-ready
+ // Some may have already partly preloaded at low resolution.
+ $( 'body' ).hidpi();
+} );
diff --git a/resources/src/mediawiki/mediawiki.hlist.css b/resources/src/mediawiki/mediawiki.hlist.css
new file mode 100644
index 00000000..adcb8104
--- /dev/null
+++ b/resources/src/mediawiki/mediawiki.hlist.css
@@ -0,0 +1,78 @@
+/*!
+ * Stylesheet for mediawiki.hlist module
+ * @author [[User:Edokter]]
+ */
+.hlist dl,
+.hlist ol,
+.hlist ul {
+ margin: 0;
+ padding: 0;
+}
+/* Display list items inline */
+.hlist dd,
+.hlist dt,
+.hlist li {
+ margin: 0;
+ display: inline;
+}
+/* Display nested lists inline */
+.hlist dl dl, .hlist dl ol, .hlist dl ul,
+.hlist ol dl, .hlist ol ol, .hlist ol ul,
+.hlist ul dl, .hlist ul ol, .hlist ul ul {
+ display: inline;
+}
+/* Generate interpuncts */
+.hlist dt:after {
+ content: ":";
+}
+.hlist dd:after,
+.hlist li:after {
+ content: " ·";
+ font-weight: bold;
+}
+.hlist dd:last-child:after,
+.hlist dt:last-child:after,
+.hlist li:last-child:after {
+ content: none;
+}
+/* For IE8 */
+.hlist dd.hlist-last-child:after,
+.hlist dt.hlist-last-child:after,
+.hlist li.hlist-last-child:after {
+ content: none;
+}
+/* Add parentheses around nested lists */
+.hlist dd dd:first-child:before, .hlist dd dt:first-child:before, .hlist dd li:first-child:before,
+.hlist dt dd:first-child:before, .hlist dt dt:first-child:before, .hlist dt li:first-child:before,
+.hlist li dd:first-child:before, .hlist li dt:first-child:before, .hlist li li:first-child:before {
+ content: "(";
+ font-weight: normal;
+}
+.hlist dd dd:last-child:after, .hlist dd dt:last-child:after, .hlist dd li:last-child:after,
+.hlist dt dd:last-child:after, .hlist dt dt:last-child:after, .hlist dt li:last-child:after,
+.hlist li dd:last-child:after, .hlist li dt:last-child:after, .hlist li li:last-child:after {
+ content: ")";
+ font-weight: normal;
+}
+/* For IE8 */
+.hlist dd dd.hlist-last-child:after, .hlist dd dt.hlist-last-child:after, .hlist dd li.hlist-last-child:after,
+.hlist dt dd.hlist-last-child:after, .hlist dt dt.hlist-last-child:after, .hlist dt li.hlist-last-child:after,
+.hlist li dd.hlist-last-child:after, .hlist li dt.hlist-last-child:after, .hlist li li.hlist-last-child:after {
+ content: ")";
+ font-weight: normal;
+}
+/* Put ordinals in front of ordered list items */
+.hlist ol {
+ counter-reset: list-item;
+}
+.hlist ol > li {
+ counter-increment: list-item;
+}
+.hlist ol > li:before {
+ content: counter(list-item) " ";
+}
+.hlist dd ol > li:first-child:before,
+.hlist dt ol > li:first-child:before,
+.hlist li ol > li:first-child:before {
+ content: "(" counter(list-item) " ";
+}
diff --git a/resources/src/mediawiki/mediawiki.hlist.js b/resources/src/mediawiki/mediawiki.hlist.js
new file mode 100644
index 00000000..0bbf8fad
--- /dev/null
+++ b/resources/src/mediawiki/mediawiki.hlist.js
@@ -0,0 +1,31 @@
+/*!
+ * .hlist fallbacks for IE 6, 7 and 8.
+ * @author [[User:Edokter]]
+ */
+( function ( mw, $ ) {
+ var profile = $.client.profile();
+
+ if ( profile.name === 'msie' ) {
+ if ( profile.versionNumber === 8 ) {
+ /* IE 8: Add pseudo-selector class to last-child list items */
+ mw.hook( 'wikipage.content' ).add( function ( $content ) {
+ $content.find( '.hlist' ).find( 'dd:last-child, dt:last-child, li:last-child' )
+ .addClass( 'hlist-last-child' );
+ } );
+ }
+ else if ( profile.versionNumber <= 7 ) {
+ /* IE 7 and below: Generate interpuncts and parentheses */
+ mw.hook( 'wikipage.content' ).add( function ( $content ) {
+ var $hlists = $content.find( '.hlist' );
+ $hlists.find( 'dt:not(:last-child)' )
+ .append( ': ' );
+ $hlists.find( 'dd:not(:last-child)' )
+ .append( '<b>·</b> ' );
+ $hlists.find( 'li:not(:last-child)' )
+ .append( '<b>·</b> ' );
+ $hlists.find( 'dl dl, dl ol, dl ul, ol dl, ol ol, ol ul, ul dl, ul ol, ul ul' )
+ .prepend( '( ' ).append( ') ' );
+ } );
+ }
+ }
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki/mediawiki.htmlform.js b/resources/src/mediawiki/mediawiki.htmlform.js
new file mode 100644
index 00000000..594800e1
--- /dev/null
+++ b/resources/src/mediawiki/mediawiki.htmlform.js
@@ -0,0 +1,408 @@
+/**
+ * Utility functions for jazzing up HTMLForm elements.
+ *
+ * @class jQuery.plugin.htmlform
+ */
+( function ( mw, $ ) {
+
+ var cloneCounter = 0;
+
+ /**
+ * Helper function for hide-if to find the nearby form field.
+ *
+ * Find the closest match for the given name, "closest" being the minimum
+ * level of parents to go to find a form field matching the given name or
+ * ending in array keys matching the given name (e.g. "baz" matches
+ * "foo[bar][baz]").
+ *
+ * @private
+ * @param {jQuery} element
+ * @param {string} name
+ * @return {jQuery|null}
+ */
+ function hideIfGetField( $el, name ) {
+ var $found, $p,
+ suffix = name.replace( /^([^\[]+)/, '[$1]' );
+
+ function nameFilter() {
+ return this.name === name ||
+ ( this.name === ( 'wp' + name ) ) ||
+ this.name.slice( -suffix.length ) === suffix;
+ }
+
+ for ( $p = $el.parent(); $p.length > 0; $p = $p.parent() ) {
+ $found = $p.find( '[name]' ).filter( nameFilter );
+ if ( $found.length ) {
+ return $found;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Helper function for hide-if to return a test function and list of
+ * dependent fields for a hide-if specification.
+ *
+ * @private
+ * @param {jQuery} element
+ * @param {Array} hide-if spec
+ * @return {Array}
+ * @return {jQuery} return.0 Dependent fields
+ * @return {Function} return.1 Test function
+ */
+ function hideIfParse( $el, spec ) {
+ var op, i, l, v, $field, $fields, fields, func, funcs, getVal;
+
+ op = spec[0];
+ l = spec.length;
+ switch ( op ) {
+ case 'AND':
+ case 'OR':
+ case 'NAND':
+ case 'NOR':
+ funcs = [];
+ fields = [];
+ for ( i = 1; i < l; i++ ) {
+ if ( !$.isArray( spec[i] ) ) {
+ throw new Error( op + ' parameters must be arrays' );
+ }
+ v = hideIfParse( $el, spec[i] );
+ fields = fields.concat( v[0].toArray() );
+ funcs.push( v[1] );
+ }
+ $fields = $( fields );
+
+ l = funcs.length;
+ switch ( op ) {
+ case 'AND':
+ func = function () {
+ var i;
+ for ( i = 0; i < l; i++ ) {
+ if ( !funcs[i]() ) {
+ return false;
+ }
+ }
+ return true;
+ };
+ break;
+
+ case 'OR':
+ func = function () {
+ var i;
+ for ( i = 0; i < l; i++ ) {
+ if ( funcs[i]() ) {
+ return true;
+ }
+ }
+ return false;
+ };
+ break;
+
+ case 'NAND':
+ func = function () {
+ var i;
+ for ( i = 0; i < l; i++ ) {
+ if ( !funcs[i]() ) {
+ return true;
+ }
+ }
+ return false;
+ };
+ break;
+
+ case 'NOR':
+ func = function () {
+ var i;
+ for ( i = 0; i < l; i++ ) {
+ if ( funcs[i]() ) {
+ return false;
+ }
+ }
+ return true;
+ };
+ break;
+ }
+
+ return [ $fields, func ];
+
+ case 'NOT':
+ if ( l !== 2 ) {
+ throw new Error( 'NOT takes exactly one parameter' );
+ }
+ if ( !$.isArray( spec[1] ) ) {
+ throw new Error( 'NOT parameters must be arrays' );
+ }
+ v = hideIfParse( $el, spec[1] );
+ $fields = v[0];
+ func = v[1];
+ return [ $fields, function () {
+ return !func();
+ } ];
+
+ case '===':
+ case '!==':
+ if ( l !== 3 ) {
+ throw new Error( op + ' takes exactly two parameters' );
+ }
+ $field = hideIfGetField( $el, spec[1] );
+ if ( !$field ) {
+ return [ $(), function () {
+ return false;
+ } ];
+ }
+ v = spec[2];
+
+ if ( $field.first().prop( 'type' ) === 'radio' ||
+ $field.first().prop( 'type' ) === 'checkbox'
+ ) {
+ getVal = function () {
+ var $selected = $field.filter( ':checked' );
+ return $selected.length ? $selected.val() : '';
+ };
+ } else {
+ getVal = function () {
+ return $field.val();
+ };
+ }
+
+ switch ( op ) {
+ case '===':
+ func = function () {
+ return getVal() === v;
+ };
+ break;
+ case '!==':
+ func = function () {
+ return getVal() !== v;
+ };
+ break;
+ }
+
+ return [ $field, func ];
+
+ default:
+ throw new Error( 'Unrecognized operation \'' + op + '\'' );
+ }
+ }
+
+ /**
+ * jQuery plugin to fade or snap to visible state.
+ *
+ * @param {boolean} [instantToggle=false]
+ * @return {jQuery}
+ * @chainable
+ */
+ $.fn.goIn = function ( instantToggle ) {
+ if ( instantToggle === true ) {
+ return this.show();
+ }
+ return this.stop( true, true ).fadeIn();
+ };
+
+ /**
+ * jQuery plugin to fade or snap to hiding state.
+ *
+ * @param {boolean} [instantToggle=false]
+ * @return jQuery
+ * @chainable
+ */
+ $.fn.goOut = function ( instantToggle ) {
+ if ( instantToggle === true ) {
+ return this.hide();
+ }
+ return this.stop( true, true ).fadeOut();
+ };
+
+ /**
+ * Bind a function to the jQuery object via live(), and also immediately trigger
+ * the function on the objects with an 'instant' parameter set to true.
+ *
+ * @method liveAndTestAtStart
+ * @deprecated since 1.24 Use .on() and .each() directly.
+ * @param {Function} callback
+ * @param {boolean|jQuery.Event} callback.immediate True when the event is called immediately,
+ * an event object when triggered from an event.
+ * @return jQuery
+ * @chainable
+ */
+ mw.log.deprecate( $.fn, 'liveAndTestAtStart', function ( callback ) {
+ this
+ // Can't really migrate to .on() generically, needs knowledge of
+ // calling code to know the correct selector. Fix callers and
+ // get rid of this .liveAndTestAtStart() hack.
+ .live( 'change', callback )
+ .each( function () {
+ callback.call( this, true );
+ } );
+ } );
+
+ function enhance( $root ) {
+ var $matrixTooltips, $autocomplete;
+
+ /**
+ * @ignore
+ * @param {boolean|jQuery.Event} instant
+ */
+ function handleSelectOrOther( instant ) {
+ var $other = $root.find( '#' + $( this ).attr( 'id' ) + '-other' );
+ $other = $other.add( $other.siblings( 'br' ) );
+ if ( $( this ).val() === 'other' ) {
+ $other.goIn( instant );
+ } else {
+ $other.goOut( instant );
+ }
+ }
+
+ // Animate the SelectOrOther fields, to only show the text field when
+ // 'other' is selected.
+ $root
+ .on( 'change', '.mw-htmlform-select-or-other', handleSelectOrOther )
+ .each( function () {
+ handleSelectOrOther.call( this, true );
+ } );
+
+ // Set up hide-if elements
+ $root.find( '.mw-htmlform-hide-if' ).each( function () {
+ var v, $fields, test, func,
+ $el = $( this ),
+ spec = $el.data( 'hideIf' );
+
+ if ( !spec ) {
+ return;
+ }
+
+ v = hideIfParse( $el, spec );
+ $fields = v[0];
+ test = v[1];
+ func = function () {
+ if ( test() ) {
+ $el.hide();
+ } else {
+ $el.show();
+ }
+ };
+ $fields.on( 'change', func );
+ func();
+ } );
+
+ function addMulti( $oldContainer, $container ) {
+ var name = $oldContainer.find( 'input:first-child' ).attr( 'name' ),
+ oldClass = ( ' ' + $oldContainer.attr( 'class' ) + ' ' ).replace( /(mw-htmlform-field-HTMLMultiSelectField|mw-chosen)/g, '' ),
+ $select = $( '<select>' ),
+ dataPlaceholder = mw.message( 'htmlform-chosen-placeholder' );
+ oldClass = $.trim( oldClass );
+ $select.attr( {
+ name: name,
+ multiple: 'multiple',
+ 'data-placeholder': dataPlaceholder.plain(),
+ 'class': 'htmlform-chzn-select mw-input ' + oldClass
+ } );
+ $oldContainer.find( 'input' ).each( function () {
+ var $oldInput = $( this ),
+ checked = $oldInput.prop( 'checked' ),
+ $option = $( '<option>' );
+ $option.prop( 'value', $oldInput.prop( 'value' ) );
+ if ( checked ) {
+ $option.prop( 'selected', true );
+ }
+ $option.text( $oldInput.prop( 'value' ) );
+ $select.append( $option );
+ } );
+ $container.append( $select );
+ }
+
+ function convertCheckboxesToMulti( $oldContainer, type ) {
+ var $fieldLabel = $( '<td>' ),
+ $td = $( '<td>' ),
+ $fieldLabelText = $( '<label>' ),
+ $container;
+ if ( type === 'tr' ) {
+ addMulti( $oldContainer, $td );
+ $container = $( '<tr>' );
+ $container.append( $td );
+ } else if ( type === 'div' ) {
+ $fieldLabel = $( '<div>' );
+ $container = $( '<div>' );
+ addMulti( $oldContainer, $container );
+ }
+ $fieldLabel.attr( 'class', 'mw-label' );
+ $fieldLabelText.text( $oldContainer.find( '.mw-label label' ).text() );
+ $fieldLabel.append( $fieldLabelText );
+ $container.prepend( $fieldLabel );
+ $oldContainer.replaceWith( $container );
+ return $container;
+ }
+
+ if ( $root.find( '.mw-chosen' ).length ) {
+ mw.loader.using( 'jquery.chosen', function () {
+ $root.find( '.mw-chosen' ).each( function () {
+ var type = this.nodeName.toLowerCase(),
+ $converted = convertCheckboxesToMulti( $( this ), type );
+ $converted.find( '.htmlform-chzn-select' ).chosen( { width: 'auto' } );
+ } );
+ } );
+ }
+
+ $matrixTooltips = $root.find( '.mw-htmlform-matrix .mw-htmlform-tooltip' );
+ if ( $matrixTooltips.length ) {
+ mw.loader.using( 'jquery.tipsy', function () {
+ $matrixTooltips.tipsy( { gravity: 's' } );
+ } );
+ }
+
+ // Set up autocomplete fields
+ $autocomplete = $root.find( '.mw-htmlform-autocomplete' );
+ if ( $autocomplete.length ) {
+ mw.loader.using( 'jquery.suggestions', function () {
+ $autocomplete.suggestions( {
+ fetch: function ( val ) {
+ var $el = $( this );
+ $el.suggestions( 'suggestions',
+ $.grep( $el.data( 'autocomplete' ), function ( v ) {
+ return v.indexOf( val ) === 0;
+ } )
+ );
+ }
+ } );
+ } );
+ }
+
+ // Add/remove cloner clones without having to resubmit the form
+ $root.find( '.mw-htmlform-cloner-delete-button' ).click( function ( ev ) {
+ ev.preventDefault();
+ $( this ).closest( 'li.mw-htmlform-cloner-li' ).remove();
+ } );
+
+ $root.find( '.mw-htmlform-cloner-create-button' ).click( function ( ev ) {
+ var $ul, $li, html;
+
+ ev.preventDefault();
+
+ $ul = $( this ).prev( 'ul.mw-htmlform-cloner-ul' );
+
+ html = $ul.data( 'template' ).replace(
+ new RegExp( $.escapeRE( $ul.data( 'uniqueId' ) ), 'g' ),
+ 'clone' + ( ++cloneCounter )
+ );
+
+ $li = $( '<li>' )
+ .addClass( 'mw-htmlform-cloner-li' )
+ .html( html )
+ .appendTo( $ul );
+
+ enhance( $li );
+ } );
+
+ mw.hook( 'htmlform.enhance' ).fire( $root );
+
+ }
+
+ $( function () {
+ enhance( $( document ) );
+ } );
+
+ /**
+ * @class jQuery
+ * @mixins jQuery.plugin.htmlform
+ */
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki/mediawiki.icon.less b/resources/src/mediawiki/mediawiki.icon.less
new file mode 100644
index 00000000..49f0f70f
--- /dev/null
+++ b/resources/src/mediawiki/mediawiki.icon.less
@@ -0,0 +1,19 @@
+/* General-purpose icons via CSS. Classes here should be named "mw-icon-*". */
+
+@import "mediawiki.mixins";
+
+/* For the collapsed and expanded arrows, we also provide selectors to make it
+ * easy to use them with jquery.makeCollapsible. */
+.mw-icon-arrow-collapsed,
+.mw-collapsible-arrow.mw-collapsible-toggle-collapsed {
+ .background-image-svg('images/arrow-collapsed-ltr.svg', 'images/arrow-collapsed-ltr.png');
+ background-repeat: no-repeat;
+ background-position: left bottom;
+}
+
+.mw-icon-arrow-expanded,
+.mw-collapsible-arrow.mw-collapsible-toggle-expanded {
+ .background-image-svg('images/arrow-expanded.svg', 'images/arrow-expanded.png');
+ background-repeat: no-repeat;
+ background-position: left bottom;
+}
diff --git a/resources/src/mediawiki/mediawiki.inspect.js b/resources/src/mediawiki/mediawiki.inspect.js
new file mode 100644
index 00000000..8e9fc89f
--- /dev/null
+++ b/resources/src/mediawiki/mediawiki.inspect.js
@@ -0,0 +1,284 @@
+/*!
+ * Tools for inspecting page composition and performance.
+ *
+ * @author Ori Livneh
+ * @since 1.22
+ */
+/*jshint devel:true */
+( function ( mw, $ ) {
+
+ function sortByProperty( array, prop, descending ) {
+ var order = descending ? -1 : 1;
+ return array.sort( function ( a, b ) {
+ return a[prop] > b[prop] ? order : a[prop] < b[prop] ? -order : 0;
+ } );
+ }
+
+ function humanSize( bytes ) {
+ if ( !$.isNumeric( bytes ) || bytes === 0 ) { return bytes; }
+ var i = 0, units = [ '', ' kB', ' MB', ' GB', ' TB', ' PB' ];
+ for ( ; bytes >= 1024; bytes /= 1024 ) { i++; }
+ return bytes.toFixed( 1 ) + units[i];
+ }
+
+ /**
+ * @class mw.inspect
+ * @singleton
+ */
+ var inspect = {
+
+ /**
+ * Return a map of all dependency relationships between loaded modules.
+ *
+ * @return {Object} Maps module names to objects. Each sub-object has
+ * two properties, 'requires' and 'requiredBy'.
+ */
+ getDependencyGraph: function () {
+ var modules = inspect.getLoadedModules(), graph = {};
+
+ $.each( modules, function ( moduleIndex, moduleName ) {
+ var dependencies = mw.loader.moduleRegistry[moduleName].dependencies || [];
+
+ graph[moduleName] = graph[moduleName] || { requiredBy: [] };
+ graph[moduleName].requires = dependencies;
+
+ $.each( dependencies, function ( depIndex, depName ) {
+ graph[depName] = graph[depName] || { requiredBy: [] };
+ graph[depName].requiredBy.push( moduleName );
+ } );
+ } );
+ return graph;
+ },
+
+ /**
+ * Calculate the byte size of a ResourceLoader module.
+ *
+ * @param {string} moduleName The name of the module
+ * @return {number|null} Module size in bytes or null
+ */
+ getModuleSize: function ( moduleName ) {
+ var module = mw.loader.moduleRegistry[ moduleName ],
+ payload = 0;
+
+ if ( mw.loader.getState( moduleName ) !== 'ready' ) {
+ return null;
+ }
+
+ if ( !module.style && !module.script ) {
+ return null;
+ }
+
+ // Tally CSS
+ if ( module.style && $.isArray( module.style.css ) ) {
+ $.each( module.style.css, function ( i, stylesheet ) {
+ payload += $.byteLength( stylesheet );
+ } );
+ }
+
+ // Tally JavaScript
+ if ( $.isFunction( module.script ) ) {
+ payload += $.byteLength( module.script.toString() );
+ }
+
+ return payload;
+ },
+
+ /**
+ * Given CSS source, count both the total number of selectors it
+ * contains and the number which match some element in the current
+ * document.
+ *
+ * @param {string} css CSS source
+ * @return Selector counts
+ * @return {number} return.selectors Total number of selectors
+ * @return {number} return.matched Number of matched selectors
+ */
+ auditSelectors: function ( css ) {
+ var selectors = { total: 0, matched: 0 },
+ style = document.createElement( 'style' ),
+ sheet, rules;
+
+ style.textContent = css;
+ document.body.appendChild( style );
+ // Standards-compliant browsers use .sheet.cssRules, IE8 uses .styleSheet.rules…
+ sheet = style.sheet || style.styleSheet;
+ rules = sheet.cssRules || sheet.rules;
+ $.each( rules, function ( index, rule ) {
+ selectors.total++;
+ if ( document.querySelector( rule.selectorText ) !== null ) {
+ selectors.matched++;
+ }
+ } );
+ document.body.removeChild( style );
+ return selectors;
+ },
+
+ /**
+ * Get a list of all loaded ResourceLoader modules.
+ *
+ * @return {Array} List of module names
+ */
+ getLoadedModules: function () {
+ return $.grep( mw.loader.getModuleNames(), function ( module ) {
+ return mw.loader.getState( module ) === 'ready';
+ } );
+ },
+
+ /**
+ * Print tabular data to the console, using console.table, console.log,
+ * or mw.log (in declining order of preference).
+ *
+ * @param {Array} data Tabular data represented as an array of objects
+ * with common properties.
+ */
+ dumpTable: function ( data ) {
+ try {
+ // Bartosz made me put this here.
+ if ( window.opera ) { throw window.opera; }
+ // Use Function.prototype#call to force an exception on Firefox,
+ // which doesn't define console#table but doesn't complain if you
+ // try to invoke it.
+ console.table.call( console, data );
+ return;
+ } catch ( e ) {}
+ try {
+ console.log( JSON.stringify( data, null, 2 ) );
+ return;
+ } catch ( e ) {}
+ mw.log( data );
+ },
+
+ /**
+ * Generate and print one more reports. When invoked with no arguments,
+ * print all reports.
+ *
+ * @param {string...} [reports] Report names to run, or unset to print
+ * all available reports.
+ */
+ runReports: function () {
+ var reports = arguments.length > 0 ?
+ Array.prototype.slice.call( arguments ) :
+ $.map( inspect.reports, function ( v, k ) { return k; } );
+
+ $.each( reports, function ( index, name ) {
+ inspect.dumpTable( inspect.reports[name]() );
+ } );
+ },
+
+ /**
+ * @class mw.inspect.reports
+ * @singleton
+ */
+ reports: {
+ /**
+ * Generate a breakdown of all loaded modules and their size in
+ * kilobytes. Modules are ordered from largest to smallest.
+ */
+ size: function () {
+ // Map each module to a descriptor object.
+ var modules = $.map( inspect.getLoadedModules(), function ( module ) {
+ return {
+ name: module,
+ size: inspect.getModuleSize( module )
+ };
+ } );
+
+ // Sort module descriptors by size, largest first.
+ sortByProperty( modules, 'size', true );
+
+ // Convert size to human-readable string.
+ $.each( modules, function ( i, module ) {
+ module.size = humanSize( module.size );
+ } );
+
+ return modules;
+ },
+
+ /**
+ * For each module with styles, count the number of selectors, and
+ * count how many match against some element currently in the DOM.
+ */
+ css: function () {
+ var modules = [];
+
+ $.each( inspect.getLoadedModules(), function ( index, name ) {
+ var css, stats, module = mw.loader.moduleRegistry[name];
+
+ try {
+ css = module.style.css.join();
+ } catch ( e ) { return; } // skip
+
+ stats = inspect.auditSelectors( css );
+ modules.push( {
+ module: name,
+ allSelectors: stats.total,
+ matchedSelectors: stats.matched,
+ percentMatched: stats.total !== 0 ?
+ ( stats.matched / stats.total * 100 ).toFixed( 2 ) + '%' : null
+ } );
+ } );
+ sortByProperty( modules, 'allSelectors', true );
+ return modules;
+ },
+
+ /**
+ * Report stats on mw.loader.store: the number of localStorage
+ * cache hits and misses, the number of items purged from the
+ * cache, and the total size of the module blob in localStorage.
+ */
+ store: function () {
+ var raw, stats = { enabled: mw.loader.store.enabled };
+ if ( stats.enabled ) {
+ $.extend( stats, mw.loader.store.stats );
+ try {
+ raw = localStorage.getItem( mw.loader.store.getStoreKey() );
+ stats.totalSize = humanSize( $.byteLength( raw ) );
+ } catch ( e ) {}
+ }
+ return [stats];
+ }
+ },
+
+ /**
+ * Perform a string search across the JavaScript and CSS source code
+ * of all loaded modules and return an array of the names of the
+ * modules that matched.
+ *
+ * @param {string|RegExp} pattern String or regexp to match.
+ * @return {Array} Array of the names of modules that matched.
+ */
+ grep: function ( pattern ) {
+ if ( typeof pattern.test !== 'function' ) {
+ // Based on Y.Escape.regex from YUI v3.15.0
+ pattern = new RegExp( pattern.replace( /[\-$\^*()+\[\]{}|\\,.?\s]/g, '\\$&' ), 'g' );
+ }
+
+ return $.grep( inspect.getLoadedModules(), function ( moduleName ) {
+ var module = mw.loader.moduleRegistry[moduleName];
+
+ // Grep module's JavaScript
+ if ( $.isFunction( module.script ) && pattern.test( module.script.toString() ) ) {
+ return true;
+ }
+
+ // Grep module's CSS
+ if (
+ $.isPlainObject( module.style ) && $.isArray( module.style.css )
+ && pattern.test( module.style.css.join( '' ) )
+ ) {
+ // Module's CSS source matches
+ return true;
+ }
+
+ return false;
+ } );
+ }
+ };
+
+ if ( mw.config.get( 'debug' ) ) {
+ mw.log( 'mw.inspect: reports are not available in debug mode.' );
+ }
+
+ mw.inspect = inspect;
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki/mediawiki.jqueryMsg.js b/resources/src/mediawiki/mediawiki.jqueryMsg.js
new file mode 100644
index 00000000..ad71b083
--- /dev/null
+++ b/resources/src/mediawiki/mediawiki.jqueryMsg.js
@@ -0,0 +1,1251 @@
+/*!
+* Experimental advanced wikitext parser-emitter.
+* See: https://www.mediawiki.org/wiki/Extension:UploadWizard/MessageParser for docs
+*
+* @author neilk@wikimedia.org
+* @author mflaschen@wikimedia.org
+*/
+( function ( mw, $ ) {
+ /**
+ * @class mw.jqueryMsg
+ * @singleton
+ */
+
+ var oldParser,
+ slice = Array.prototype.slice,
+ parserDefaults = {
+ magic: {
+ 'SITENAME': mw.config.get( 'wgSiteName' )
+ },
+ // This is a whitelist based on, but simpler than, Sanitizer.php.
+ // Self-closing tags are not currently supported.
+ allowedHtmlElements: [
+ 'b',
+ 'i'
+ ],
+ // Key tag name, value allowed attributes for that tag.
+ // See Sanitizer::setupAttributeWhitelist
+ allowedHtmlCommonAttributes: [
+ // HTML
+ 'id',
+ 'class',
+ 'style',
+ 'lang',
+ 'dir',
+ 'title',
+
+ // WAI-ARIA
+ 'role'
+ ],
+
+ // Attributes allowed for specific elements.
+ // Key is element name in lower case
+ // Value is array of allowed attributes for that element
+ allowedHtmlAttributesByElement: {},
+ messages: mw.messages,
+ language: mw.language,
+
+ // Same meaning as in mediawiki.js.
+ //
+ // Only 'text', 'parse', and 'escaped' are supported, and the
+ // actual escaping for 'escaped' is done by other code (generally
+ // through mediawiki.js).
+ //
+ // However, note that this default only
+ // applies to direct calls to jqueryMsg. The default for mediawiki.js itself
+ // is 'text', including when it uses jqueryMsg.
+ format: 'parse'
+
+ };
+
+ /**
+ * Wrapper around jQuery append that converts all non-objects to TextNode so append will not
+ * convert what it detects as an htmlString to an element.
+ *
+ * Object elements of children (jQuery, HTMLElement, TextNode, etc.) will be left as is.
+ *
+ * @private
+ * @param {jQuery} $parent Parent node wrapped by jQuery
+ * @param {Object|string|Array} children What to append, with the same possible types as jQuery
+ * @return {jQuery} $parent
+ */
+ function appendWithoutParsing( $parent, children ) {
+ var i, len;
+
+ if ( !$.isArray( children ) ) {
+ children = [children];
+ }
+
+ for ( i = 0, len = children.length; i < len; i++ ) {
+ if ( typeof children[i] !== 'object' ) {
+ children[i] = document.createTextNode( children[i] );
+ }
+ }
+
+ return $parent.append( children );
+ }
+
+ /**
+ * Decodes the main HTML entities, those encoded by mw.html.escape.
+ *
+ * @private
+ * @param {string} encoded Encoded string
+ * @return {string} String with those entities decoded
+ */
+ function decodePrimaryHtmlEntities( encoded ) {
+ return encoded
+ .replace( /&#039;/g, '\'' )
+ .replace( /&quot;/g, '"' )
+ .replace( /&lt;/g, '<' )
+ .replace( /&gt;/g, '>' )
+ .replace( /&amp;/g, '&' );
+ }
+
+ /**
+ * Given parser options, return a function that parses a key and replacements, returning jQuery object
+ *
+ * Try to parse a key and optional replacements, returning a jQuery object that may be a tree of jQuery nodes.
+ * If there was an error parsing, return the key and the error message (wrapped in jQuery). This should put the error right into
+ * the interface, without causing the page to halt script execution, and it hopefully should be clearer how to fix it.
+ * @private
+ * @param {Object} options Parser options
+ * @return {Function}
+ * @return {Array} return.args First element is the key, replacements may be in array in 2nd element, or remaining elements.
+ * @return {jQuery} return.return
+ */
+ function getFailableParserFn( options ) {
+ var parser = new mw.jqueryMsg.parser( options );
+
+ return function ( args ) {
+ var fallback,
+ key = args[0],
+ argsArray = $.isArray( args[1] ) ? args[1] : slice.call( args, 1 );
+ try {
+ return parser.parse( key, argsArray );
+ } catch ( e ) {
+ fallback = parser.settings.messages.get( key );
+ mw.log.warn( 'mediawiki.jqueryMsg: ' + key + ': ' + e.message );
+ return $( '<span>' ).text( fallback );
+ }
+ };
+ }
+
+ mw.jqueryMsg = {};
+
+ /**
+ * Returns a function suitable for use as a global, to construct strings from the message key (and optional replacements).
+ * e.g.
+ *
+ * window.gM = mediaWiki.parser.getMessageFunction( options );
+ * $( 'p#headline' ).html( gM( 'hello-user', username ) );
+ *
+ * Like the old gM() function this returns only strings, so it destroys any bindings. If you want to preserve bindings use the
+ * jQuery plugin version instead. This is only included for backwards compatibility with gM().
+ *
+ * N.B. replacements are variadic arguments or an array in second parameter. In other words:
+ * somefunction( a, b, c, d )
+ * is equivalent to
+ * somefunction( a, [b, c, d] )
+ *
+ * @param {Object} options parser options
+ * @return {Function} Function suitable for assigning to window.gM
+ * @return {string} return.key Message key.
+ * @return {Array|Mixed} return.replacements Optional variable replacements (variadically or an array).
+ * @return {string} return.return Rendered HTML.
+ */
+ mw.jqueryMsg.getMessageFunction = function ( options ) {
+ var failableParserFn = getFailableParserFn( options ),
+ format;
+
+ if ( options && options.format !== undefined ) {
+ format = options.format;
+ } else {
+ format = parserDefaults.format;
+ }
+
+ return function () {
+ var failableResult = failableParserFn( arguments );
+ if ( format === 'text' || format === 'escaped' ) {
+ return failableResult.text();
+ } else {
+ return failableResult.html();
+ }
+ };
+ };
+
+ /**
+ * Returns a jQuery plugin which parses the message in the message key, doing replacements optionally, and appends the nodes to
+ * the current selector. Bindings to passed-in jquery elements are preserved. Functions become click handlers for [$1 linktext] links.
+ * e.g.
+ *
+ * $.fn.msg = mediaWiki.parser.getJqueryPlugin( options );
+ * var userlink = $( '<a>' ).click( function () { alert( "hello!!" ) } );
+ * $( 'p#headline' ).msg( 'hello-user', userlink );
+ *
+ * N.B. replacements are variadic arguments or an array in second parameter. In other words:
+ * somefunction( a, b, c, d )
+ * is equivalent to
+ * somefunction( a, [b, c, d] )
+ *
+ * We append to 'this', which in a jQuery plugin context will be the selected elements.
+ *
+ * @param {Object} options Parser options
+ * @return {Function} Function suitable for assigning to jQuery plugin, such as jQuery#msg
+ * @return {string} return.key Message key.
+ * @return {Array|Mixed} return.replacements Optional variable replacements (variadically or an array).
+ * @return {jQuery} return.return
+ */
+ mw.jqueryMsg.getPlugin = function ( options ) {
+ var failableParserFn = getFailableParserFn( options );
+
+ return function () {
+ var $target = this.empty();
+ // TODO: Simply appendWithoutParsing( $target, failableParserFn( arguments ).contents() )
+ // or Simply appendWithoutParsing( $target, failableParserFn( arguments ) )
+ $.each( failableParserFn( arguments ).contents(), function ( i, node ) {
+ appendWithoutParsing( $target, node );
+ } );
+ return $target;
+ };
+ };
+
+ /**
+ * The parser itself.
+ * Describes an object, whose primary duty is to .parse() message keys.
+ *
+ * @class
+ * @private
+ * @param {Object} options
+ */
+ mw.jqueryMsg.parser = function ( options ) {
+ this.settings = $.extend( {}, parserDefaults, options );
+ this.settings.onlyCurlyBraceTransform = ( this.settings.format === 'text' || this.settings.format === 'escaped' );
+
+ this.emitter = new mw.jqueryMsg.htmlEmitter( this.settings.language, this.settings.magic );
+ };
+
+ mw.jqueryMsg.parser.prototype = {
+ /**
+ * Cache mapping MediaWiki message keys and the value onlyCurlyBraceTransform, to the AST of the message.
+ *
+ * In most cases, the message is a string so this is identical.
+ * (This is why we would like to move this functionality server-side).
+ *
+ * The two parts of the key are separated by colon. For example:
+ *
+ * "message-key:true": ast
+ *
+ * if they key is "message-key" and onlyCurlyBraceTransform is true.
+ *
+ * This cache is shared by all instances of mw.jqueryMsg.parser.
+ *
+ * NOTE: We promise, it's static - when you create this empty object
+ * in the prototype, each new instance of the class gets a reference
+ * to the same object.
+ *
+ * @static
+ * @property {Object}
+ */
+ astCache: {},
+
+ /**
+ * Where the magic happens.
+ * Parses a message from the key, and swaps in replacements as necessary, wraps in jQuery
+ * If an error is thrown, returns original key, and logs the error
+ * @param {string} key Message key.
+ * @param {Array} replacements Variable replacements for $1, $2... $n
+ * @return {jQuery}
+ */
+ parse: function ( key, replacements ) {
+ return this.emitter.emit( this.getAst( key ), replacements );
+ },
+
+ /**
+ * Fetch the message string associated with a key, return parsed structure. Memoized.
+ * Note that we pass '[' + key + ']' back for a missing message here.
+ * @param {string} key
+ * @return {string|Array} string of '[key]' if message missing, simple string if possible, array of arrays if needs parsing
+ */
+ getAst: function ( key ) {
+ var cacheKey = [key, this.settings.onlyCurlyBraceTransform].join( ':' ), wikiText;
+
+ if ( this.astCache[ cacheKey ] === undefined ) {
+ wikiText = this.settings.messages.get( key );
+ if ( typeof wikiText !== 'string' ) {
+ wikiText = '\\[' + key + '\\]';
+ }
+ this.astCache[ cacheKey ] = this.wikiTextToAst( wikiText );
+ }
+ return this.astCache[ cacheKey ];
+ },
+
+ /**
+ * Parses the input wikiText into an abstract syntax tree, essentially an s-expression.
+ *
+ * CAVEAT: This does not parse all wikitext. It could be more efficient, but it's pretty good already.
+ * n.b. We want to move this functionality to the server. Nothing here is required to be on the client.
+ *
+ * @param {string} input Message string wikitext
+ * @throws Error
+ * @return {Mixed} abstract syntax tree
+ */
+ wikiTextToAst: function ( input ) {
+ var pos, settings = this.settings, concat = Array.prototype.concat,
+ regularLiteral, regularLiteralWithoutBar, regularLiteralWithoutSpace, regularLiteralWithSquareBrackets,
+ doubleQuote, singleQuote, backslash, anyCharacter, asciiAlphabetLiteral,
+ escapedOrLiteralWithoutSpace, escapedOrLiteralWithoutBar, escapedOrRegularLiteral,
+ whitespace, dollar, digits, htmlDoubleQuoteAttributeValue, htmlSingleQuoteAttributeValue,
+ htmlAttributeEquals, openHtmlStartTag, optionalForwardSlash, openHtmlEndTag, closeHtmlTag,
+ openExtlink, closeExtlink, wikilinkPage, wikilinkContents, openWikilink, closeWikilink, templateName, pipe, colon,
+ templateContents, openTemplate, closeTemplate,
+ nonWhitespaceExpression, paramExpression, expression, curlyBraceTransformExpression, result;
+
+ // Indicates current position in input as we parse through it.
+ // Shared among all parsing functions below.
+ pos = 0;
+
+ // =========================================================
+ // parsing combinators - could be a library on its own
+ // =========================================================
+
+ /**
+ * Try parsers until one works, if none work return null
+ * @private
+ * @param {Function[]} ps
+ * @return {string|null}
+ */
+ function choice( ps ) {
+ return function () {
+ var i, result;
+ for ( i = 0; i < ps.length; i++ ) {
+ result = ps[i]();
+ if ( result !== null ) {
+ return result;
+ }
+ }
+ return null;
+ };
+ }
+
+ /**
+ * Try several ps in a row, all must succeed or return null.
+ * This is the only eager one.
+ * @private
+ * @param {Function[]} ps
+ * @return {string|null}
+ */
+ function sequence( ps ) {
+ var i, res,
+ originalPos = pos,
+ result = [];
+ for ( i = 0; i < ps.length; i++ ) {
+ res = ps[i]();
+ if ( res === null ) {
+ pos = originalPos;
+ return null;
+ }
+ result.push( res );
+ }
+ return result;
+ }
+
+ /**
+ * Run the same parser over and over until it fails.
+ * Must succeed a minimum of n times or return null.
+ * @private
+ * @param {number} n
+ * @param {Function} p
+ * @return {string|null}
+ */
+ function nOrMore( n, p ) {
+ return function () {
+ var originalPos = pos,
+ result = [],
+ parsed = p();
+ while ( parsed !== null ) {
+ result.push( parsed );
+ parsed = p();
+ }
+ if ( result.length < n ) {
+ pos = originalPos;
+ return null;
+ }
+ return result;
+ };
+ }
+
+ /**
+ * There is a general pattern -- parse a thing, if that worked, apply transform, otherwise return null.
+ *
+ * TODO: But using this as a combinator seems to cause problems when combined with #nOrMore().
+ * May be some scoping issue
+ *
+ * @private
+ * @param {Function} p
+ * @param {Function} fn
+ * @return {string|null}
+ */
+ function transform( p, fn ) {
+ return function () {
+ var result = p();
+ return result === null ? null : fn( result );
+ };
+ }
+
+ /**
+ * Just make parsers out of simpler JS builtin types
+ * @private
+ * @param {string} s
+ * @return {Function}
+ * @return {string} return.return
+ */
+ function makeStringParser( s ) {
+ var len = s.length;
+ return function () {
+ var result = null;
+ if ( input.substr( pos, len ) === s ) {
+ result = s;
+ pos += len;
+ }
+ return result;
+ };
+ }
+
+ /**
+ * Makes a regex parser, given a RegExp object.
+ * The regex being passed in should start with a ^ to anchor it to the start
+ * of the string.
+ *
+ * @private
+ * @param {RegExp} regex anchored regex
+ * @return {Function} function to parse input based on the regex
+ */
+ function makeRegexParser( regex ) {
+ return function () {
+ var matches = input.slice( pos ).match( regex );
+ if ( matches === null ) {
+ return null;
+ }
+ pos += matches[0].length;
+ return matches[0];
+ };
+ }
+
+ // ===================================================================
+ // General patterns above this line -- wikitext specific parsers below
+ // ===================================================================
+
+ // Parsing functions follow. All parsing functions work like this:
+ // They don't accept any arguments.
+ // Instead, they just operate non destructively on the string 'input'
+ // As they can consume parts of the string, they advance the shared variable pos,
+ // and return tokens (or whatever else they want to return).
+ // some things are defined as closures and other things as ordinary functions
+ // converting everything to a closure makes it a lot harder to debug... errors pop up
+ // but some debuggers can't tell you exactly where they come from. Also the mutually
+ // recursive functions seem not to work in all browsers then. (Tested IE6-7, Opera, Safari, FF)
+ // This may be because, to save code, memoization was removed
+
+ regularLiteral = makeRegexParser( /^[^{}\[\]$<\\]/ );
+ regularLiteralWithoutBar = makeRegexParser( /^[^{}\[\]$\\|]/ );
+ regularLiteralWithoutSpace = makeRegexParser( /^[^{}\[\]$\s]/ );
+ regularLiteralWithSquareBrackets = makeRegexParser( /^[^{}$\\]/ );
+
+ backslash = makeStringParser( '\\' );
+ doubleQuote = makeStringParser( '"' );
+ singleQuote = makeStringParser( '\'' );
+ anyCharacter = makeRegexParser( /^./ );
+
+ openHtmlStartTag = makeStringParser( '<' );
+ optionalForwardSlash = makeRegexParser( /^\/?/ );
+ openHtmlEndTag = makeStringParser( '</' );
+ htmlAttributeEquals = makeRegexParser( /^\s*=\s*/ );
+ closeHtmlTag = makeRegexParser( /^\s*>/ );
+
+ function escapedLiteral() {
+ var result = sequence( [
+ backslash,
+ anyCharacter
+ ] );
+ return result === null ? null : result[1];
+ }
+ escapedOrLiteralWithoutSpace = choice( [
+ escapedLiteral,
+ regularLiteralWithoutSpace
+ ] );
+ escapedOrLiteralWithoutBar = choice( [
+ escapedLiteral,
+ regularLiteralWithoutBar
+ ] );
+ escapedOrRegularLiteral = choice( [
+ escapedLiteral,
+ regularLiteral
+ ] );
+ // Used to define "literals" without spaces, in space-delimited situations
+ function literalWithoutSpace() {
+ var result = nOrMore( 1, escapedOrLiteralWithoutSpace )();
+ return result === null ? null : result.join( '' );
+ }
+ // Used to define "literals" within template parameters. The pipe character is the parameter delimeter, so by default
+ // it is not a literal in the parameter
+ function literalWithoutBar() {
+ var result = nOrMore( 1, escapedOrLiteralWithoutBar )();
+ return result === null ? null : result.join( '' );
+ }
+
+ // Used for wikilink page names. Like literalWithoutBar, but
+ // without allowing escapes.
+ function unescapedLiteralWithoutBar() {
+ var result = nOrMore( 1, regularLiteralWithoutBar )();
+ return result === null ? null : result.join( '' );
+ }
+
+ function literal() {
+ var result = nOrMore( 1, escapedOrRegularLiteral )();
+ return result === null ? null : result.join( '' );
+ }
+
+ function curlyBraceTransformExpressionLiteral() {
+ var result = nOrMore( 1, regularLiteralWithSquareBrackets )();
+ return result === null ? null : result.join( '' );
+ }
+
+ asciiAlphabetLiteral = makeRegexParser( /[A-Za-z]+/ );
+ htmlDoubleQuoteAttributeValue = makeRegexParser( /^[^"]*/ );
+ htmlSingleQuoteAttributeValue = makeRegexParser( /^[^']*/ );
+
+ whitespace = makeRegexParser( /^\s+/ );
+ dollar = makeStringParser( '$' );
+ digits = makeRegexParser( /^\d+/ );
+
+ function replacement() {
+ var result = sequence( [
+ dollar,
+ digits
+ ] );
+ if ( result === null ) {
+ return null;
+ }
+ return [ 'REPLACE', parseInt( result[1], 10 ) - 1 ];
+ }
+ openExtlink = makeStringParser( '[' );
+ closeExtlink = makeStringParser( ']' );
+ // this extlink MUST have inner contents, e.g. [foo] not allowed; [foo bar] [foo <i>bar</i>], etc. are allowed
+ function extlink() {
+ var result, parsedResult;
+ result = null;
+ parsedResult = sequence( [
+ openExtlink,
+ nonWhitespaceExpression,
+ whitespace,
+ nOrMore( 1, expression ),
+ closeExtlink
+ ] );
+ if ( parsedResult !== null ) {
+ result = [ 'EXTLINK', parsedResult[1] ];
+ // TODO (mattflaschen, 2013-03-22): Clean this up if possible.
+ // It's avoiding CONCAT for single nodes, so they at least doesn't get the htmlEmitter span.
+ if ( parsedResult[3].length === 1 ) {
+ result.push( parsedResult[3][0] );
+ } else {
+ result.push( ['CONCAT'].concat( parsedResult[3] ) );
+ }
+ }
+ return result;
+ }
+ // this is the same as the above extlink, except that the url is being passed on as a parameter
+ function extLinkParam() {
+ var result = sequence( [
+ openExtlink,
+ dollar,
+ digits,
+ whitespace,
+ expression,
+ closeExtlink
+ ] );
+ if ( result === null ) {
+ return null;
+ }
+ return [ 'EXTLINKPARAM', parseInt( result[2], 10 ) - 1, result[4] ];
+ }
+ openWikilink = makeStringParser( '[[' );
+ closeWikilink = makeStringParser( ']]' );
+ pipe = makeStringParser( '|' );
+
+ function template() {
+ var result = sequence( [
+ openTemplate,
+ templateContents,
+ closeTemplate
+ ] );
+ return result === null ? null : result[1];
+ }
+
+ wikilinkPage = choice( [
+ unescapedLiteralWithoutBar,
+ template
+ ] );
+
+ function pipedWikilink() {
+ var result = sequence( [
+ wikilinkPage,
+ pipe,
+ expression
+ ] );
+ return result === null ? null : [ result[0], result[2] ];
+ }
+
+ wikilinkContents = choice( [
+ pipedWikilink,
+ wikilinkPage // unpiped link
+ ] );
+
+ function wikilink() {
+ var result, parsedResult, parsedLinkContents;
+ result = null;
+
+ parsedResult = sequence( [
+ openWikilink,
+ wikilinkContents,
+ closeWikilink
+ ] );
+ if ( parsedResult !== null ) {
+ parsedLinkContents = parsedResult[1];
+ result = [ 'WIKILINK' ].concat( parsedLinkContents );
+ }
+ return result;
+ }
+
+ // TODO: Support data- if appropriate
+ function doubleQuotedHtmlAttributeValue() {
+ var parsedResult = sequence( [
+ doubleQuote,
+ htmlDoubleQuoteAttributeValue,
+ doubleQuote
+ ] );
+ return parsedResult === null ? null : parsedResult[1];
+ }
+
+ function singleQuotedHtmlAttributeValue() {
+ var parsedResult = sequence( [
+ singleQuote,
+ htmlSingleQuoteAttributeValue,
+ singleQuote
+ ] );
+ return parsedResult === null ? null : parsedResult[1];
+ }
+
+ function htmlAttribute() {
+ var parsedResult = sequence( [
+ whitespace,
+ asciiAlphabetLiteral,
+ htmlAttributeEquals,
+ choice( [
+ doubleQuotedHtmlAttributeValue,
+ singleQuotedHtmlAttributeValue
+ ] )
+ ] );
+ return parsedResult === null ? null : [parsedResult[1], parsedResult[3]];
+ }
+
+ /**
+ * Checks if HTML is allowed
+ *
+ * @param {string} startTagName HTML start tag name
+ * @param {string} endTagName HTML start tag name
+ * @param {Object} attributes array of consecutive key value pairs,
+ * with index 2 * n being a name and 2 * n + 1 the associated value
+ * @return {boolean} true if this is HTML is allowed, false otherwise
+ */
+ function isAllowedHtml( startTagName, endTagName, attributes ) {
+ var i, len, attributeName;
+
+ startTagName = startTagName.toLowerCase();
+ endTagName = endTagName.toLowerCase();
+ if ( startTagName !== endTagName || $.inArray( startTagName, settings.allowedHtmlElements ) === -1 ) {
+ return false;
+ }
+
+ for ( i = 0, len = attributes.length; i < len; i += 2 ) {
+ attributeName = attributes[i];
+ if ( $.inArray( attributeName, settings.allowedHtmlCommonAttributes ) === -1 &&
+ $.inArray( attributeName, settings.allowedHtmlAttributesByElement[startTagName] || [] ) === -1 ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ function htmlAttributes() {
+ var parsedResult = nOrMore( 0, htmlAttribute )();
+ // Un-nest attributes array due to structure of jQueryMsg operations (see emit).
+ return concat.apply( ['HTMLATTRIBUTES'], parsedResult );
+ }
+
+ // Subset of allowed HTML markup.
+ // Most elements and many attributes allowed on the server are not supported yet.
+ function html() {
+ var result = null, parsedOpenTagResult, parsedHtmlContents,
+ parsedCloseTagResult, wrappedAttributes, attributes,
+ startTagName, endTagName, startOpenTagPos, startCloseTagPos,
+ endOpenTagPos, endCloseTagPos;
+
+ // Break into three sequence calls. That should allow accurate reconstruction of the original HTML, and requiring an exact tag name match.
+ // 1. open through closeHtmlTag
+ // 2. expression
+ // 3. openHtmlEnd through close
+ // This will allow recording the positions to reconstruct if HTML is to be treated as text.
+
+ startOpenTagPos = pos;
+ parsedOpenTagResult = sequence( [
+ openHtmlStartTag,
+ asciiAlphabetLiteral,
+ htmlAttributes,
+ optionalForwardSlash,
+ closeHtmlTag
+ ] );
+
+ if ( parsedOpenTagResult === null ) {
+ return null;
+ }
+
+ endOpenTagPos = pos;
+ startTagName = parsedOpenTagResult[1];
+
+ parsedHtmlContents = nOrMore( 0, expression )();
+
+ startCloseTagPos = pos;
+ parsedCloseTagResult = sequence( [
+ openHtmlEndTag,
+ asciiAlphabetLiteral,
+ closeHtmlTag
+ ] );
+
+ if ( parsedCloseTagResult === null ) {
+ // Closing tag failed. Return the start tag and contents.
+ return [ 'CONCAT', input.slice( startOpenTagPos, endOpenTagPos ) ]
+ .concat( parsedHtmlContents );
+ }
+
+ endCloseTagPos = pos;
+ endTagName = parsedCloseTagResult[1];
+ wrappedAttributes = parsedOpenTagResult[2];
+ attributes = wrappedAttributes.slice( 1 );
+ if ( isAllowedHtml( startTagName, endTagName, attributes ) ) {
+ result = [ 'HTMLELEMENT', startTagName, wrappedAttributes ]
+ .concat( parsedHtmlContents );
+ } else {
+ // HTML is not allowed, so contents will remain how
+ // it was, while HTML markup at this level will be
+ // treated as text
+ // E.g. assuming script tags are not allowed:
+ //
+ // <script>[[Foo|bar]]</script>
+ //
+ // results in '&lt;script&gt;' and '&lt;/script&gt;'
+ // (not treated as an HTML tag), surrounding a fully
+ // parsed HTML link.
+ //
+ // Concatenate everything from the tag, flattening the contents.
+ result = [ 'CONCAT', input.slice( startOpenTagPos, endOpenTagPos ) ]
+ .concat( parsedHtmlContents, input.slice( startCloseTagPos, endCloseTagPos ) );
+ }
+
+ return result;
+ }
+
+ templateName = transform(
+ // see $wgLegalTitleChars
+ // not allowing : due to the need to catch "PLURAL:$1"
+ makeRegexParser( /^[ !"$&'()*,.\/0-9;=?@A-Z\^_`a-z~\x80-\xFF+\-]+/ ),
+ function ( result ) { return result.toString(); }
+ );
+ function templateParam() {
+ var expr, result;
+ result = sequence( [
+ pipe,
+ nOrMore( 0, paramExpression )
+ ] );
+ if ( result === null ) {
+ return null;
+ }
+ expr = result[1];
+ // use a CONCAT operator if there are multiple nodes, otherwise return the first node, raw.
+ return expr.length > 1 ? [ 'CONCAT' ].concat( expr ) : expr[0];
+ }
+
+ function templateWithReplacement() {
+ var result = sequence( [
+ templateName,
+ colon,
+ replacement
+ ] );
+ return result === null ? null : [ result[0], result[2] ];
+ }
+ function templateWithOutReplacement() {
+ var result = sequence( [
+ templateName,
+ colon,
+ paramExpression
+ ] );
+ return result === null ? null : [ result[0], result[2] ];
+ }
+ function templateWithOutFirstParameter() {
+ var result = sequence( [
+ templateName,
+ colon
+ ] );
+ return result === null ? null : [ result[0], '' ];
+ }
+ colon = makeStringParser( ':' );
+ templateContents = choice( [
+ function () {
+ var res = sequence( [
+ // templates can have placeholders for dynamic replacement eg: {{PLURAL:$1|one car|$1 cars}}
+ // or no placeholders eg: {{GRAMMAR:genitive|{{SITENAME}}}
+ choice( [ templateWithReplacement, templateWithOutReplacement, templateWithOutFirstParameter ] ),
+ nOrMore( 0, templateParam )
+ ] );
+ return res === null ? null : res[0].concat( res[1] );
+ },
+ function () {
+ var res = sequence( [
+ templateName,
+ nOrMore( 0, templateParam )
+ ] );
+ if ( res === null ) {
+ return null;
+ }
+ return [ res[0] ].concat( res[1] );
+ }
+ ] );
+ openTemplate = makeStringParser( '{{' );
+ closeTemplate = makeStringParser( '}}' );
+ nonWhitespaceExpression = choice( [
+ template,
+ wikilink,
+ extLinkParam,
+ extlink,
+ replacement,
+ literalWithoutSpace
+ ] );
+ paramExpression = choice( [
+ template,
+ wikilink,
+ extLinkParam,
+ extlink,
+ replacement,
+ literalWithoutBar
+ ] );
+
+ expression = choice( [
+ template,
+ wikilink,
+ extLinkParam,
+ extlink,
+ replacement,
+ html,
+ literal
+ ] );
+
+ // Used when only {{-transformation is wanted, for 'text'
+ // or 'escaped' formats
+ curlyBraceTransformExpression = choice( [
+ template,
+ replacement,
+ curlyBraceTransformExpressionLiteral
+ ] );
+
+ /**
+ * Starts the parse
+ *
+ * @param {Function} rootExpression root parse function
+ */
+ function start( rootExpression ) {
+ var result = nOrMore( 0, rootExpression )();
+ if ( result === null ) {
+ return null;
+ }
+ return [ 'CONCAT' ].concat( result );
+ }
+ // everything above this point is supposed to be stateless/static, but
+ // I am deferring the work of turning it into prototypes & objects. It's quite fast enough
+ // finally let's do some actual work...
+
+ // If you add another possible rootExpression, you must update the astCache key scheme.
+ result = start( this.settings.onlyCurlyBraceTransform ? curlyBraceTransformExpression : expression );
+
+ /*
+ * For success, the p must have gotten to the end of the input
+ * and returned a non-null.
+ * n.b. This is part of language infrastructure, so we do not throw an internationalizable message.
+ */
+ if ( result === null || pos !== input.length ) {
+ throw new Error( 'Parse error at position ' + pos.toString() + ' in input: ' + input );
+ }
+ return result;
+ }
+
+ };
+
+ /**
+ * htmlEmitter - object which primarily exists to emit HTML from parser ASTs
+ */
+ mw.jqueryMsg.htmlEmitter = function ( language, magic ) {
+ this.language = language;
+ var jmsg = this;
+ $.each( magic, function ( key, val ) {
+ jmsg[ key.toLowerCase() ] = function () {
+ return val;
+ };
+ } );
+
+ /**
+ * (We put this method definition here, and not in prototype, to make sure it's not overwritten by any magic.)
+ * Walk entire node structure, applying replacements and template functions when appropriate
+ * @param {Mixed} node Abstract syntax tree (top node or subnode)
+ * @param {Array} replacements for $1, $2, ... $n
+ * @return {Mixed} single-string node or array of nodes suitable for jQuery appending
+ */
+ this.emit = function ( node, replacements ) {
+ var ret, subnodes, operation,
+ jmsg = this;
+ switch ( typeof node ) {
+ case 'string':
+ case 'number':
+ ret = node;
+ break;
+ // typeof returns object for arrays
+ case 'object':
+ // node is an array of nodes
+ subnodes = $.map( node.slice( 1 ), function ( n ) {
+ return jmsg.emit( n, replacements );
+ } );
+ operation = node[0].toLowerCase();
+ if ( typeof jmsg[operation] === 'function' ) {
+ ret = jmsg[ operation ]( subnodes, replacements );
+ } else {
+ throw new Error( 'Unknown operation "' + operation + '"' );
+ }
+ break;
+ case 'undefined':
+ // Parsing the empty string (as an entire expression, or as a paramExpression in a template) results in undefined
+ // Perhaps a more clever parser can detect this, and return the empty string? Or is that useful information?
+ // The logical thing is probably to return the empty string here when we encounter undefined.
+ ret = '';
+ break;
+ default:
+ throw new Error( 'Unexpected type in AST: ' + typeof node );
+ }
+ return ret;
+ };
+ };
+
+ // For everything in input that follows double-open-curly braces, there should be an equivalent parser
+ // function. For instance {{PLURAL ... }} will be processed by 'plural'.
+ // If you have 'magic words' then configure the parser to have them upon creation.
+ //
+ // An emitter method takes the parent node, the array of subnodes and the array of replacements (the values that $1, $2... should translate to).
+ // Note: all such functions must be pure, with the exception of referring to other pure functions via this.language (convertPlural and so on)
+ mw.jqueryMsg.htmlEmitter.prototype = {
+ /**
+ * Parsing has been applied depth-first we can assume that all nodes here are single nodes
+ * Must return a single node to parents -- a jQuery with synthetic span
+ * However, unwrap any other synthetic spans in our children and pass them upwards
+ * @param {Mixed[]} nodes Some single nodes, some arrays of nodes
+ * @return {jQuery}
+ */
+ concat: function ( nodes ) {
+ var $span = $( '<span>' ).addClass( 'mediaWiki_htmlEmitter' );
+ $.each( nodes, function ( i, node ) {
+ if ( node instanceof jQuery && node.hasClass( 'mediaWiki_htmlEmitter' ) ) {
+ $.each( node.contents(), function ( j, childNode ) {
+ appendWithoutParsing( $span, childNode );
+ } );
+ } else {
+ // Let jQuery append nodes, arrays of nodes and jQuery objects
+ // other things (strings, numbers, ..) are appended as text nodes (not as HTML strings)
+ appendWithoutParsing( $span, node );
+ }
+ } );
+ return $span;
+ },
+
+ /**
+ * Return escaped replacement of correct index, or string if unavailable.
+ * Note that we expect the parsed parameter to be zero-based. i.e. $1 should have become [ 0 ].
+ * if the specified parameter is not found return the same string
+ * (e.g. "$99" -> parameter 98 -> not found -> return "$99" )
+ *
+ * TODO: Throw error if nodes.length > 1 ?
+ *
+ * @param {Array} nodes List of one element, integer, n >= 0
+ * @param {Array} replacements List of at least n strings
+ * @return {String} replacement
+ */
+ replace: function ( nodes, replacements ) {
+ var index = parseInt( nodes[0], 10 );
+
+ if ( index < replacements.length ) {
+ return replacements[index];
+ } else {
+ // index not found, fallback to displaying variable
+ return '$' + ( index + 1 );
+ }
+ },
+
+ /**
+ * Transform wiki-link
+ *
+ * TODO:
+ * It only handles basic cases, either no pipe, or a pipe with an explicit
+ * anchor.
+ *
+ * It does not attempt to handle features like the pipe trick.
+ * However, the pipe trick should usually not be present in wikitext retrieved
+ * from the server, since the replacement is done at save time.
+ * It may, though, if the wikitext appears in extension-controlled content.
+ *
+ * @param nodes
+ */
+ wikilink: function ( nodes ) {
+ var page, anchor, url;
+
+ page = nodes[0];
+ url = mw.util.getUrl( page );
+
+ // [[Some Page]] or [[Namespace:Some Page]]
+ if ( nodes.length === 1 ) {
+ anchor = page;
+ }
+
+ /*
+ * [[Some Page|anchor text]] or
+ * [[Namespace:Some Page|anchor]
+ */
+ else {
+ anchor = nodes[1];
+ }
+
+ return $( '<a>' ).attr( {
+ title: page,
+ href: url
+ } ).text( anchor );
+ },
+
+ /**
+ * Converts array of HTML element key value pairs to object
+ *
+ * @param {Array} nodes Array of consecutive key value pairs, with index 2 * n being a
+ * name and 2 * n + 1 the associated value
+ * @return {Object} Object mapping attribute name to attribute value
+ */
+ htmlattributes: function ( nodes ) {
+ var i, len, mapping = {};
+ for ( i = 0, len = nodes.length; i < len; i += 2 ) {
+ mapping[nodes[i]] = decodePrimaryHtmlEntities( nodes[i + 1] );
+ }
+ return mapping;
+ },
+
+ /**
+ * Handles an (already-validated) HTML element.
+ *
+ * @param {Array} nodes Nodes to process when creating element
+ * @return {jQuery|Array} jQuery node for valid HTML or array for disallowed element
+ */
+ htmlelement: function ( nodes ) {
+ var tagName, attributes, contents, $element;
+
+ tagName = nodes.shift();
+ attributes = nodes.shift();
+ contents = nodes;
+ $element = $( document.createElement( tagName ) ).attr( attributes );
+ return appendWithoutParsing( $element, contents );
+ },
+
+ /**
+ * Transform parsed structure into external link
+ * If the href is a jQuery object, treat it as "enclosing" the link text.
+ *
+ * - ... function, treat it as the click handler.
+ * - ... string, treat it as a URI.
+ *
+ * TODO: throw an error if nodes.length > 2 ?
+ *
+ * @param {Array} nodes List of two elements, {jQuery|Function|String} and {String}
+ * @return {jQuery}
+ */
+ extlink: function ( nodes ) {
+ var $el,
+ arg = nodes[0],
+ contents = nodes[1];
+ if ( arg instanceof jQuery ) {
+ $el = arg;
+ } else {
+ $el = $( '<a>' );
+ if ( typeof arg === 'function' ) {
+ $el.attr( 'href', '#' )
+ .click( function ( e ) {
+ e.preventDefault();
+ } )
+ .click( arg );
+ } else {
+ $el.attr( 'href', arg.toString() );
+ }
+ }
+ return appendWithoutParsing( $el, contents );
+ },
+
+ /**
+ * This is basically use a combination of replace + external link (link with parameter
+ * as url), but we don't want to run the regular replace here-on: inserting a
+ * url as href-attribute of a link will automatically escape it already, so
+ * we don't want replace to (manually) escape it as well.
+ *
+ * TODO: throw error if nodes.length > 1 ?
+ *
+ * @param {Array} nodes List of one element, integer, n >= 0
+ * @param {Array} replacements List of at least n strings
+ * @return {string} replacement
+ */
+ extlinkparam: function ( nodes, replacements ) {
+ var replacement,
+ index = parseInt( nodes[0], 10 );
+ if ( index < replacements.length ) {
+ replacement = replacements[index];
+ } else {
+ replacement = '$' + ( index + 1 );
+ }
+ return this.extlink( [ replacement, nodes[1] ] );
+ },
+
+ /**
+ * Transform parsed structure into pluralization
+ * n.b. The first node may be a non-integer (for instance, a string representing an Arabic number).
+ * So convert it back with the current language's convertNumber.
+ * @param {Array} nodes List of nodes, [ {string|number}, {string}, {string} ... ]
+ * @return {string} selected pluralized form according to current language
+ */
+ plural: function ( nodes ) {
+ var forms, formIndex, node, count;
+ count = parseFloat( this.language.convertNumber( nodes[0], true ) );
+ forms = nodes.slice( 1 );
+ for ( formIndex = 0; formIndex < forms.length; formIndex++ ) {
+ node = forms[formIndex];
+ if ( node.jquery && node.hasClass( 'mediaWiki_htmlEmitter' ) ) {
+ // This is a nested node, already expanded.
+ forms[formIndex] = forms[formIndex].html();
+ }
+ }
+ return forms.length ? this.language.convertPlural( count, forms ) : '';
+ },
+
+ /**
+ * Transform parsed structure according to gender.
+ *
+ * Usage: {{gender:[ mw.user object | '' | 'male' | 'female' | 'unknown' ] | masculine form | feminine form | neutral form}}.
+ *
+ * The first node must be one of:
+ * - the mw.user object (or a compatible one)
+ * - an empty string - indicating the current user, same effect as passing the mw.user object
+ * - a gender string ('male', 'female' or 'unknown')
+ *
+ * @param {Array} nodes List of nodes, [ {string|mw.user}, {string}, {string}, {string} ]
+ * @return {string} Selected gender form according to current language
+ */
+ gender: function ( nodes ) {
+ var gender,
+ maybeUser = nodes[0],
+ forms = nodes.slice( 1 );
+
+ if ( maybeUser === '' ) {
+ maybeUser = mw.user;
+ }
+
+ // If we are passed a mw.user-like object, check their gender.
+ // Otherwise, assume the gender string itself was passed .
+ if ( maybeUser && maybeUser.options instanceof mw.Map ) {
+ gender = maybeUser.options.get( 'gender' );
+ } else {
+ gender = maybeUser;
+ }
+
+ return this.language.gender( gender, forms );
+ },
+
+ /**
+ * Transform parsed structure into grammar conversion.
+ * Invoked by putting `{{grammar:form|word}}` in a message
+ * @param {Array} nodes List of nodes [{Grammar case eg: genitive}, {string word}]
+ * @return {string} selected grammatical form according to current language
+ */
+ grammar: function ( nodes ) {
+ var form = nodes[0],
+ word = nodes[1];
+ return word && form && this.language.convertGrammar( word, form );
+ },
+
+ /**
+ * Tranform parsed structure into a int: (interface language) message include
+ * Invoked by putting `{{int:othermessage}}` into a message
+ * @param {Array} nodes List of nodes
+ * @return {string} Other message
+ */
+ 'int': function ( nodes ) {
+ return mw.jqueryMsg.getMessageFunction()( nodes[0].toLowerCase() );
+ },
+
+ /**
+ * Takes an unformatted number (arab, no group separators and . as decimal separator)
+ * and outputs it in the localized digit script and formatted with decimal
+ * separator, according to the current language.
+ * @param {Array} nodes List of nodes
+ * @return {number|string} Formatted number
+ */
+ formatnum: function ( nodes ) {
+ var isInteger = ( nodes[1] && nodes[1] === 'R' ) ? true : false,
+ number = nodes[0];
+
+ return this.language.convertNumber( number, isInteger );
+ }
+ };
+
+ // Deprecated! don't rely on gM existing.
+ // The window.gM ought not to be required - or if required, not required here.
+ // But moving it to extensions breaks it (?!)
+ // Need to fix plugin so it could do attributes as well, then will be okay to remove this.
+ // @deprecated since 1.23
+ mw.log.deprecate( window, 'gM', mw.jqueryMsg.getMessageFunction(), 'Use mw.message( ... ).parse() instead.' );
+
+ /**
+ * @method
+ * @member jQuery
+ * @see mw.jqueryMsg#getPlugin
+ */
+ $.fn.msg = mw.jqueryMsg.getPlugin();
+
+ // Replace the default message parser with jqueryMsg
+ oldParser = mw.Message.prototype.parser;
+ mw.Message.prototype.parser = function () {
+ var messageFunction;
+
+ // TODO: should we cache the message function so we don't create a new one every time? Benchmark this maybe?
+ // Caching is somewhat problematic, because we do need different message functions for different maps, so
+ // we'd have to cache the parser as a member of this.map, which sounds a bit ugly.
+ // Do not use mw.jqueryMsg unless required
+ if ( this.format === 'plain' || !/\{\{|[\[<>]/.test( this.map.get( this.key ) ) ) {
+ // Fall back to mw.msg's simple parser
+ return oldParser.apply( this );
+ }
+
+ messageFunction = mw.jqueryMsg.getMessageFunction( {
+ 'messages': this.map,
+ // For format 'escaped', escaping part is handled by mediawiki.js
+ 'format': this.format
+ } );
+ return messageFunction( this.key, this.parameters );
+ };
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki/mediawiki.jqueryMsg.peg b/resources/src/mediawiki/mediawiki.jqueryMsg.peg
new file mode 100644
index 00000000..716c3261
--- /dev/null
+++ b/resources/src/mediawiki/mediawiki.jqueryMsg.peg
@@ -0,0 +1,85 @@
+/* PEG grammar for a subset of wikitext, useful in the MediaWiki frontend */
+
+start
+ = e:expression* { return e.length > 1 ? [ "CONCAT" ].concat(e) : e[0]; }
+
+expression
+ = template
+ / link
+ / extlink
+ / replacement
+ / literal
+
+paramExpression
+ = template
+ / link
+ / extlink
+ / replacement
+ / literalWithoutBar
+
+template
+ = "{{" t:templateContents "}}" { return t; }
+
+templateContents
+ = twr:templateWithReplacement p:templateParam* { return twr.concat(p) }
+ / twr:templateWithOutReplacement p:templateParam* { return twr.concat(p) }
+ / twr:templateWithOutFirstParameter p:templateParam* { return twr.concat(p) }
+ / t:templateName p:templateParam* { return p.length ? [ t, p ] : [ t ] }
+
+templateWithReplacement
+ = t:templateName ":" r:replacement { return [ t, r ] }
+
+templateWithOutReplacement
+ = t:templateName ":" p:paramExpression { return [ t, p ] }
+
+templateWithOutFirstParameter
+ = t:templateName ":" { return [ t, "" ] }
+
+templateParam
+ = "|" e:paramExpression* { return e.length > 1 ? [ "CONCAT" ].concat(e) : e[0]; }
+
+templateName
+ = tn:[A-Za-z_]+ { return tn.join('').toUpperCase() }
+
+/* TODO: Update to reflect separate piped and unpiped handling */
+link
+ = "[[" w:expression "]]" { return [ 'WLINK', w ]; }
+
+extlink
+ = "[" url:url whitespace text:expression "]" { return [ 'LINK', url, text ] }
+
+url
+ = url:[^ ]+ { return url.join(''); }
+
+whitespace
+ = [ ]+
+
+replacement
+ = '$' digits:digits { return [ 'REPLACE', parseInt( digits, 10 ) - 1 ] }
+
+digits
+ = [0-9]+
+
+literal
+ = lit:escapedOrRegularLiteral+ { return lit.join(''); }
+
+literalWithoutBar
+ = lit:escapedOrLiteralWithoutBar+ { return lit.join(''); }
+
+escapedOrRegularLiteral
+ = escapedLiteral
+ / regularLiteral
+
+escapedOrLiteralWithoutBar
+ = escapedLiteral
+ / regularLiteralWithoutBar
+
+escapedLiteral
+ = "\\" escaped:. { return escaped; }
+
+regularLiteral
+ = [^{}\[\]$\\]
+
+regularLiteralWithoutBar
+ = [^{}\[\]$\\|]
+
diff --git a/resources/src/mediawiki/mediawiki.js b/resources/src/mediawiki/mediawiki.js
new file mode 100644
index 00000000..e29c734d
--- /dev/null
+++ b/resources/src/mediawiki/mediawiki.js
@@ -0,0 +1,2399 @@
+/**
+ * Base library for MediaWiki.
+ *
+ * Exposed as globally as `mediaWiki` with `mw` as shortcut.
+ *
+ * @class mw
+ * @alternateClassName mediaWiki
+ * @singleton
+ */
+( function ( $ ) {
+ 'use strict';
+
+ /* Private Members */
+
+ var mw,
+ hasOwn = Object.prototype.hasOwnProperty,
+ slice = Array.prototype.slice,
+ trackCallbacks = $.Callbacks( 'memory' ),
+ trackQueue = [];
+
+ /**
+ * Log a message to window.console, if possible. Useful to force logging of some
+ * errors that are otherwise hard to detect (I.e., this logs also in production mode).
+ * Gets console references in each invocation, so that delayed debugging tools work
+ * fine. No need for optimization here, which would only result in losing logs.
+ *
+ * @private
+ * @method log_
+ * @param {string} msg text for the log entry.
+ * @param {Error} [e]
+ */
+ function log( msg, e ) {
+ var console = window.console;
+ if ( console && console.log ) {
+ console.log( msg );
+ // If we have an exception object, log it through .error() to trigger
+ // proper stacktraces in browsers that support it. There are no (known)
+ // browsers that don't support .error(), that do support .log() and
+ // have useful exception handling through .log().
+ if ( e && console.error ) {
+ console.error( String( e ), e );
+ }
+ }
+ }
+
+ /* Object constructors */
+
+ /**
+ * Creates an object that can be read from or written to from prototype functions
+ * that allow both single and multiple variables at once.
+ *
+ * @example
+ *
+ * var addies, wanted, results;
+ *
+ * // Create your address book
+ * addies = new mw.Map();
+ *
+ * // This data could be coming from an external source (eg. API/AJAX)
+ * addies.set( {
+ * 'John Doe' : '10 Wall Street, New York, USA',
+ * 'Jane Jackson' : '21 Oxford St, London, UK',
+ * 'Dominique van Halen' : 'Kalverstraat 7, Amsterdam, NL'
+ * } );
+ *
+ * wanted = ['Dominique van Halen', 'George Johnson', 'Jane Jackson'];
+ *
+ * // You can detect missing keys first
+ * if ( !addies.exists( wanted ) ) {
+ * // One or more are missing (in this case: "George Johnson")
+ * mw.log( 'One or more names were not found in your address book' );
+ * }
+ *
+ * // Or just let it give you what it can
+ * results = addies.get( wanted, 'Middle of Nowhere, Alaska, US' );
+ * mw.log( results['Jane Jackson'] ); // "21 Oxford St, London, UK"
+ * mw.log( results['George Johnson'] ); // "Middle of Nowhere, Alaska, US"
+ *
+ * @class mw.Map
+ *
+ * @constructor
+ * @param {Object|boolean} [values] Value-bearing object to map, or boolean
+ * true to map over the global object. Defaults to an empty object.
+ */
+ function Map( values ) {
+ this.values = values === true ? window : ( values || {} );
+ return this;
+ }
+
+ Map.prototype = {
+ /**
+ * Get the value of one or multiple a keys.
+ *
+ * If called with no arguments, all values will be returned.
+ *
+ * @param {string|Array} selection String key or array of keys to get values for.
+ * @param {Mixed} [fallback] Value to use in case key(s) do not exist.
+ * @return mixed If selection was a string returns the value or null,
+ * If selection was an array, returns an object of key/values (value is null if not found),
+ * If selection was not passed or invalid, will return the 'values' object member (be careful as
+ * objects are always passed by reference in JavaScript!).
+ * @return {string|Object|null} Values as a string or object, null if invalid/inexistant.
+ */
+ get: function ( selection, fallback ) {
+ var results, i;
+ // If we only do this in the `return` block, it'll fail for the
+ // call to get() from the mutli-selection block.
+ fallback = arguments.length > 1 ? fallback : null;
+
+ if ( $.isArray( selection ) ) {
+ selection = slice.call( selection );
+ results = {};
+ for ( i = 0; i < selection.length; i++ ) {
+ results[selection[i]] = this.get( selection[i], fallback );
+ }
+ return results;
+ }
+
+ if ( typeof selection === 'string' ) {
+ if ( !hasOwn.call( this.values, selection ) ) {
+ return fallback;
+ }
+ return this.values[selection];
+ }
+
+ if ( selection === undefined ) {
+ return this.values;
+ }
+
+ // invalid selection key
+ return null;
+ },
+
+ /**
+ * Sets one or multiple key/value pairs.
+ *
+ * @param {string|Object} selection String key to set value for, or object mapping keys to values.
+ * @param {Mixed} [value] Value to set (optional, only in use when key is a string)
+ * @return {Boolean} This returns true on success, false on failure.
+ */
+ set: function ( selection, value ) {
+ var s;
+
+ if ( $.isPlainObject( selection ) ) {
+ for ( s in selection ) {
+ this.values[s] = selection[s];
+ }
+ return true;
+ }
+ if ( typeof selection === 'string' && arguments.length > 1 ) {
+ this.values[selection] = value;
+ return true;
+ }
+ return false;
+ },
+
+ /**
+ * Checks if one or multiple keys exist.
+ *
+ * @param {Mixed} selection String key or array of keys to check
+ * @return {boolean} Existence of key(s)
+ */
+ exists: function ( selection ) {
+ var s;
+
+ if ( $.isArray( selection ) ) {
+ for ( s = 0; s < selection.length; s++ ) {
+ if ( typeof selection[s] !== 'string' || !hasOwn.call( this.values, selection[s] ) ) {
+ return false;
+ }
+ }
+ return true;
+ }
+ return typeof selection === 'string' && hasOwn.call( this.values, selection );
+ }
+ };
+
+ /**
+ * Object constructor for messages.
+ *
+ * Similar to the Message class in MediaWiki PHP.
+ *
+ * Format defaults to 'text'.
+ *
+ * @example
+ *
+ * var obj, str;
+ * mw.messages.set( {
+ * 'hello': 'Hello world',
+ * 'hello-user': 'Hello, $1!',
+ * 'welcome-user': 'Welcome back to $2, $1! Last visit by $1: $3'
+ * } );
+ *
+ * obj = new mw.Message( mw.messages, 'hello' );
+ * mw.log( obj.text() );
+ * // Hello world
+ *
+ * obj = new mw.Message( mw.messages, 'hello-user', [ 'John Doe' ] );
+ * mw.log( obj.text() );
+ * // Hello, John Doe!
+ *
+ * obj = new mw.Message( mw.messages, 'welcome-user', [ 'John Doe', 'Wikipedia', '2 hours ago' ] );
+ * mw.log( obj.text() );
+ * // Welcome back to Wikipedia, John Doe! Last visit by John Doe: 2 hours ago
+ *
+ * // Using mw.message shortcut
+ * obj = mw.message( 'hello-user', 'John Doe' );
+ * mw.log( obj.text() );
+ * // Hello, John Doe!
+ *
+ * // Using mw.msg shortcut
+ * str = mw.msg( 'hello-user', 'John Doe' );
+ * mw.log( str );
+ * // Hello, John Doe!
+ *
+ * // Different formats
+ * obj = new mw.Message( mw.messages, 'hello-user', [ 'John "Wiki" <3 Doe' ] );
+ *
+ * obj.format = 'text';
+ * str = obj.toString();
+ * // Same as:
+ * str = obj.text();
+ *
+ * mw.log( str );
+ * // Hello, John "Wiki" <3 Doe!
+ *
+ * mw.log( obj.escaped() );
+ * // Hello, John &quot;Wiki&quot; &lt;3 Doe!
+ *
+ * @class mw.Message
+ *
+ * @constructor
+ * @param {mw.Map} map Message storage
+ * @param {string} key
+ * @param {Array} [parameters]
+ */
+ function Message( map, key, parameters ) {
+ this.format = 'text';
+ this.map = map;
+ this.key = key;
+ this.parameters = parameters === undefined ? [] : slice.call( parameters );
+ return this;
+ }
+
+ Message.prototype = {
+ /**
+ * Simple message parser, does $N replacement and nothing else.
+ *
+ * This may be overridden to provide a more complex message parser.
+ *
+ * The primary override is in mediawiki.jqueryMsg.
+ *
+ * This function will not be called for nonexistent messages.
+ */
+ parser: function () {
+ var parameters = this.parameters;
+ return this.map.get( this.key ).replace( /\$(\d+)/g, function ( str, match ) {
+ var index = parseInt( match, 10 ) - 1;
+ return parameters[index] !== undefined ? parameters[index] : '$' + match;
+ } );
+ },
+
+ /**
+ * Appends (does not replace) parameters for replacement to the .parameters property.
+ *
+ * @param {Array} parameters
+ * @chainable
+ */
+ params: function ( parameters ) {
+ var i;
+ for ( i = 0; i < parameters.length; i += 1 ) {
+ this.parameters.push( parameters[i] );
+ }
+ return this;
+ },
+
+ /**
+ * Converts message object to its string form based on the state of format.
+ *
+ * @return {string} Message as a string in the current form or `<key>` if key does not exist.
+ */
+ toString: function () {
+ var text;
+
+ if ( !this.exists() ) {
+ // Use <key> as text if key does not exist
+ if ( this.format === 'escaped' || this.format === 'parse' ) {
+ // format 'escaped' and 'parse' need to have the brackets and key html escaped
+ return mw.html.escape( '<' + this.key + '>' );
+ }
+ return '<' + this.key + '>';
+ }
+
+ if ( this.format === 'plain' || this.format === 'text' || this.format === 'parse' ) {
+ text = this.parser();
+ }
+
+ if ( this.format === 'escaped' ) {
+ text = this.parser();
+ text = mw.html.escape( text );
+ }
+
+ return text;
+ },
+
+ /**
+ * Changes format to 'parse' and converts message to string
+ *
+ * If jqueryMsg is loaded, this parses the message text from wikitext
+ * (where supported) to HTML
+ *
+ * Otherwise, it is equivalent to plain.
+ *
+ * @return {string} String form of parsed message
+ */
+ parse: function () {
+ this.format = 'parse';
+ return this.toString();
+ },
+
+ /**
+ * Changes format to 'plain' and converts message to string
+ *
+ * This substitutes parameters, but otherwise does not change the
+ * message text.
+ *
+ * @return {string} String form of plain message
+ */
+ plain: function () {
+ this.format = 'plain';
+ return this.toString();
+ },
+
+ /**
+ * Changes format to 'text' and converts message to string
+ *
+ * If jqueryMsg is loaded, {{-transformation is done where supported
+ * (such as {{plural:}}, {{gender:}}, {{int:}}).
+ *
+ * Otherwise, it is equivalent to plain.
+ */
+ text: function () {
+ this.format = 'text';
+ return this.toString();
+ },
+
+ /**
+ * Changes the format to 'escaped' and converts message to string
+ *
+ * This is equivalent to using the 'text' format (see text method), then
+ * HTML-escaping the output.
+ *
+ * @return {string} String form of html escaped message
+ */
+ escaped: function () {
+ this.format = 'escaped';
+ return this.toString();
+ },
+
+ /**
+ * Checks if message exists
+ *
+ * @see mw.Map#exists
+ * @return {boolean}
+ */
+ exists: function () {
+ return this.map.exists( this.key );
+ }
+ };
+
+ /**
+ * @class mw
+ */
+ mw = {
+ /* Public Members */
+
+ /**
+ * Get the current time, measured in milliseconds since January 1, 1970 (UTC).
+ *
+ * On browsers that implement the Navigation Timing API, this function will produce floating-point
+ * values with microsecond precision that are guaranteed to be monotonic. On all other browsers,
+ * it will fall back to using `Date`.
+ *
+ * @return {number} Current time
+ */
+ now: ( function () {
+ var perf = window.performance,
+ navStart = perf && perf.timing && perf.timing.navigationStart;
+ return navStart && typeof perf.now === 'function' ?
+ function () { return navStart + perf.now(); } :
+ function () { return +new Date(); };
+ }() ),
+
+ /**
+ * Track an analytic event.
+ *
+ * This method provides a generic means for MediaWiki JavaScript code to capture state
+ * information for analysis. Each logged event specifies a string topic name that describes
+ * the kind of event that it is. Topic names consist of dot-separated path components,
+ * arranged from most general to most specific. Each path component should have a clear and
+ * well-defined purpose.
+ *
+ * Data handlers are registered via `mw.trackSubscribe`, and receive the full set of
+ * events that match their subcription, including those that fired before the handler was
+ * bound.
+ *
+ * @param {string} topic Topic name
+ * @param {Object} [data] Data describing the event, encoded as an object
+ */
+ track: function ( topic, data ) {
+ trackQueue.push( { topic: topic, timeStamp: mw.now(), data: data } );
+ trackCallbacks.fire( trackQueue );
+ },
+
+ /**
+ * Register a handler for subset of analytic events, specified by topic
+ *
+ * Handlers will be called once for each tracked event, including any events that fired before the
+ * handler was registered; 'this' is set to a plain object with a 'timeStamp' property indicating
+ * the exact time at which the event fired, a string 'topic' property naming the event, and a
+ * 'data' property which is an object of event-specific data. The event topic and event data are
+ * also passed to the callback as the first and second arguments, respectively.
+ *
+ * @param {string} topic Handle events whose name starts with this string prefix
+ * @param {Function} callback Handler to call for each matching tracked event
+ */
+ trackSubscribe: function ( topic, callback ) {
+ var seen = 0;
+
+ trackCallbacks.add( function ( trackQueue ) {
+ var event;
+ for ( ; seen < trackQueue.length; seen++ ) {
+ event = trackQueue[ seen ];
+ if ( event.topic.indexOf( topic ) === 0 ) {
+ callback.call( event, event.topic, event.data );
+ }
+ }
+ } );
+ },
+
+ // Make the Map constructor publicly available.
+ Map: Map,
+
+ // Make the Message constructor publicly available.
+ Message: Message,
+
+ /**
+ * Map of configuration values
+ *
+ * Check out [the complete list of configuration values](https://www.mediawiki.org/wiki/Manual:Interface/JavaScript#mw.config)
+ * on mediawiki.org.
+ *
+ * If `$wgLegacyJavaScriptGlobals` is true, this Map will add its values to the
+ * global `window` object.
+ *
+ * @property {mw.Map} config
+ */
+ // Dummy placeholder. Re-assigned in ResourceLoaderStartupModule to an instance of `mw.Map`.
+ config: null,
+
+ /**
+ * Empty object that plugins can be installed in.
+ * @property
+ */
+ libs: {},
+
+ /**
+ * Access container for deprecated functionality that can be moved from
+ * from their legacy location and attached to this object (e.g. a global
+ * function that is deprecated and as stop-gap can be exposed through here).
+ *
+ * This was reserved for future use but never ended up being used.
+ *
+ * @deprecated since 1.22 Let deprecated identifiers keep their original name
+ * and use mw.log#deprecate to create an access container for tracking.
+ * @property
+ */
+ legacy: {},
+
+ /**
+ * Localization system
+ * @property {mw.Map}
+ */
+ messages: new Map(),
+
+ /* Public Methods */
+
+ /**
+ * Get a message object.
+ *
+ * Shorcut for `new mw.Message( mw.messages, key, parameters )`.
+ *
+ * @see mw.Message
+ * @param {string} key Key of message to get
+ * @param {Mixed...} parameters Parameters for the $N replacements in messages.
+ * @return {mw.Message}
+ */
+ message: function ( key ) {
+ // Variadic arguments
+ var parameters = slice.call( arguments, 1 );
+ return new Message( mw.messages, key, parameters );
+ },
+
+ /**
+ * Get a message string using the (default) 'text' format.
+ *
+ * Shortcut for `mw.message( key, parameters... ).text()`.
+ *
+ * @see mw.Message
+ * @param {string} key Key of message to get
+ * @param {Mixed...} parameters Parameters for the $N replacements in messages.
+ * @return {string}
+ */
+ msg: function () {
+ return mw.message.apply( mw.message, arguments ).toString();
+ },
+
+ /**
+ * Dummy placeholder for {@link mw.log}
+ * @method
+ */
+ log: ( function () {
+ // Also update the restoration of methods in mediawiki.log.js
+ // when adding or removing methods here.
+ var log = function () {};
+
+ /**
+ * @class mw.log
+ * @singleton
+ */
+
+ /**
+ * Write a message the console's warning channel.
+ * Also logs a stacktrace for easier debugging.
+ * Each action is silently ignored if the browser doesn't support it.
+ *
+ * @param {string...} msg Messages to output to console
+ */
+ log.warn = function () {
+ var console = window.console;
+ if ( console && console.warn && console.warn.apply ) {
+ console.warn.apply( console, arguments );
+ if ( console.trace ) {
+ console.trace();
+ }
+ }
+ };
+
+ /**
+ * Create a property in a host object that, when accessed, will produce
+ * a deprecation warning in the console with backtrace.
+ *
+ * @param {Object} obj Host object of deprecated property
+ * @param {string} key Name of property to create in `obj`
+ * @param {Mixed} val The value this property should return when accessed
+ * @param {string} [msg] Optional text to include in the deprecation message.
+ */
+ log.deprecate = !Object.defineProperty ? function ( obj, key, val ) {
+ obj[key] = val;
+ } : function ( obj, key, val, msg ) {
+ msg = 'Use of "' + key + '" is deprecated.' + ( msg ? ( ' ' + msg ) : '' );
+ try {
+ Object.defineProperty( obj, key, {
+ configurable: true,
+ enumerable: true,
+ get: function () {
+ mw.track( 'mw.deprecate', key );
+ mw.log.warn( msg );
+ return val;
+ },
+ set: function ( newVal ) {
+ mw.track( 'mw.deprecate', key );
+ mw.log.warn( msg );
+ val = newVal;
+ }
+ } );
+ } catch ( err ) {
+ // IE8 can throw on Object.defineProperty
+ obj[key] = val;
+ }
+ };
+
+ return log;
+ }() ),
+
+ /**
+ * Client-side module loader which integrates with the MediaWiki ResourceLoader
+ * @class mw.loader
+ * @singleton
+ */
+ loader: ( function () {
+
+ /* Private Members */
+
+ /**
+ * Mapping of registered modules
+ *
+ * The jquery module is pre-registered, because it must have already
+ * been provided for this object to have been built, and in debug mode
+ * jquery would have been provided through a unique loader request,
+ * making it impossible to hold back registration of jquery until after
+ * mediawiki.
+ *
+ * For exact details on support for script, style and messages, look at
+ * mw.loader.implement.
+ *
+ * Format:
+ * {
+ * 'moduleName': {
+ * // At registry
+ * 'version': ############## (unix timestamp),
+ * 'dependencies': ['required.foo', 'bar.also', ...], (or) function () {}
+ * 'group': 'somegroup', (or) null,
+ * 'source': 'local', 'someforeignwiki', (or) null
+ * 'state': 'registered', 'loaded', 'loading', 'ready', 'error' or 'missing'
+ * 'skip': 'return !!window.Example', (or) null
+ *
+ * // Added during implementation
+ * 'skipped': true,
+ * 'script': ...,
+ * 'style': ...,
+ * 'messages': { 'key': 'value' },
+ * }
+ * }
+ *
+ * @property
+ * @private
+ */
+ var registry = {},
+ //
+ // Mapping of sources, keyed by source-id, values are strings.
+ // Format:
+ // {
+ // 'sourceId': 'http://foo.bar/w/load.php'
+ // }
+ //
+ sources = {},
+ // List of modules which will be loaded as when ready
+ batch = [],
+ // List of modules to be loaded
+ queue = [],
+ // List of callback functions waiting for modules to be ready to be called
+ jobs = [],
+ // Selector cache for the marker element. Use getMarker() to get/use the marker!
+ $marker = null,
+ // Buffer for addEmbeddedCSS.
+ cssBuffer = '',
+ // Callbacks for addEmbeddedCSS.
+ cssCallbacks = $.Callbacks();
+
+ /* Private methods */
+
+ function getMarker() {
+ // Cached
+ if ( !$marker ) {
+ $marker = $( 'meta[name="ResourceLoaderDynamicStyles"]' );
+ if ( !$marker.length ) {
+ mw.log( 'No <meta name="ResourceLoaderDynamicStyles"> found, inserting dynamically' );
+ $marker = $( '<meta>' ).attr( 'name', 'ResourceLoaderDynamicStyles' ).appendTo( 'head' );
+ }
+ }
+ return $marker;
+ }
+
+ /**
+ * Create a new style tag and add it to the DOM.
+ *
+ * @private
+ * @param {string} text CSS text
+ * @param {HTMLElement|jQuery} [nextnode=document.head] The element where the style tag should be
+ * inserted before. Otherwise it will be appended to `<head>`.
+ * @return {HTMLElement} Reference to the created `<style>` element.
+ */
+ function newStyleTag( text, nextnode ) {
+ var s = document.createElement( 'style' );
+ // Insert into document before setting cssText (bug 33305)
+ if ( nextnode ) {
+ // Must be inserted with native insertBefore, not $.fn.before.
+ // When using jQuery to insert it, like $nextnode.before( s ),
+ // then IE6 will throw "Access is denied" when trying to append
+ // to .cssText later. Some kind of weird security measure.
+ // http://stackoverflow.com/q/12586482/319266
+ // Works: jsfiddle.net/zJzMy/1
+ // Fails: jsfiddle.net/uJTQz
+ // Works again: http://jsfiddle.net/Azr4w/ (diff: the next 3 lines)
+ if ( nextnode.jquery ) {
+ nextnode = nextnode.get( 0 );
+ }
+ nextnode.parentNode.insertBefore( s, nextnode );
+ } else {
+ document.getElementsByTagName( 'head' )[0].appendChild( s );
+ }
+ if ( s.styleSheet ) {
+ // IE
+ s.styleSheet.cssText = text;
+ } else {
+ // Other browsers.
+ // (Safari sometimes borks on non-string values,
+ // play safe by casting to a string, just in case.)
+ s.appendChild( document.createTextNode( String( text ) ) );
+ }
+ return s;
+ }
+
+ /**
+ * Checks whether it is safe to add this css to a stylesheet.
+ *
+ * @private
+ * @param {string} cssText
+ * @return {boolean} False if a new one must be created.
+ */
+ function canExpandStylesheetWith( cssText ) {
+ // Makes sure that cssText containing `@import`
+ // rules will end up in a new stylesheet (as those only work when
+ // placed at the start of a stylesheet; bug 35562).
+ return cssText.indexOf( '@import' ) === -1;
+ }
+
+ /**
+ * Add a bit of CSS text to the current browser page.
+ *
+ * The CSS will be appended to an existing ResourceLoader-created `<style>` tag
+ * or create a new one based on whether the given `cssText` is safe for extension.
+ *
+ * @param {string} [cssText=cssBuffer] If called without cssText,
+ * the internal buffer will be inserted instead.
+ * @param {Function} [callback]
+ */
+ function addEmbeddedCSS( cssText, callback ) {
+ var $style, styleEl;
+
+ if ( callback ) {
+ cssCallbacks.add( callback );
+ }
+
+ // Yield once before inserting the <style> tag. There are likely
+ // more calls coming up which we can combine this way.
+ // Appending a stylesheet and waiting for the browser to repaint
+ // is fairly expensive, this reduces it (bug 45810)
+ if ( cssText ) {
+ // Be careful not to extend the buffer with css that needs a new stylesheet
+ if ( !cssBuffer || canExpandStylesheetWith( cssText ) ) {
+ // Linebreak for somewhat distinguishable sections
+ // (the rl-cachekey comment separating each)
+ cssBuffer += '\n' + cssText;
+ // TODO: Use requestAnimationFrame in the future which will
+ // perform even better by not injecting styles while the browser
+ // is paiting.
+ setTimeout( function () {
+ // Can't pass addEmbeddedCSS to setTimeout directly because Firefox
+ // (below version 13) has the non-standard behaviour of passing a
+ // numerical "lateness" value as first argument to this callback
+ // http://benalman.com/news/2009/07/the-mysterious-firefox-settime/
+ addEmbeddedCSS();
+ } );
+ return;
+ }
+
+ // This is a delayed call and we got a buffer still
+ } else if ( cssBuffer ) {
+ cssText = cssBuffer;
+ cssBuffer = '';
+ } else {
+ // This is a delayed call, but buffer is already cleared by
+ // another delayed call.
+ return;
+ }
+
+ // By default, always create a new <style>. Appending text to a <style>
+ // tag is bad as it means the contents have to be re-parsed (bug 45810).
+ //
+ // Except, of course, in IE 9 and below. In there we default to re-using and
+ // appending to a <style> tag due to the IE stylesheet limit (bug 31676).
+ if ( 'documentMode' in document && document.documentMode <= 9 ) {
+
+ $style = getMarker().prev();
+ // Verify that the the element before Marker actually is a
+ // <style> tag and one that came from ResourceLoader
+ // (not some other style tag or even a `<meta>` or `<script>`).
+ if ( $style.data( 'ResourceLoaderDynamicStyleTag' ) === true ) {
+ // There's already a dynamic <style> tag present and
+ // canExpandStylesheetWith() gave a green light to append more to it.
+ styleEl = $style.get( 0 );
+ if ( styleEl.styleSheet ) {
+ try {
+ styleEl.styleSheet.cssText += cssText; // IE
+ } catch ( e ) {
+ log( 'Stylesheet error', e );
+ }
+ } else {
+ styleEl.appendChild( document.createTextNode( String( cssText ) ) );
+ }
+ cssCallbacks.fire().empty();
+ return;
+ }
+ }
+
+ $( newStyleTag( cssText, getMarker() ) ).data( 'ResourceLoaderDynamicStyleTag', true );
+
+ cssCallbacks.fire().empty();
+ }
+
+ /**
+ * Generates an ISO8601 "basic" string from a UNIX timestamp
+ * @private
+ */
+ function formatVersionNumber( timestamp ) {
+ var d = new Date();
+ function pad( a, b, c ) {
+ return [a < 10 ? '0' + a : a, b < 10 ? '0' + b : b, c < 10 ? '0' + c : c].join( '' );
+ }
+ d.setTime( timestamp * 1000 );
+ return [
+ pad( d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate() ), 'T',
+ pad( d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds() ), 'Z'
+ ].join( '' );
+ }
+
+ /**
+ * Resolves dependencies and detects circular references.
+ *
+ * @private
+ * @param {string} module Name of the top-level module whose dependencies shall be
+ * resolved and sorted.
+ * @param {Array} resolved Returns a topological sort of the given module and its
+ * dependencies, such that later modules depend on earlier modules. The array
+ * contains the module names. If the array contains already some module names,
+ * this function appends its result to the pre-existing array.
+ * @param {Object} [unresolved] Hash used to track the current dependency
+ * chain; used to report loops in the dependency graph.
+ * @throws {Error} If any unregistered module or a dependency loop is encountered
+ */
+ function sortDependencies( module, resolved, unresolved ) {
+ var n, deps, len, skip;
+
+ if ( registry[module] === undefined ) {
+ throw new Error( 'Unknown dependency: ' + module );
+ }
+
+ if ( registry[module].skip !== null ) {
+ /*jshint evil:true */
+ skip = new Function( registry[module].skip );
+ registry[module].skip = null;
+ if ( skip() ) {
+ registry[module].skipped = true;
+ registry[module].dependencies = [];
+ registry[module].state = 'ready';
+ handlePending( module );
+ return;
+ }
+ }
+
+ // Resolves dynamic loader function and replaces it with its own results
+ if ( $.isFunction( registry[module].dependencies ) ) {
+ registry[module].dependencies = registry[module].dependencies();
+ // Ensures the module's dependencies are always in an array
+ if ( typeof registry[module].dependencies !== 'object' ) {
+ registry[module].dependencies = [registry[module].dependencies];
+ }
+ }
+ if ( $.inArray( module, resolved ) !== -1 ) {
+ // Module already resolved; nothing to do.
+ return;
+ }
+ // unresolved is optional, supply it if not passed in
+ if ( !unresolved ) {
+ unresolved = {};
+ }
+ // Tracks down dependencies
+ deps = registry[module].dependencies;
+ len = deps.length;
+ for ( n = 0; n < len; n += 1 ) {
+ if ( $.inArray( deps[n], resolved ) === -1 ) {
+ if ( unresolved[deps[n]] ) {
+ throw new Error(
+ 'Circular reference detected: ' + module +
+ ' -> ' + deps[n]
+ );
+ }
+
+ // Add to unresolved
+ unresolved[module] = true;
+ sortDependencies( deps[n], resolved, unresolved );
+ delete unresolved[module];
+ }
+ }
+ resolved[resolved.length] = module;
+ }
+
+ /**
+ * Gets a list of module names that a module depends on in their proper dependency
+ * order.
+ *
+ * @private
+ * @param {string} module Module name or array of string module names
+ * @return {Array} list of dependencies, including 'module'.
+ * @throws {Error} If circular reference is detected
+ */
+ function resolve( module ) {
+ var m, resolved;
+
+ // Allow calling with an array of module names
+ if ( $.isArray( module ) ) {
+ resolved = [];
+ for ( m = 0; m < module.length; m += 1 ) {
+ sortDependencies( module[m], resolved );
+ }
+ return resolved;
+ }
+
+ if ( typeof module === 'string' ) {
+ resolved = [];
+ sortDependencies( module, resolved );
+ return resolved;
+ }
+
+ throw new Error( 'Invalid module argument: ' + module );
+ }
+
+ /**
+ * Narrows a list of module names down to those matching a specific
+ * state (see comment on top of this scope for a list of valid states).
+ * One can also filter for 'unregistered', which will return the
+ * modules names that don't have a registry entry.
+ *
+ * @private
+ * @param {string|string[]} states Module states to filter by
+ * @param {Array} [modules] List of module names to filter (optional, by default the entire
+ * registry is used)
+ * @return {Array} List of filtered module names
+ */
+ function filter( states, modules ) {
+ var list, module, s, m;
+
+ // Allow states to be given as a string
+ if ( typeof states === 'string' ) {
+ states = [states];
+ }
+ // If called without a list of modules, build and use a list of all modules
+ list = [];
+ if ( modules === undefined ) {
+ modules = [];
+ for ( module in registry ) {
+ modules[modules.length] = module;
+ }
+ }
+ // Build a list of modules which are in one of the specified states
+ for ( s = 0; s < states.length; s += 1 ) {
+ for ( m = 0; m < modules.length; m += 1 ) {
+ if ( registry[modules[m]] === undefined ) {
+ // Module does not exist
+ if ( states[s] === 'unregistered' ) {
+ // OK, undefined
+ list[list.length] = modules[m];
+ }
+ } else {
+ // Module exists, check state
+ if ( registry[modules[m]].state === states[s] ) {
+ // OK, correct state
+ list[list.length] = modules[m];
+ }
+ }
+ }
+ }
+ return list;
+ }
+
+ /**
+ * Determine whether all dependencies are in state 'ready', which means we may
+ * execute the module or job now.
+ *
+ * @private
+ * @param {Array} dependencies Dependencies (module names) to be checked.
+ * @return {boolean} True if all dependencies are in state 'ready', false otherwise
+ */
+ function allReady( dependencies ) {
+ return filter( 'ready', dependencies ).length === dependencies.length;
+ }
+
+ /**
+ * A module has entered state 'ready', 'error', or 'missing'. Automatically update pending jobs
+ * and modules that depend upon this module. if the given module failed, propagate the 'error'
+ * state up the dependency tree; otherwise, execute all jobs/modules that now have all their
+ * dependencies satisfied. On jobs depending on a failed module, run the error callback, if any.
+ *
+ * @private
+ * @param {string} module Name of module that entered one of the states 'ready', 'error', or 'missing'.
+ */
+ function handlePending( module ) {
+ var j, job, hasErrors, m, stateChange;
+
+ // Modules.
+ if ( $.inArray( registry[module].state, ['error', 'missing'] ) !== -1 ) {
+ // If the current module failed, mark all dependent modules also as failed.
+ // Iterate until steady-state to propagate the error state upwards in the
+ // dependency tree.
+ do {
+ stateChange = false;
+ for ( m in registry ) {
+ if ( $.inArray( registry[m].state, ['error', 'missing'] ) === -1 ) {
+ if ( filter( ['error', 'missing'], registry[m].dependencies ).length > 0 ) {
+ registry[m].state = 'error';
+ stateChange = true;
+ }
+ }
+ }
+ } while ( stateChange );
+ }
+
+ // Execute all jobs whose dependencies are either all satisfied or contain at least one failed module.
+ for ( j = 0; j < jobs.length; j += 1 ) {
+ hasErrors = filter( ['error', 'missing'], jobs[j].dependencies ).length > 0;
+ if ( hasErrors || allReady( jobs[j].dependencies ) ) {
+ // All dependencies satisfied, or some have errors
+ job = jobs[j];
+ jobs.splice( j, 1 );
+ j -= 1;
+ try {
+ if ( hasErrors ) {
+ if ( $.isFunction( job.error ) ) {
+ job.error( new Error( 'Module ' + module + ' has failed dependencies' ), [module] );
+ }
+ } else {
+ if ( $.isFunction( job.ready ) ) {
+ job.ready();
+ }
+ }
+ } catch ( e ) {
+ // A user-defined callback raised an exception.
+ // Swallow it to protect our state machine!
+ log( 'Exception thrown by user callback', e );
+ }
+ }
+ }
+
+ if ( registry[module].state === 'ready' ) {
+ // The current module became 'ready'. Set it in the module store, and recursively execute all
+ // dependent modules that are loaded and now have all dependencies satisfied.
+ mw.loader.store.set( module, registry[module] );
+ for ( m in registry ) {
+ if ( registry[m].state === 'loaded' && allReady( registry[m].dependencies ) ) {
+ execute( m );
+ }
+ }
+ }
+ }
+
+ /**
+ * Adds a script tag to the DOM, either using document.write or low-level DOM manipulation,
+ * depending on whether document-ready has occurred yet and whether we are in async mode.
+ *
+ * @private
+ * @param {string} src URL to script, will be used as the src attribute in the script tag
+ * @param {Function} [callback] Callback which will be run when the script is done
+ * @param {boolean} [async=false] Whether to load modules asynchronously.
+ * Ignored (and defaulted to `true`) if the document-ready event has already occurred.
+ */
+ function addScript( src, callback, async ) {
+ // Using isReady directly instead of storing it locally from a $().ready callback (bug 31895)
+ if ( $.isReady || async ) {
+ $.ajax( {
+ url: src,
+ dataType: 'script',
+ // Force jQuery behaviour to be for crossDomain. Otherwise jQuery would use
+ // XHR for a same domain request instead of <script>, which changes the request
+ // headers (potentially missing a cache hit), and reduces caching in general
+ // since browsers cache XHR much less (if at all). And XHR means we retreive
+ // text, so we'd need to $.globalEval, which then messes up line numbers.
+ crossDomain: true,
+ cache: true,
+ async: true
+ } ).always( callback );
+ } else {
+ /*jshint evil:true */
+ document.write( mw.html.element( 'script', { 'src': src }, '' ) );
+ if ( callback ) {
+ // Document.write is synchronous, so this is called when it's done.
+ // FIXME: That's a lie. doc.write isn't actually synchronous.
+ callback();
+ }
+ }
+ }
+
+ /**
+ * Executes a loaded module, making it ready to use
+ *
+ * @private
+ * @param {string} module Module name to execute
+ */
+ function execute( module ) {
+ var key, value, media, i, urls, cssHandle, checkCssHandles,
+ cssHandlesRegistered = false;
+
+ if ( registry[module] === undefined ) {
+ throw new Error( 'Module has not been registered yet: ' + module );
+ } else if ( registry[module].state === 'registered' ) {
+ throw new Error( 'Module has not been requested from the server yet: ' + module );
+ } else if ( registry[module].state === 'loading' ) {
+ throw new Error( 'Module has not completed loading yet: ' + module );
+ } else if ( registry[module].state === 'ready' ) {
+ throw new Error( 'Module has already been executed: ' + module );
+ }
+
+ /**
+ * Define loop-function here for efficiency
+ * and to avoid re-using badly scoped variables.
+ * @ignore
+ */
+ function addLink( media, url ) {
+ var el = document.createElement( 'link' );
+ // For IE: Insert in document *before* setting href
+ getMarker().before( el );
+ el.rel = 'stylesheet';
+ if ( media && media !== 'all' ) {
+ el.media = media;
+ }
+ // If you end up here from an IE exception "SCRIPT: Invalid property value.",
+ // see #addEmbeddedCSS, bug 31676, and bug 47277 for details.
+ el.href = url;
+ }
+
+ function runScript() {
+ var script, markModuleReady, nestedAddScript;
+ try {
+ script = registry[module].script;
+ markModuleReady = function () {
+ registry[module].state = 'ready';
+ handlePending( module );
+ };
+ nestedAddScript = function ( arr, callback, async, i ) {
+ // Recursively call addScript() in its own callback
+ // for each element of arr.
+ if ( i >= arr.length ) {
+ // We're at the end of the array
+ callback();
+ return;
+ }
+
+ addScript( arr[i], function () {
+ nestedAddScript( arr, callback, async, i + 1 );
+ }, async );
+ };
+
+ if ( $.isArray( script ) ) {
+ nestedAddScript( script, markModuleReady, registry[module].async, 0 );
+ } else if ( $.isFunction( script ) ) {
+ registry[module].state = 'ready';
+ // Pass jQuery twice so that the signature of the closure which wraps
+ // the script can bind both '$' and 'jQuery'.
+ script( $, $ );
+ handlePending( module );
+ }
+ } catch ( e ) {
+ // This needs to NOT use mw.log because these errors are common in production mode
+ // and not in debug mode, such as when a symbol that should be global isn't exported
+ log( 'Exception thrown by ' + module, e );
+ registry[module].state = 'error';
+ handlePending( module );
+ }
+ }
+
+ // This used to be inside runScript, but since that is now fired asychronously
+ // (after CSS is loaded) we need to set it here right away. It is crucial that
+ // when execute() is called this is set synchronously, otherwise modules will get
+ // executed multiple times as the registry will state that it isn't loading yet.
+ registry[module].state = 'loading';
+
+ // Add localizations to message system
+ if ( $.isPlainObject( registry[module].messages ) ) {
+ mw.messages.set( registry[module].messages );
+ }
+
+ if ( $.isReady || registry[module].async ) {
+ // Make sure we don't run the scripts until all (potentially asynchronous)
+ // stylesheet insertions have completed.
+ ( function () {
+ var pending = 0;
+ checkCssHandles = function () {
+ // cssHandlesRegistered ensures we don't take off too soon, e.g. when
+ // one of the cssHandles is fired while we're still creating more handles.
+ if ( cssHandlesRegistered && pending === 0 && runScript ) {
+ runScript();
+ runScript = undefined; // Revoke
+ }
+ };
+ cssHandle = function () {
+ var check = checkCssHandles;
+ pending++;
+ return function () {
+ if (check) {
+ pending--;
+ check();
+ check = undefined; // Revoke
+ }
+ };
+ };
+ }() );
+ } else {
+ // We are in blocking mode, and so we can't afford to wait for CSS
+ cssHandle = function () {};
+ // Run immediately
+ checkCssHandles = runScript;
+ }
+
+ // Process styles (see also mw.loader.implement)
+ // * back-compat: { <media>: css }
+ // * back-compat: { <media>: [url, ..] }
+ // * { "css": [css, ..] }
+ // * { "url": { <media>: [url, ..] } }
+ if ( $.isPlainObject( registry[module].style ) ) {
+ for ( key in registry[module].style ) {
+ value = registry[module].style[key];
+ media = undefined;
+
+ if ( key !== 'url' && key !== 'css' ) {
+ // Backwards compatibility, key is a media-type
+ if ( typeof value === 'string' ) {
+ // back-compat: { <media>: css }
+ // Ignore 'media' because it isn't supported (nor was it used).
+ // Strings are pre-wrapped in "@media". The media-type was just ""
+ // (because it had to be set to something).
+ // This is one of the reasons why this format is no longer used.
+ addEmbeddedCSS( value, cssHandle() );
+ } else {
+ // back-compat: { <media>: [url, ..] }
+ media = key;
+ key = 'bc-url';
+ }
+ }
+
+ // Array of css strings in key 'css',
+ // or back-compat array of urls from media-type
+ if ( $.isArray( value ) ) {
+ for ( i = 0; i < value.length; i += 1 ) {
+ if ( key === 'bc-url' ) {
+ // back-compat: { <media>: [url, ..] }
+ addLink( media, value[i] );
+ } else if ( key === 'css' ) {
+ // { "css": [css, ..] }
+ addEmbeddedCSS( value[i], cssHandle() );
+ }
+ }
+ // Not an array, but a regular object
+ // Array of urls inside media-type key
+ } else if ( typeof value === 'object' ) {
+ // { "url": { <media>: [url, ..] } }
+ for ( media in value ) {
+ urls = value[media];
+ for ( i = 0; i < urls.length; i += 1 ) {
+ addLink( media, urls[i] );
+ }
+ }
+ }
+ }
+ }
+
+ // Kick off.
+ cssHandlesRegistered = true;
+ checkCssHandles();
+ }
+
+ /**
+ * Adds a dependencies to the queue with optional callbacks to be run
+ * when the dependencies are ready or fail
+ *
+ * @private
+ * @param {string|string[]} dependencies Module name or array of string module names
+ * @param {Function} [ready] Callback to execute when all dependencies are ready
+ * @param {Function} [error] Callback to execute when any dependency fails
+ * @param {boolean} [async=false] Whether to load modules asynchronously.
+ * Ignored (and defaulted to `true`) if the document-ready event has already occurred.
+ */
+ function request( dependencies, ready, error, async ) {
+ var n;
+
+ // Allow calling by single module name
+ if ( typeof dependencies === 'string' ) {
+ dependencies = [dependencies];
+ }
+
+ // Add ready and error callbacks if they were given
+ if ( ready !== undefined || error !== undefined ) {
+ jobs[jobs.length] = {
+ 'dependencies': filter(
+ ['registered', 'loading', 'loaded'],
+ dependencies
+ ),
+ 'ready': ready,
+ 'error': error
+ };
+ }
+
+ // Queue up any dependencies that are registered
+ dependencies = filter( ['registered'], dependencies );
+ for ( n = 0; n < dependencies.length; n += 1 ) {
+ if ( $.inArray( dependencies[n], queue ) === -1 ) {
+ queue[queue.length] = dependencies[n];
+ if ( async ) {
+ // Mark this module as async in the registry
+ registry[dependencies[n]].async = true;
+ }
+ }
+ }
+
+ // Work the queue
+ mw.loader.work();
+ }
+
+ function sortQuery( o ) {
+ var sorted = {}, key, a = [];
+ for ( key in o ) {
+ if ( hasOwn.call( o, key ) ) {
+ a.push( key );
+ }
+ }
+ a.sort();
+ for ( key = 0; key < a.length; key += 1 ) {
+ sorted[a[key]] = o[a[key]];
+ }
+ return sorted;
+ }
+
+ /**
+ * Converts a module map of the form { foo: [ 'bar', 'baz' ], bar: [ 'baz, 'quux' ] }
+ * to a query string of the form foo.bar,baz|bar.baz,quux
+ * @private
+ */
+ function buildModulesString( moduleMap ) {
+ var arr = [], p, prefix;
+ for ( prefix in moduleMap ) {
+ p = prefix === '' ? '' : prefix + '.';
+ arr.push( p + moduleMap[prefix].join( ',' ) );
+ }
+ return arr.join( '|' );
+ }
+
+ /**
+ * Asynchronously append a script tag to the end of the body
+ * that invokes load.php
+ * @private
+ * @param {Object} moduleMap Module map, see #buildModulesString
+ * @param {Object} currReqBase Object with other parameters (other than 'modules') to use in the request
+ * @param {string} sourceLoadScript URL of load.php
+ * @param {boolean} async Whether to load modules asynchronously.
+ * Ignored (and defaulted to `true`) if the document-ready event has already occurred.
+ */
+ function doRequest( moduleMap, currReqBase, sourceLoadScript, async ) {
+ var request = $.extend(
+ { modules: buildModulesString( moduleMap ) },
+ currReqBase
+ );
+ request = sortQuery( request );
+ // Append &* to avoid triggering the IE6 extension check
+ addScript( sourceLoadScript + '?' + $.param( request ) + '&*', null, async );
+ }
+
+ /* Public Members */
+ return {
+ /**
+ * The module registry is exposed as an aid for debugging and inspecting page
+ * state; it is not a public interface for modifying the registry.
+ *
+ * @see #registry
+ * @property
+ * @private
+ */
+ moduleRegistry: registry,
+
+ /**
+ * @inheritdoc #newStyleTag
+ * @method
+ */
+ addStyleTag: newStyleTag,
+
+ /**
+ * Batch-request queued dependencies from the server.
+ */
+ work: function () {
+ var reqBase, splits, maxQueryLength, q, b, bSource, bGroup, bSourceGroup,
+ source, concatSource, origBatch, group, g, i, modules, maxVersion, sourceLoadScript,
+ currReqBase, currReqBaseLength, moduleMap, l,
+ lastDotIndex, prefix, suffix, bytesAdded, async;
+
+ // Build a list of request parameters common to all requests.
+ reqBase = {
+ skin: mw.config.get( 'skin' ),
+ lang: mw.config.get( 'wgUserLanguage' ),
+ debug: mw.config.get( 'debug' )
+ };
+ // Split module batch by source and by group.
+ splits = {};
+ maxQueryLength = mw.config.get( 'wgResourceLoaderMaxQueryLength', -1 );
+
+ // Appends a list of modules from the queue to the batch
+ for ( q = 0; q < queue.length; q += 1 ) {
+ // Only request modules which are registered
+ if ( registry[queue[q]] !== undefined && registry[queue[q]].state === 'registered' ) {
+ // Prevent duplicate entries
+ if ( $.inArray( queue[q], batch ) === -1 ) {
+ batch[batch.length] = queue[q];
+ // Mark registered modules as loading
+ registry[queue[q]].state = 'loading';
+ }
+ }
+ }
+
+ mw.loader.store.init();
+ if ( mw.loader.store.enabled ) {
+ concatSource = [];
+ origBatch = batch;
+ batch = $.grep( batch, function ( module ) {
+ var source = mw.loader.store.get( module );
+ if ( source ) {
+ concatSource.push( source );
+ return false;
+ }
+ return true;
+ } );
+ try {
+ $.globalEval( concatSource.join( ';' ) );
+ } catch ( err ) {
+ // Not good, the cached mw.loader.implement calls failed! This should
+ // never happen, barring ResourceLoader bugs, browser bugs and PEBKACs.
+ // Depending on how corrupt the string is, it is likely that some
+ // modules' implement() succeeded while the ones after the error will
+ // never run and leave their modules in the 'loading' state forever.
+
+ // Since this is an error not caused by an individual module but by
+ // something that infected the implement call itself, don't take any
+ // risks and clear everything in this cache.
+ mw.loader.store.clear();
+ // Re-add the ones still pending back to the batch and let the server
+ // repopulate these modules to the cache.
+ // This means that at most one module will be useless (the one that had
+ // the error) instead of all of them.
+ log( 'Error while evaluating data from mw.loader.store', err );
+ origBatch = $.grep( origBatch, function ( module ) {
+ return registry[module].state === 'loading';
+ } );
+ batch = batch.concat( origBatch );
+ }
+ }
+
+ // Early exit if there's nothing to load...
+ if ( !batch.length ) {
+ return;
+ }
+
+ // The queue has been processed into the batch, clear up the queue.
+ queue = [];
+
+ // Always order modules alphabetically to help reduce cache
+ // misses for otherwise identical content.
+ batch.sort();
+
+ // Split batch by source and by group.
+ for ( b = 0; b < batch.length; b += 1 ) {
+ bSource = registry[batch[b]].source;
+ bGroup = registry[batch[b]].group;
+ if ( splits[bSource] === undefined ) {
+ splits[bSource] = {};
+ }
+ if ( splits[bSource][bGroup] === undefined ) {
+ splits[bSource][bGroup] = [];
+ }
+ bSourceGroup = splits[bSource][bGroup];
+ bSourceGroup[bSourceGroup.length] = batch[b];
+ }
+
+ // Clear the batch - this MUST happen before we append any
+ // script elements to the body or it's possible that a script
+ // will be locally cached, instantly load, and work the batch
+ // again, all before we've cleared it causing each request to
+ // include modules which are already loaded.
+ batch = [];
+
+ for ( source in splits ) {
+
+ sourceLoadScript = sources[source];
+
+ for ( group in splits[source] ) {
+
+ // Cache access to currently selected list of
+ // modules for this group from this source.
+ modules = splits[source][group];
+
+ // Calculate the highest timestamp
+ maxVersion = 0;
+ for ( g = 0; g < modules.length; g += 1 ) {
+ if ( registry[modules[g]].version > maxVersion ) {
+ maxVersion = registry[modules[g]].version;
+ }
+ }
+
+ currReqBase = $.extend( { version: formatVersionNumber( maxVersion ) }, reqBase );
+ // For user modules append a user name to the request.
+ if ( group === 'user' && mw.config.get( 'wgUserName' ) !== null ) {
+ currReqBase.user = mw.config.get( 'wgUserName' );
+ }
+ currReqBaseLength = $.param( currReqBase ).length;
+ async = true;
+ // We may need to split up the request to honor the query string length limit,
+ // so build it piece by piece.
+ l = currReqBaseLength + 9; // '&modules='.length == 9
+
+ moduleMap = {}; // { prefix: [ suffixes ] }
+
+ for ( i = 0; i < modules.length; i += 1 ) {
+ // Determine how many bytes this module would add to the query string
+ lastDotIndex = modules[i].lastIndexOf( '.' );
+
+ // If lastDotIndex is -1, substr() returns an empty string
+ prefix = modules[i].substr( 0, lastDotIndex );
+ suffix = modules[i].slice( lastDotIndex + 1 );
+
+ bytesAdded = moduleMap[prefix] !== undefined
+ ? suffix.length + 3 // '%2C'.length == 3
+ : modules[i].length + 3; // '%7C'.length == 3
+
+ // If the request would become too long, create a new one,
+ // but don't create empty requests
+ if ( maxQueryLength > 0 && !$.isEmptyObject( moduleMap ) && l + bytesAdded > maxQueryLength ) {
+ // This request would become too long, create a new one
+ // and fire off the old one
+ doRequest( moduleMap, currReqBase, sourceLoadScript, async );
+ moduleMap = {};
+ async = true;
+ l = currReqBaseLength + 9;
+ }
+ if ( moduleMap[prefix] === undefined ) {
+ moduleMap[prefix] = [];
+ }
+ moduleMap[prefix].push( suffix );
+ if ( !registry[modules[i]].async ) {
+ // If this module is blocking, make the entire request blocking
+ // This is slightly suboptimal, but in practice mixing of blocking
+ // and async modules will only occur in debug mode.
+ async = false;
+ }
+ l += bytesAdded;
+ }
+ // If there's anything left in moduleMap, request that too
+ if ( !$.isEmptyObject( moduleMap ) ) {
+ doRequest( moduleMap, currReqBase, sourceLoadScript, async );
+ }
+ }
+ }
+ },
+
+ /**
+ * Register a source.
+ *
+ * The #work method will use this information to split up requests by source.
+ *
+ * mw.loader.addSource( 'mediawikiwiki', '//www.mediawiki.org/w/load.php' );
+ *
+ * @param {string} id Short string representing a source wiki, used internally for
+ * registered modules to indicate where they should be loaded from (usually lowercase a-z).
+ * @param {Object|string} loadUrl load.php url, may be an object for backwards-compatibility
+ * @return {boolean}
+ */
+ addSource: function ( id, loadUrl ) {
+ var source;
+ // Allow multiple additions
+ if ( typeof id === 'object' ) {
+ for ( source in id ) {
+ mw.loader.addSource( source, id[source] );
+ }
+ return true;
+ }
+
+ if ( sources[id] !== undefined ) {
+ throw new Error( 'source already registered: ' + id );
+ }
+
+ if ( typeof loadUrl === 'object' ) {
+ loadUrl = loadUrl.loadScript;
+ }
+
+ sources[id] = loadUrl;
+
+ return true;
+ },
+
+ /**
+ * Register a module, letting the system know about it and its
+ * properties. Startup modules contain calls to this function.
+ *
+ * @param {string} module Module name
+ * @param {number} version Module version number as a timestamp (falls backs to 0)
+ * @param {string|Array|Function} dependencies One string or array of strings of module
+ * names on which this module depends, or a function that returns that array.
+ * @param {string} [group=null] Group which the module is in
+ * @param {string} [source='local'] Name of the source
+ * @param {string} [skip=null] Script body of the skip function
+ */
+ register: function ( module, version, dependencies, group, source, skip ) {
+ var m;
+ // Allow multiple registration
+ if ( typeof module === 'object' ) {
+ for ( m = 0; m < module.length; m += 1 ) {
+ // module is an array of module names
+ if ( typeof module[m] === 'string' ) {
+ mw.loader.register( module[m] );
+ // module is an array of arrays
+ } else if ( typeof module[m] === 'object' ) {
+ mw.loader.register.apply( mw.loader, module[m] );
+ }
+ }
+ return;
+ }
+ // Validate input
+ if ( typeof module !== 'string' ) {
+ throw new Error( 'module must be a string, not a ' + typeof module );
+ }
+ if ( registry[module] !== undefined ) {
+ throw new Error( 'module already registered: ' + module );
+ }
+ // List the module as registered
+ registry[module] = {
+ version: version !== undefined ? parseInt( version, 10 ) : 0,
+ dependencies: [],
+ group: typeof group === 'string' ? group : null,
+ source: typeof source === 'string' ? source : 'local',
+ state: 'registered',
+ skip: typeof skip === 'string' ? skip : null
+ };
+ if ( typeof dependencies === 'string' ) {
+ // Allow dependencies to be given as a single module name
+ registry[module].dependencies = [ dependencies ];
+ } else if ( typeof dependencies === 'object' || $.isFunction( dependencies ) ) {
+ // Allow dependencies to be given as an array of module names
+ // or a function which returns an array
+ registry[module].dependencies = dependencies;
+ }
+ },
+
+ /**
+ * Implement a module given the components that make up the module.
+ *
+ * When #load or #using requests one or more modules, the server
+ * response contain calls to this function.
+ *
+ * All arguments are required.
+ *
+ * @param {string} module Name of module
+ * @param {Function|Array} script Function with module code or Array of URLs to
+ * be used as the src attribute of a new `<script>` tag.
+ * @param {Object} style Should follow one of the following patterns:
+ *
+ * { "css": [css, ..] }
+ * { "url": { <media>: [url, ..] } }
+ *
+ * And for backwards compatibility (needs to be supported forever due to caching):
+ *
+ * { <media>: css }
+ * { <media>: [url, ..] }
+ *
+ * The reason css strings are not concatenated anymore is bug 31676. We now check
+ * whether it's safe to extend the stylesheet (see #canExpandStylesheetWith).
+ *
+ * @param {Object} msgs List of key/value pairs to be added to mw#messages.
+ */
+ implement: function ( module, script, style, msgs ) {
+ // Validate input
+ if ( typeof module !== 'string' ) {
+ throw new Error( 'module must be a string, not a ' + typeof module );
+ }
+ if ( !$.isFunction( script ) && !$.isArray( script ) ) {
+ throw new Error( 'script must be a function or an array, not a ' + typeof script );
+ }
+ if ( !$.isPlainObject( style ) ) {
+ throw new Error( 'style must be an object, not a ' + typeof style );
+ }
+ if ( !$.isPlainObject( msgs ) ) {
+ throw new Error( 'msgs must be an object, not a ' + typeof msgs );
+ }
+ // Automatically register module
+ if ( registry[module] === undefined ) {
+ mw.loader.register( module );
+ }
+ // Check for duplicate implementation
+ if ( registry[module] !== undefined && registry[module].script !== undefined ) {
+ throw new Error( 'module already implemented: ' + module );
+ }
+ // Attach components
+ registry[module].script = script;
+ registry[module].style = style;
+ registry[module].messages = msgs;
+ // The module may already have been marked as erroneous
+ if ( $.inArray( registry[module].state, ['error', 'missing'] ) === -1 ) {
+ registry[module].state = 'loaded';
+ if ( allReady( registry[module].dependencies ) ) {
+ execute( module );
+ }
+ }
+ },
+
+ /**
+ * Execute a function as soon as one or more required modules are ready.
+ *
+ * Example of inline dependency on OOjs:
+ *
+ * mw.loader.using( 'oojs', function () {
+ * OO.compare( [ 1 ], [ 1 ] );
+ * } );
+ *
+ * @param {string|Array} dependencies Module name or array of modules names the callback
+ * dependends on to be ready before executing
+ * @param {Function} [ready] Callback to execute when all dependencies are ready
+ * @param {Function} [error] Callback to execute if one or more dependencies failed
+ * @return {jQuery.Promise}
+ */
+ using: function ( dependencies, ready, error ) {
+ var deferred = $.Deferred();
+
+ // Allow calling with a single dependency as a string
+ if ( typeof dependencies === 'string' ) {
+ dependencies = [ dependencies ];
+ } else if ( !$.isArray( dependencies ) ) {
+ // Invalid input
+ throw new Error( 'Dependencies must be a string or an array' );
+ }
+
+ if ( ready ) {
+ deferred.done( ready );
+ }
+ if ( error ) {
+ deferred.fail( error );
+ }
+
+ // Resolve entire dependency map
+ dependencies = resolve( dependencies );
+ if ( allReady( dependencies ) ) {
+ // Run ready immediately
+ deferred.resolve();
+ } else if ( filter( ['error', 'missing'], dependencies ).length ) {
+ // Execute error immediately if any dependencies have errors
+ deferred.reject(
+ new Error( 'One or more dependencies failed to load' ),
+ dependencies
+ );
+ } else {
+ // Not all dependencies are ready: queue up a request
+ request( dependencies, deferred.resolve, deferred.reject );
+ }
+
+ return deferred.promise();
+ },
+
+ /**
+ * Load an external script or one or more modules.
+ *
+ * @param {string|Array} modules Either the name of a module, array of modules,
+ * or a URL of an external script or style
+ * @param {string} [type='text/javascript'] MIME type to use if calling with a URL of an
+ * external script or style; acceptable values are "text/css" and
+ * "text/javascript"; if no type is provided, text/javascript is assumed.
+ * @param {boolean} [async] Whether to load modules asynchronously.
+ * Ignored (and defaulted to `true`) if the document-ready event has already occurred.
+ * Defaults to `true` if loading a URL, `false` otherwise.
+ */
+ load: function ( modules, type, async ) {
+ var filtered, m, module, l;
+
+ // Validate input
+ if ( typeof modules !== 'object' && typeof modules !== 'string' ) {
+ throw new Error( 'modules must be a string or an array, not a ' + typeof modules );
+ }
+ // Allow calling with an external url or single dependency as a string
+ if ( typeof modules === 'string' ) {
+ // Support adding arbitrary external scripts
+ if ( /^(https?:)?\/\//.test( modules ) ) {
+ if ( async === undefined ) {
+ // Assume async for bug 34542
+ async = true;
+ }
+ if ( type === 'text/css' ) {
+ // IE7-8 throws security warnings when inserting a <link> tag
+ // with a protocol-relative URL set though attributes (instead of
+ // properties) - when on HTTPS. See also bug 41331.
+ l = document.createElement( 'link' );
+ l.rel = 'stylesheet';
+ l.href = modules;
+ $( 'head' ).append( l );
+ return;
+ }
+ if ( type === 'text/javascript' || type === undefined ) {
+ addScript( modules, null, async );
+ return;
+ }
+ // Unknown type
+ throw new Error( 'invalid type for external url, must be text/css or text/javascript. not ' + type );
+ }
+ // Called with single module
+ modules = [ modules ];
+ }
+
+ // Filter out undefined modules, otherwise resolve() will throw
+ // an exception for trying to load an undefined module.
+ // Undefined modules are acceptable here in load(), because load() takes
+ // an array of unrelated modules, whereas the modules passed to
+ // using() are related and must all be loaded.
+ for ( filtered = [], m = 0; m < modules.length; m += 1 ) {
+ module = registry[modules[m]];
+ if ( module !== undefined ) {
+ if ( $.inArray( module.state, ['error', 'missing'] ) === -1 ) {
+ filtered[filtered.length] = modules[m];
+ }
+ }
+ }
+
+ if ( filtered.length === 0 ) {
+ return;
+ }
+ // Resolve entire dependency map
+ filtered = resolve( filtered );
+ // If all modules are ready, nothing to be done
+ if ( allReady( filtered ) ) {
+ return;
+ }
+ // If any modules have errors: also quit.
+ if ( filter( ['error', 'missing'], filtered ).length ) {
+ return;
+ }
+ // Since some modules are not yet ready, queue up a request.
+ request( filtered, undefined, undefined, async );
+ },
+
+ /**
+ * Change the state of one or more modules.
+ *
+ * @param {string|Object} module Module name or object of module name/state pairs
+ * @param {string} state State name
+ */
+ state: function ( module, state ) {
+ var m;
+
+ if ( typeof module === 'object' ) {
+ for ( m in module ) {
+ mw.loader.state( m, module[m] );
+ }
+ return;
+ }
+ if ( registry[module] === undefined ) {
+ mw.loader.register( module );
+ }
+ if ( $.inArray( state, ['ready', 'error', 'missing'] ) !== -1
+ && registry[module].state !== state ) {
+ // Make sure pending modules depending on this one get executed if their
+ // dependencies are now fulfilled!
+ registry[module].state = state;
+ handlePending( module );
+ } else {
+ registry[module].state = state;
+ }
+ },
+
+ /**
+ * Get the version of a module.
+ *
+ * @param {string} module Name of module to get version for
+ * @return {string|null} The version, or null if the module (or its version) is not
+ * in the registry.
+ */
+ getVersion: function ( module ) {
+ if ( registry[module] !== undefined && registry[module].version !== undefined ) {
+ return formatVersionNumber( registry[module].version );
+ }
+ return null;
+ },
+
+ /**
+ * Get the state of a module.
+ *
+ * @param {string} module Name of module to get state for
+ */
+ getState: function ( module ) {
+ if ( registry[module] !== undefined && registry[module].state !== undefined ) {
+ return registry[module].state;
+ }
+ return null;
+ },
+
+ /**
+ * Get the names of all registered modules.
+ *
+ * @return {Array}
+ */
+ getModuleNames: function () {
+ return $.map( registry, function ( i, key ) {
+ return key;
+ } );
+ },
+
+ /**
+ * @inheritdoc mw.inspect#runReports
+ * @method
+ */
+ inspect: function () {
+ var args = slice.call( arguments );
+ mw.loader.using( 'mediawiki.inspect', function () {
+ mw.inspect.runReports.apply( mw.inspect, args );
+ } );
+ },
+
+ /**
+ * On browsers that implement the localStorage API, the module store serves as a
+ * smart complement to the browser cache. Unlike the browser cache, the module store
+ * can slice a concatenated response from ResourceLoader into its constituent
+ * modules and cache each of them separately, using each module's versioning scheme
+ * to determine when the cache should be invalidated.
+ *
+ * @singleton
+ * @class mw.loader.store
+ */
+ store: {
+ // Whether the store is in use on this page.
+ enabled: null,
+
+ // The contents of the store, mapping '[module name]@[version]' keys
+ // to module implementations.
+ items: {},
+
+ // Cache hit stats
+ stats: { hits: 0, misses: 0, expired: 0 },
+
+ /**
+ * Construct a JSON-serializable object representing the content of the store.
+ * @return {Object} Module store contents.
+ */
+ toJSON: function () {
+ return { items: mw.loader.store.items, vary: mw.loader.store.getVary() };
+ },
+
+ /**
+ * Get the localStorage key for the entire module store. The key references
+ * $wgDBname to prevent clashes between wikis which share a common host.
+ *
+ * @return {string} localStorage item key
+ */
+ getStoreKey: function () {
+ return 'MediaWikiModuleStore:' + mw.config.get( 'wgDBname' );
+ },
+
+ /**
+ * Get a string key on which to vary the module cache.
+ * @return {string} String of concatenated vary conditions.
+ */
+ getVary: function () {
+ return [
+ mw.config.get( 'skin' ),
+ mw.config.get( 'wgResourceLoaderStorageVersion' ),
+ mw.config.get( 'wgUserLanguage' )
+ ].join( ':' );
+ },
+
+ /**
+ * Get a string key for a specific module. The key format is '[name]@[version]'.
+ *
+ * @param {string} module Module name
+ * @return {string|null} Module key or null if module does not exist
+ */
+ getModuleKey: function ( module ) {
+ return typeof registry[module] === 'object' ?
+ ( module + '@' + registry[module].version ) : null;
+ },
+
+ /**
+ * Initialize the store.
+ *
+ * Retrieves store from localStorage and (if successfully retrieved) decoding
+ * the stored JSON value to a plain object.
+ *
+ * The try / catch block is used for JSON & localStorage feature detection.
+ * See the in-line documentation for Modernizr's localStorage feature detection
+ * code for a full account of why we need a try / catch:
+ * <https://github.com/Modernizr/Modernizr/blob/v2.7.1/modernizr.js#L771-L796>.
+ */
+ init: function () {
+ var raw, data;
+
+ if ( mw.loader.store.enabled !== null ) {
+ // Init already ran
+ return;
+ }
+
+ if ( !mw.config.get( 'wgResourceLoaderStorageEnabled' ) || mw.config.get( 'debug' ) ) {
+ // Disabled by configuration, or because debug mode is set
+ mw.loader.store.enabled = false;
+ return;
+ }
+
+ try {
+ raw = localStorage.getItem( mw.loader.store.getStoreKey() );
+ // If we get here, localStorage is available; mark enabled
+ mw.loader.store.enabled = true;
+ data = JSON.parse( raw );
+ if ( data && typeof data.items === 'object' && data.vary === mw.loader.store.getVary() ) {
+ mw.loader.store.items = data.items;
+ return;
+ }
+ } catch ( e ) {
+ log( 'Storage error', e );
+ }
+
+ if ( raw === undefined ) {
+ // localStorage failed; disable store
+ mw.loader.store.enabled = false;
+ } else {
+ mw.loader.store.update();
+ }
+ },
+
+ /**
+ * Retrieve a module from the store and update cache hit stats.
+ *
+ * @param {string} module Module name
+ * @return {string|boolean} Module implementation or false if unavailable
+ */
+ get: function ( module ) {
+ var key;
+
+ if ( !mw.loader.store.enabled ) {
+ return false;
+ }
+
+ key = mw.loader.store.getModuleKey( module );
+ if ( key in mw.loader.store.items ) {
+ mw.loader.store.stats.hits++;
+ return mw.loader.store.items[key];
+ }
+ mw.loader.store.stats.misses++;
+ return false;
+ },
+
+ /**
+ * Stringify a module and queue it for storage.
+ *
+ * @param {string} module Module name
+ * @param {Object} descriptor The module's descriptor as set in the registry
+ */
+ set: function ( module, descriptor ) {
+ var args, key;
+
+ if ( !mw.loader.store.enabled ) {
+ return false;
+ }
+
+ key = mw.loader.store.getModuleKey( module );
+
+ if (
+ // Already stored a copy of this exact version
+ key in mw.loader.store.items ||
+ // Module failed to load
+ descriptor.state !== 'ready' ||
+ // Unversioned, private, or site-/user-specific
+ ( !descriptor.version || $.inArray( descriptor.group, [ 'private', 'user', 'site' ] ) !== -1 ) ||
+ // Partial descriptor
+ $.inArray( undefined, [ descriptor.script, descriptor.style, descriptor.messages ] ) !== -1
+ ) {
+ // Decline to store
+ return false;
+ }
+
+ try {
+ args = [
+ JSON.stringify( module ),
+ typeof descriptor.script === 'function' ?
+ String( descriptor.script ) :
+ JSON.stringify( descriptor.script ),
+ JSON.stringify( descriptor.style ),
+ JSON.stringify( descriptor.messages )
+ ];
+ // Attempted workaround for a possible Opera bug (bug 57567).
+ // This regex should never match under sane conditions.
+ if ( /^\s*\(/.test( args[1] ) ) {
+ args[1] = 'function' + args[1];
+ log( 'Detected malformed function stringification (bug 57567)' );
+ }
+ } catch ( e ) {
+ log( 'Storage error', e );
+ return;
+ }
+
+ mw.loader.store.items[key] = 'mw.loader.implement(' + args.join( ',' ) + ');';
+ mw.loader.store.update();
+ },
+
+ /**
+ * Iterate through the module store, removing any item that does not correspond
+ * (in name and version) to an item in the module registry.
+ */
+ prune: function () {
+ var key, module;
+
+ if ( !mw.loader.store.enabled ) {
+ return false;
+ }
+
+ for ( key in mw.loader.store.items ) {
+ module = key.slice( 0, key.indexOf( '@' ) );
+ if ( mw.loader.store.getModuleKey( module ) !== key ) {
+ mw.loader.store.stats.expired++;
+ delete mw.loader.store.items[key];
+ }
+ }
+ },
+
+ /**
+ * Clear the entire module store right now.
+ */
+ clear: function () {
+ mw.loader.store.items = {};
+ localStorage.removeItem( mw.loader.store.getStoreKey() );
+ },
+
+ /**
+ * Sync modules to localStorage.
+ *
+ * This function debounces localStorage updates. When called multiple times in
+ * quick succession, the calls are coalesced into a single update operation.
+ * This allows us to call #update without having to consider the module load
+ * queue; the call to localStorage.setItem will be naturally deferred until the
+ * page is quiescent.
+ *
+ * Because localStorage is shared by all pages with the same origin, if multiple
+ * pages are loaded with different module sets, the possibility exists that
+ * modules saved by one page will be clobbered by another. But the impact would
+ * be minor and the problem would be corrected by subsequent page views.
+ *
+ * @method
+ */
+ update: ( function () {
+ var timer;
+
+ function flush() {
+ var data,
+ key = mw.loader.store.getStoreKey();
+
+ if ( !mw.loader.store.enabled ) {
+ return false;
+ }
+ mw.loader.store.prune();
+ try {
+ // Replacing the content of the module store might fail if the new
+ // contents would exceed the browser's localStorage size limit. To
+ // avoid clogging the browser with stale data, always remove the old
+ // value before attempting to set the new one.
+ localStorage.removeItem( key );
+ data = JSON.stringify( mw.loader.store );
+ localStorage.setItem( key, data );
+ } catch ( e ) {
+ log( 'Storage error', e );
+ }
+ }
+
+ return function () {
+ clearTimeout( timer );
+ timer = setTimeout( flush, 2000 );
+ };
+ }() )
+ }
+ };
+ }() ),
+
+ /**
+ * HTML construction helper functions
+ *
+ * @example
+ *
+ * var Html, output;
+ *
+ * Html = mw.html;
+ * output = Html.element( 'div', {}, new Html.Raw(
+ * Html.element( 'img', { src: '<' } )
+ * ) );
+ * mw.log( output ); // <div><img src="&lt;"/></div>
+ *
+ * @class mw.html
+ * @singleton
+ */
+ html: ( function () {
+ function escapeCallback( s ) {
+ switch ( s ) {
+ case '\'':
+ return '&#039;';
+ case '"':
+ return '&quot;';
+ case '<':
+ return '&lt;';
+ case '>':
+ return '&gt;';
+ case '&':
+ return '&amp;';
+ }
+ }
+
+ return {
+ /**
+ * Escape a string for HTML.
+ *
+ * Converts special characters to HTML entities.
+ *
+ * mw.html.escape( '< > \' & "' );
+ * // Returns &lt; &gt; &#039; &amp; &quot;
+ *
+ * @param {string} s The string to escape
+ * @return {string} HTML
+ */
+ escape: function ( s ) {
+ return s.replace( /['"<>&]/g, escapeCallback );
+ },
+
+ /**
+ * Create an HTML element string, with safe escaping.
+ *
+ * @param {string} name The tag name.
+ * @param {Object} attrs An object with members mapping element names to values
+ * @param {Mixed} contents The contents of the element. May be either:
+ *
+ * - string: The string is escaped.
+ * - null or undefined: The short closing form is used, e.g. `<br/>`.
+ * - this.Raw: The value attribute is included without escaping.
+ * - this.Cdata: The value attribute is included, and an exception is
+ * thrown if it contains an illegal ETAGO delimiter.
+ * See <http://www.w3.org/TR/1999/REC-html401-19991224/appendix/notes.html#h-B.3.2>.
+ * @return {string} HTML
+ */
+ element: function ( name, attrs, contents ) {
+ var v, attrName, s = '<' + name;
+
+ for ( attrName in attrs ) {
+ v = attrs[attrName];
+ // Convert name=true, to name=name
+ if ( v === true ) {
+ v = attrName;
+ // Skip name=false
+ } else if ( v === false ) {
+ continue;
+ }
+ s += ' ' + attrName + '="' + this.escape( String( v ) ) + '"';
+ }
+ if ( contents === undefined || contents === null ) {
+ // Self close tag
+ s += '/>';
+ return s;
+ }
+ // Regular open tag
+ s += '>';
+ switch ( typeof contents ) {
+ case 'string':
+ // Escaped
+ s += this.escape( contents );
+ break;
+ case 'number':
+ case 'boolean':
+ // Convert to string
+ s += String( contents );
+ break;
+ default:
+ if ( contents instanceof this.Raw ) {
+ // Raw HTML inclusion
+ s += contents.value;
+ } else if ( contents instanceof this.Cdata ) {
+ // CDATA
+ if ( /<\/[a-zA-z]/.test( contents.value ) ) {
+ throw new Error( 'mw.html.element: Illegal end tag found in CDATA' );
+ }
+ s += contents.value;
+ } else {
+ throw new Error( 'mw.html.element: Invalid type of contents' );
+ }
+ }
+ s += '</' + name + '>';
+ return s;
+ },
+
+ /**
+ * Wrapper object for raw HTML passed to mw.html.element().
+ * @class mw.html.Raw
+ */
+ Raw: function ( value ) {
+ this.value = value;
+ },
+
+ /**
+ * Wrapper object for CDATA element contents passed to mw.html.element()
+ * @class mw.html.Cdata
+ */
+ Cdata: function ( value ) {
+ this.value = value;
+ }
+ };
+ }() ),
+
+ // Skeleton user object. mediawiki.user.js extends this
+ user: {
+ options: new Map(),
+ tokens: new Map()
+ },
+
+ /**
+ * Registry and firing of events.
+ *
+ * MediaWiki has various interface components that are extended, enhanced
+ * or manipulated in some other way by extensions, gadgets and even
+ * in core itself.
+ *
+ * This framework helps streamlining the timing of when these other
+ * code paths fire their plugins (instead of using document-ready,
+ * which can and should be limited to firing only once).
+ *
+ * Features like navigating to other wiki pages, previewing an edit
+ * and editing itself – without a refresh – can then retrigger these
+ * hooks accordingly to ensure everything still works as expected.
+ *
+ * Example usage:
+ *
+ * mw.hook( 'wikipage.content' ).add( fn ).remove( fn );
+ * mw.hook( 'wikipage.content' ).fire( $content );
+ *
+ * Handlers can be added and fired for arbitrary event names at any time. The same
+ * event can be fired multiple times. The last run of an event is memorized
+ * (similar to `$(document).ready` and `$.Deferred().done`).
+ * This means if an event is fired, and a handler added afterwards, the added
+ * function will be fired right away with the last given event data.
+ *
+ * Like Deferreds and Promises, the mw.hook object is both detachable and chainable.
+ * Thus allowing flexible use and optimal maintainability and authority control.
+ * You can pass around the `add` and/or `fire` method to another piece of code
+ * without it having to know the event name (or `mw.hook` for that matter).
+ *
+ * var h = mw.hook( 'bar.ready' );
+ * new mw.Foo( .. ).fetch( { callback: h.fire } );
+ *
+ * Note: Events are documented with an underscore instead of a dot in the event
+ * name due to jsduck not supporting dots in that position.
+ *
+ * @class mw.hook
+ */
+ hook: ( function () {
+ var lists = {};
+
+ /**
+ * Create an instance of mw.hook.
+ *
+ * @method hook
+ * @member mw
+ * @param {string} name Name of hook.
+ * @return {mw.hook}
+ */
+ return function ( name ) {
+ var list = hasOwn.call( lists, name ) ?
+ lists[name] :
+ lists[name] = $.Callbacks( 'memory' );
+
+ return {
+ /**
+ * Register a hook handler
+ * @param {Function...} handler Function to bind.
+ * @chainable
+ */
+ add: list.add,
+
+ /**
+ * Unregister a hook handler
+ * @param {Function...} handler Function to unbind.
+ * @chainable
+ */
+ remove: list.remove,
+
+ /**
+ * Run a hook.
+ * @param {Mixed...} data
+ * @chainable
+ */
+ fire: function () {
+ return list.fireWith.call( this, null, slice.call( arguments ) );
+ }
+ };
+ };
+ }() )
+ };
+
+ // Alias $j to jQuery for backwards compatibility
+ // @deprecated since 1.23 Use $ or jQuery instead
+ mw.log.deprecate( window, '$j', $, 'Use $ or jQuery instead.' );
+
+ // Attach to window and globally alias
+ window.mw = window.mediaWiki = mw;
+
+ // Auto-register from pre-loaded startup scripts
+ if ( $.isFunction( window.startUp ) ) {
+ window.startUp();
+ window.startUp = undefined;
+ }
+
+}( jQuery ) );
diff --git a/resources/src/mediawiki/mediawiki.log.js b/resources/src/mediawiki/mediawiki.log.js
new file mode 100644
index 00000000..ad68967a
--- /dev/null
+++ b/resources/src/mediawiki/mediawiki.log.js
@@ -0,0 +1,84 @@
+/*!
+ * Logger for MediaWiki javascript.
+ * Implements the stub left by the main 'mediawiki' module.
+ *
+ * @author Michael Dale <mdale@wikimedia.org>
+ * @author Trevor Parscal <tparscal@wikimedia.org>
+ */
+
+( function ( mw, $ ) {
+
+ // Reference to dummy
+ // We don't need the dummy, but it has other methods on it
+ // that we need to restore afterwards.
+ var original = mw.log,
+ slice = Array.prototype.slice;
+
+ /**
+ * Logs a message to the console in debug mode.
+ *
+ * In the case the browser does not have a console API, a console is created on-the-fly by appending
+ * a `<div id="mw-log-console">` element to the bottom of the body and then appending this and future
+ * messages to that, instead of the console.
+ *
+ * @member mw.log
+ * @param {string...} msg Messages to output to console.
+ */
+ mw.log = function () {
+ // Turn arguments into an array
+ var args = slice.call( arguments ),
+ // Allow log messages to use a configured prefix to identify the source window (ie. frame)
+ prefix = mw.config.exists( 'mw.log.prefix' ) ? mw.config.get( 'mw.log.prefix' ) + '> ' : '';
+
+ // Try to use an existing console
+ // Generally we can cache this, but in this case we want to re-evaluate this as a
+ // global property live so that things like Firebug Lite can take precedence.
+ if ( window.console && window.console.log && window.console.log.apply ) {
+ args.unshift( prefix );
+ window.console.log.apply( window.console, args );
+ return;
+ }
+
+ // If there is no console, use our own log box
+ mw.loader.using( 'jquery.footHovzer', function () {
+
+ var hovzer,
+ d = new Date(),
+ // Create HH:MM:SS.MIL timestamp
+ time = ( d.getHours() < 10 ? '0' + d.getHours() : d.getHours() ) +
+ ':' + ( d.getMinutes() < 10 ? '0' + d.getMinutes() : d.getMinutes() ) +
+ ':' + ( d.getSeconds() < 10 ? '0' + d.getSeconds() : d.getSeconds() ) +
+ '.' + ( d.getMilliseconds() < 10 ? '00' + d.getMilliseconds() : ( d.getMilliseconds() < 100 ? '0' + d.getMilliseconds() : d.getMilliseconds() ) ),
+ $log = $( '#mw-log-console' );
+
+ if ( !$log.length ) {
+ $log = $( '<div id="mw-log-console"></div>' ).css( {
+ overflow: 'auto',
+ height: '150px',
+ backgroundColor: 'white',
+ borderTop: 'solid 2px #ADADAD'
+ } );
+ hovzer = $.getFootHovzer();
+ hovzer.$.append( $log );
+ hovzer.update();
+ }
+ $log.append(
+ $( '<div>' )
+ .css( {
+ borderBottom: 'solid 1px #DDDDDD',
+ fontSize: 'small',
+ fontFamily: 'monospace',
+ whiteSpace: 'pre-wrap',
+ padding: '0.125em 0.25em'
+ } )
+ .text( prefix + args.join( ', ' ) )
+ .prepend( '<span style="float: right;">[' + time + ']</span>' )
+ );
+ } );
+ };
+
+ // Restore original methods
+ mw.log.warn = original.warn;
+ mw.log.deprecate = original.deprecate;
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki/mediawiki.notification.css b/resources/src/mediawiki/mediawiki.notification.css
new file mode 100644
index 00000000..ae399ce7
--- /dev/null
+++ b/resources/src/mediawiki/mediawiki.notification.css
@@ -0,0 +1,27 @@
+.mw-notification-area {
+ position: absolute;
+ top: 0;
+ right: 0;
+ padding: 1em 1em 0 0;
+ width: 20em;
+ line-height: 1.35;
+ z-index: 10000;
+}
+
+.mw-notification-area-floating {
+ position: fixed;
+}
+
+.mw-notification {
+ padding: 0.25em 1em;
+ margin-bottom: 0.5em;
+ border: solid 1px #ddd;
+ background-color: #fcfcfc;
+ /* Message hides on-click */
+ /* See also mediawiki.notification.js */
+ cursor: pointer;
+}
+
+.mw-notification-title {
+ font-weight: bold;
+}
diff --git a/resources/src/mediawiki/mediawiki.notification.hideForPrint.css b/resources/src/mediawiki/mediawiki.notification.hideForPrint.css
new file mode 100644
index 00000000..4f9162e2
--- /dev/null
+++ b/resources/src/mediawiki/mediawiki.notification.hideForPrint.css
@@ -0,0 +1,3 @@
+.mw-notification-area {
+ display: none;
+}
diff --git a/resources/src/mediawiki/mediawiki.notification.js b/resources/src/mediawiki/mediawiki.notification.js
new file mode 100644
index 00000000..1968aa94
--- /dev/null
+++ b/resources/src/mediawiki/mediawiki.notification.js
@@ -0,0 +1,523 @@
+( function ( mw, $ ) {
+ 'use strict';
+
+ var notification,
+ // The #mw-notification-area div that all notifications are contained inside.
+ $area,
+ // Number of open notification boxes at any time
+ openNotificationCount = 0,
+ isPageReady = false,
+ preReadyNotifQueue = [];
+
+ /**
+ * A Notification object for 1 message.
+ *
+ * The "_" in the name is to avoid a bug (http://github.com/senchalabs/jsduck/issues/304).
+ * It is not part of the actual class name.
+ *
+ * @class mw.Notification_
+ * @alternateClassName mw.Notification
+ *
+ * @constructor The constructor is not publicly accessible; use mw.notification#notify instead.
+ * This does not insert anything into the document (see #start).
+ * @private
+ */
+ function Notification( message, options ) {
+ var $notification, $notificationTitle, $notificationContent;
+
+ $notification = $( '<div class="mw-notification"></div>' )
+ .data( 'mw.notification', this )
+ .addClass( options.autoHide ? 'mw-notification-autohide' : 'mw-notification-noautohide' );
+
+ if ( options.tag ) {
+ // Sanitize options.tag before it is used by any code. (Including Notification class methods)
+ options.tag = options.tag.replace( /[ _\-]+/g, '-' ).replace( /[^\-a-z0-9]+/ig, '' );
+ if ( options.tag ) {
+ $notification.addClass( 'mw-notification-tag-' + options.tag );
+ } else {
+ delete options.tag;
+ }
+ }
+
+ if ( options.title ) {
+ $notificationTitle = $( '<div class="mw-notification-title"></div>' )
+ .text( options.title )
+ .appendTo( $notification );
+ }
+
+ $notificationContent = $( '<div class="mw-notification-content"></div>' );
+
+ if ( typeof message === 'object' ) {
+ // Handle mw.Message objects separately from DOM nodes and jQuery objects
+ if ( message instanceof mw.Message ) {
+ $notificationContent.html( message.parse() );
+ } else {
+ $notificationContent.append( message );
+ }
+ } else {
+ $notificationContent.text( message );
+ }
+
+ $notificationContent.appendTo( $notification );
+
+ // Private state parameters, meant for internal use only
+ // isOpen: Set to true after .start() is called to avoid double calls.
+ // Set back to false after .close() to avoid duplicating the close animation.
+ // isPaused: false after .resume(), true after .pause(). Avoids duplicating or breaking the hide timeouts.
+ // Set to true initially so .start() can call .resume().
+ // message: The message passed to the notification. Unused now but may be used in the future
+ // to stop replacement of a tagged notification with another notification using the same message.
+ // options: The options passed to the notification with a little sanitization. Used by various methods.
+ // $notification: jQuery object containing the notification DOM node.
+ this.isOpen = false;
+ this.isPaused = true;
+ this.message = message;
+ this.options = options;
+ this.$notification = $notification;
+ }
+
+ /**
+ * Start the notification. Called automatically by mw.notification#notify
+ * (possibly asynchronously on document-ready).
+ *
+ * This inserts the notification into the page, closes any matching tagged notifications,
+ * handles the fadeIn animations and replacement transitions, and starts autoHide timers.
+ *
+ * @private
+ */
+ Notification.prototype.start = function () {
+ var
+ // Local references
+ $notification, options,
+ // Original opacity so that we can animate back to it later
+ opacity,
+ // Other notification elements matching the same tag
+ $tagMatches,
+ outerHeight,
+ placeholderHeight,
+ autohideCount,
+ notif;
+
+ $area.show();
+
+ if ( this.isOpen ) {
+ return;
+ }
+
+ this.isOpen = true;
+ openNotificationCount++;
+
+ options = this.options;
+ $notification = this.$notification;
+
+ opacity = this.$notification.css( 'opacity' );
+
+ // Set the opacity to 0 so we can fade in later.
+ $notification.css( 'opacity', 0 );
+
+ if ( options.tag ) {
+ // Check to see if there are any tagged notifications with the same tag as the new one
+ $tagMatches = $area.find( '.mw-notification-tag-' + options.tag );
+ }
+
+ // If we found a tagged notification use the replacement pattern instead of the new
+ // notification fade-in pattern.
+ if ( options.tag && $tagMatches.length ) {
+
+ // Iterate over the tag matches to find the outerHeight we should use
+ // for the placeholder.
+ outerHeight = 0;
+ $tagMatches.each( function () {
+ var notif = $( this ).data( 'mw.notification' );
+ if ( notif ) {
+ // Use the notification's height + padding + border + margins
+ // as the placeholder height.
+ outerHeight = notif.$notification.outerHeight( true );
+ if ( notif.$replacementPlaceholder ) {
+ // Grab the height of a placeholder that has not finished animating.
+ placeholderHeight = notif.$replacementPlaceholder.height();
+ // Remove any placeholders added by a previous tagged
+ // notification that was in the middle of replacing another.
+ // This also makes sure that we only grab the placeholderHeight
+ // for the most recent notification.
+ notif.$replacementPlaceholder.remove();
+ delete notif.$replacementPlaceholder;
+ }
+ // Close the previous tagged notification
+ // Since we're replacing it do this with a fast speed and don't output a placeholder
+ // since we're taking care of that transition ourselves.
+ notif.close( { speed: 'fast', placeholder: false } );
+ }
+ } );
+ if ( placeholderHeight !== undefined ) {
+ // If the other tagged notification was in the middle of replacing another
+ // tagged notification, continue from the placeholder's height instead of
+ // using the outerHeight of the notification.
+ outerHeight = placeholderHeight;
+ }
+
+ $notification
+ // Insert the new notification before the tagged notification(s)
+ .insertBefore( $tagMatches.first() )
+ .css( {
+ // Use an absolute position so that we can use a placeholder to gracefully push other notifications
+ // into the right spot.
+ position: 'absolute',
+ width: $notification.width()
+ } )
+ // Fade-in the notification
+ .animate( { opacity: opacity },
+ {
+ duration: 'slow',
+ complete: function () {
+ // After we've faded in clear the opacity and let css take over
+ $( this ).css( { opacity: '' } );
+ }
+ } );
+
+ notif = this;
+
+ // Create a clear placeholder we can use to make the notifications around the notification that is being
+ // replaced expand or contract gracefully to fit the height of the new notification.
+ notif.$replacementPlaceholder = $( '<div>' )
+ // Set the height to the space the previous notification or placeholder took
+ .css( 'height', outerHeight )
+ // Make sure that this placeholder is at the very end of this tagged notification group
+ .insertAfter( $tagMatches.eq( -1 ) )
+ // Animate the placeholder height to the space that this new notification will take up
+ .animate( { height: $notification.outerHeight( true ) },
+ {
+ // Do space animations fast
+ speed: 'fast',
+ complete: function () {
+ // Reset the notification position after we've finished the space animation
+ // However do not do it if the placeholder was removed because another tagged
+ // notification went and closed this one.
+ if ( notif.$replacementPlaceholder ) {
+ $notification.css( 'position', '' );
+ }
+ // Finally, remove the placeholder from the DOM
+ $( this ).remove();
+ }
+ } );
+ } else {
+ // Append to the notification area and fade in to the original opacity.
+ $notification
+ .appendTo( $area )
+ .animate( { opacity: opacity },
+ {
+ duration: 'fast',
+ complete: function () {
+ // After we've faded in clear the opacity and let css take over
+ $( this ).css( 'opacity', '' );
+ }
+ }
+ );
+ }
+
+ // By default a notification is paused.
+ // If this notification is within the first {autoHideLimit} notifications then
+ // start the auto-hide timer as soon as it's created.
+ autohideCount = $area.find( '.mw-notification-autohide' ).length;
+ if ( autohideCount <= notification.autoHideLimit ) {
+ this.resume();
+ }
+ };
+
+ /**
+ * Pause any running auto-hide timer for this notification
+ */
+ Notification.prototype.pause = function () {
+ if ( this.isPaused ) {
+ return;
+ }
+ this.isPaused = true;
+
+ if ( this.timeout ) {
+ clearTimeout( this.timeout );
+ delete this.timeout;
+ }
+ };
+
+ /**
+ * Start autoHide timer if not already started.
+ * Does nothing if autoHide is disabled.
+ * Either to resume from pause or to make the first start.
+ */
+ Notification.prototype.resume = function () {
+ var notif = this;
+ if ( !notif.isPaused ) {
+ return;
+ }
+ // Start any autoHide timeouts
+ if ( notif.options.autoHide ) {
+ notif.isPaused = false;
+ notif.timeout = setTimeout( function () {
+ // Already finished, so don't try to re-clear it
+ delete notif.timeout;
+ notif.close();
+ }, notification.autoHideSeconds * 1000 );
+ }
+ };
+
+ /**
+ * Close/hide the notification.
+ *
+ * @param {Object} options An object containing options for the closing of the notification.
+ *
+ * - speed: Use a close speed different than the default 'slow'.
+ * - placeholder: Set to false to disable the placeholder transition.
+ */
+ Notification.prototype.close = function ( options ) {
+ if ( !this.isOpen ) {
+ return;
+ }
+ this.isOpen = false;
+ openNotificationCount--;
+ // Clear any remaining timeout on close
+ this.pause();
+
+ options = $.extend( {
+ speed: 'slow',
+ placeholder: true
+ }, options );
+
+ // Remove the mw-notification-autohide class from the notification to avoid
+ // having a half-closed notification counted as a notification to resume
+ // when handling {autoHideLimit}.
+ this.$notification.removeClass( 'mw-notification-autohide' );
+
+ // Now that a notification is being closed. Start auto-hide timers for any
+ // notification that has now become one of the first {autoHideLimit} notifications.
+ notification.resume();
+
+ this.$notification
+ .css( {
+ // Don't trigger any mouse events while fading out, just in case the cursor
+ // happens to be right above us when we transition upwards.
+ pointerEvents: 'none',
+ // Set an absolute position so we can move upwards in the animation.
+ // Notification replacement doesn't look right unless we use an animation like this.
+ position: 'absolute',
+ // We must fix the width to avoid it shrinking horizontally.
+ width: this.$notification.width()
+ } )
+ // Fix the top/left position to the current computed position from which we
+ // can animate upwards.
+ .css( this.$notification.position() );
+
+ // This needs to be done *after* notification's position has been made absolute.
+ if ( options.placeholder ) {
+ // Insert a placeholder with a height equal to the height of the
+ // notification plus it's vertical margins in place of the notification
+ var $placeholder = $( '<div>' )
+ .css( 'height', this.$notification.outerHeight( true ) )
+ .insertBefore( this.$notification );
+ }
+
+ // Animate opacity and top to create fade upwards animation for notification closing
+ this.$notification
+ .animate( {
+ opacity: 0,
+ top: '-=35'
+ }, {
+ duration: options.speed,
+ complete: function () {
+ // Remove the notification
+ $( this ).remove();
+ // Hide the area manually after closing the last notification, since it has padding,
+ // causing it to obscure whatever is behind it in spite of being invisible (bug 52659).
+ // It's okay to do this before getting rid of the placeholder, as it's invisible as well.
+ if ( openNotificationCount === 0 ) {
+ $area.hide();
+ }
+ if ( options.placeholder ) {
+ // Use a fast slide up animation after closing to make it look like the notifications
+ // below slide up into place when the notification disappears
+ $placeholder.slideUp( 'fast', function () {
+ // Remove the placeholder
+ $( this ).remove();
+ } );
+ }
+ }
+ } );
+ };
+
+ /**
+ * Helper function, take a list of notification divs and call
+ * a function on the Notification instance attached to them.
+ *
+ * @private
+ * @static
+ * @param {jQuery} $notifications A jQuery object containing notification divs
+ * @param {string} fn The name of the function to call on the Notification instance
+ */
+ function callEachNotification( $notifications, fn ) {
+ $notifications.each( function () {
+ var notif = $( this ).data( 'mw.notification' );
+ if ( notif ) {
+ notif[fn]();
+ }
+ } );
+ }
+
+ /**
+ * Initialisation.
+ * Must only be called once, and not before the document is ready.
+ * @ignore
+ */
+ function init() {
+ var offset, $window = $( window );
+
+ $area = $( '<div id="mw-notification-area" class="mw-notification-area mw-notification-area-layout"></div>' )
+ // Pause auto-hide timers when the mouse is in the notification area.
+ .on( {
+ mouseenter: notification.pause,
+ mouseleave: notification.resume
+ } )
+ // When clicking on a notification close it.
+ .on( 'click', '.mw-notification', function () {
+ var notif = $( this ).data( 'mw.notification' );
+ if ( notif ) {
+ notif.close();
+ }
+ } )
+ // Stop click events from <a> tags from propogating to prevent clicking.
+ // on links from hiding a notification.
+ .on( 'click', 'a', function ( e ) {
+ e.stopPropagation();
+ } )
+ .hide();
+
+ // Prepend the notification area to the content area and save it's object.
+ mw.util.$content.prepend( $area );
+ offset = $area.offset();
+
+ function updateAreaMode() {
+ var isFloating = $window.scrollTop() > offset.top;
+ $area
+ .toggleClass( 'mw-notification-area-floating', isFloating )
+ .toggleClass( 'mw-notification-area-layout', !isFloating );
+ }
+
+ $window.on( 'scroll', updateAreaMode );
+
+ // Initial mode
+ updateAreaMode();
+ }
+
+ /**
+ * @class mw.notification
+ * @singleton
+ */
+ notification = {
+ /**
+ * Pause auto-hide timers for all notifications.
+ * Notifications will not auto-hide until resume is called.
+ * @see mw.Notification#pause
+ */
+ pause: function () {
+ callEachNotification(
+ $area.children( '.mw-notification' ),
+ 'pause'
+ );
+ },
+
+ /**
+ * Resume any paused auto-hide timers from the beginning.
+ * Only the first #autoHideLimit timers will be resumed.
+ */
+ resume: function () {
+ callEachNotification(
+ // Only call resume on the first #autoHideLimit notifications.
+ // Exclude noautohide notifications to avoid bugs where #autoHideLimit
+ // `{ autoHide: false }` notifications are at the start preventing any
+ // auto-hide notifications from being autohidden.
+ $area.children( '.mw-notification-autohide' ).slice( 0, notification.autoHideLimit ),
+ 'resume'
+ );
+ },
+
+ /**
+ * Display a notification message to the user.
+ *
+ * @param {HTMLElement|HTMLElement[]|jQuery|mw.Message|string} message
+ * @param {Object} options The options to use for the notification.
+ * See #defaults for details.
+ * @return {mw.Notification} Notification object
+ */
+ notify: function ( message, options ) {
+ var notif;
+ options = $.extend( {}, notification.defaults, options );
+
+ notif = new Notification( message, options );
+
+ if ( isPageReady ) {
+ notif.start();
+ } else {
+ preReadyNotifQueue.push( notif );
+ }
+
+ return notif;
+ },
+
+ /**
+ * @property {Object}
+ * The defaults for #notify options parameter.
+ *
+ * - autoHide:
+ * A boolean indicating whether the notifification should automatically
+ * be hidden after shown. Or if it should persist.
+ *
+ * - tag:
+ * An optional string. When a notification is tagged only one message
+ * with that tag will be displayed. Trying to display a new notification
+ * with the same tag as one already being displayed will cause the other
+ * notification to be closed and this new notification to open up inside
+ * the same place as the previous notification.
+ *
+ * - title:
+ * An optional title for the notification. Will be displayed above the
+ * content. Usually in bold.
+ */
+ defaults: {
+ autoHide: true,
+ tag: false,
+ title: undefined
+ },
+
+ /**
+ * @property {number}
+ * Number of seconds to wait before auto-hiding notifications.
+ */
+ autoHideSeconds: 5,
+
+ /**
+ * @property {number}
+ * Maximum number of notifications to count down auto-hide timers for.
+ * Only the first #autoHideLimit notifications being displayed will
+ * auto-hide. Any notifications further down in the list will only start
+ * counting down to auto-hide after the first few messages have closed.
+ *
+ * This basically represents the number of notifications the user should
+ * be able to process in #autoHideSeconds time.
+ */
+ autoHideLimit: 3
+ };
+
+ $( function () {
+ var notif;
+
+ init();
+
+ // Handle pre-ready queue.
+ isPageReady = true;
+ while ( preReadyNotifQueue.length ) {
+ notif = preReadyNotifQueue.shift();
+ notif.start();
+ }
+ } );
+
+ mw.notification = notification;
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki/mediawiki.notify.js b/resources/src/mediawiki/mediawiki.notify.js
new file mode 100644
index 00000000..c1e1dabf
--- /dev/null
+++ b/resources/src/mediawiki/mediawiki.notify.js
@@ -0,0 +1,27 @@
+/**
+ * @class mw.plugin.notify
+ */
+( function ( mw ) {
+ 'use strict';
+
+ /**
+ * @see mw.notification#notify
+ * @param message
+ * @param options
+ * @return {jQuery.Promise}
+ */
+ mw.notify = function ( message, options ) {
+ // Don't bother loading the whole notification system if we never use it.
+ return mw.loader.using( 'mediawiki.notification' )
+ .then( function () {
+ // Call notify with the notification the user requested of us.
+ return mw.notification.notify( message, options );
+ } );
+ };
+
+ /**
+ * @class mw
+ * @mixins mw.plugin.notify
+ */
+
+}( mediaWiki ) );
diff --git a/resources/src/mediawiki/mediawiki.pager.tablePager.less b/resources/src/mediawiki/mediawiki.pager.tablePager.less
new file mode 100644
index 00000000..d37aec5b
--- /dev/null
+++ b/resources/src/mediawiki/mediawiki.pager.tablePager.less
@@ -0,0 +1,84 @@
+/*!
+ * Structures generated by the TablePager PHP class
+ * in MediaWiki (used e.g. on Special:ListFiles).
+ */
+
+@import "mediawiki.mixins";
+
+.TablePager {
+ min-width: 80%;
+}
+
+.TablePager .TablePager_sort-ascending a {
+ padding-left: 15px;
+ background: none left center no-repeat;
+ .background-image-svg('images/arrow-sort-ascending.svg', 'images/arrow-sort-ascending.png');
+}
+
+.TablePager .TablePager_sort-descending a {
+ padding-left: 15px;
+ background: none left center no-repeat;
+ .background-image-svg('images/arrow-sort-descending.svg', 'images/arrow-sort-descending.png');
+}
+
+.TablePager_nav {
+ margin: 0 auto;
+}
+
+.TablePager_nav td {
+ padding: 3px;
+ text-align: center;
+ vertical-align: center;
+}
+
+.TablePager_nav a {
+ text-decoration: none;
+}
+
+.TablePager_nav td.TablePager_nav-first .TablePager_nav-disabled {
+ padding-top: 25px;
+ /* @embed */
+ background: url(images/pager-arrow-disabled-fastforward-rtl.png) center top no-repeat;
+}
+
+.TablePager_nav td.TablePager_nav-prev .TablePager_nav-disabled {
+ padding-top: 25px;
+ /* @embed */
+ background: url(images/pager-arrow-disabled-forward-rtl.png) center top no-repeat;
+}
+
+.TablePager_nav td.TablePager_nav-next .TablePager_nav-disabled {
+ padding-top: 25px;
+ /* @embed */
+ background: url(images/pager-arrow-disabled-forward-ltr.png) center top no-repeat;
+}
+
+.TablePager_nav td.TablePager_nav-last .TablePager_nav-disabled {
+ padding-top: 25px;
+ /* @embed */
+ background: url(images/pager-arrow-disabled-fastforward-ltr.png) center top no-repeat;
+}
+
+.TablePager_nav td.TablePager_nav-first .TablePager_nav-enabled {
+ padding-top: 25px;
+ /* @embed */
+ background: url(images/pager-arrow-fastforward-rtl.png) center top no-repeat;
+}
+
+.TablePager_nav td.TablePager_nav-prev .TablePager_nav-enabled {
+ padding-top: 25px;
+ /* @embed */
+ background: url(images/pager-arrow-forward-rtl.png) center top no-repeat;
+}
+
+.TablePager_nav td.TablePager_nav-next .TablePager_nav-enabled {
+ padding-top: 25px;
+ /* @embed */
+ background: url(images/pager-arrow-forward-ltr.png) center top no-repeat;
+}
+
+.TablePager_nav td.TablePager_nav-last .TablePager_nav-enabled {
+ padding-top: 25px;
+ /* @embed */
+ background: url(images/pager-arrow-fastforward-ltr.png) center top no-repeat;
+}
diff --git a/resources/src/mediawiki/mediawiki.searchSuggest.css b/resources/src/mediawiki/mediawiki.searchSuggest.css
new file mode 100644
index 00000000..df144ce9
--- /dev/null
+++ b/resources/src/mediawiki/mediawiki.searchSuggest.css
@@ -0,0 +1,24 @@
+/* Make sure the links are not underlined or colored, ever. */
+/* There is already a :focus / :hover indication on the <div>. */
+.suggestions a.mw-searchSuggest-link,
+.suggestions a.mw-searchSuggest-link:hover,
+.suggestions a.mw-searchSuggest-link:active,
+.suggestions a.mw-searchSuggest-link:focus {
+ color: black;
+ text-decoration: none;
+}
+
+.suggestions-result-current a.mw-searchSuggest-link,
+.suggestions-result-current a.mw-searchSuggest-link:hover,
+.suggestions-result-current a.mw-searchSuggest-link:active,
+.suggestions-result-current a.mw-searchSuggest-link:focus {
+ color: white;
+}
+
+.suggestions a.mw-searchSuggest-link .special-query {
+ /* Apply ellipsis to suggestions */
+ overflow: hidden;
+ -o-text-overflow: ellipsis; /* Opera 9 to 10 */
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
diff --git a/resources/src/mediawiki/mediawiki.searchSuggest.js b/resources/src/mediawiki/mediawiki.searchSuggest.js
new file mode 100644
index 00000000..a214cb3f
--- /dev/null
+++ b/resources/src/mediawiki/mediawiki.searchSuggest.js
@@ -0,0 +1,199 @@
+/*!
+ * Add search suggestions to the search form.
+ */
+( function ( mw, $ ) {
+ $( function () {
+ var api, map, resultRenderCache, searchboxesSelectors,
+ // Region where the suggestions box will appear directly below
+ // (using the same width). Can be a container element or the input
+ // itself, depending on what suits best in the environment.
+ // For Vector the suggestion box should align with the simpleSearch
+ // container's borders, in other skins it should align with the input
+ // element (not the search form, as that would leave the buttons
+ // vertically between the input and the suggestions).
+ $searchRegion = $( '#simpleSearch, #searchInput' ).first(),
+ $searchInput = $( '#searchInput' );
+
+ // Compatibility map
+ map = {
+ // SimpleSearch is broken in Opera < 9.6
+ opera: [['>=', 9.6]],
+ // Older Konquerors are unable to position the suggestions correctly (bug 50805)
+ konqueror: [['>=', '4.11']],
+ docomo: false,
+ blackberry: false,
+ // Support for iOS 6 or higher. It has not been tested on iOS 5 or lower
+ ipod: [['>=', 6]],
+ iphone: [['>=', 6]]
+ };
+
+ if ( !$.client.test( map ) ) {
+ return;
+ }
+
+ // Compute form data for search suggestions functionality.
+ function computeResultRenderCache( context ) {
+ var $form, baseHref, linkParams;
+
+ // Compute common parameters for links' hrefs
+ $form = context.config.$region.closest( 'form' );
+
+ baseHref = $form.attr( 'action' );
+ baseHref += baseHref.indexOf( '?' ) > -1 ? '&' : '?';
+
+ linkParams = {};
+ $.each( $form.serializeArray(), function ( idx, obj ) {
+ linkParams[ obj.name ] = obj.value;
+ } );
+
+ return {
+ textParam: context.data.$textbox.attr( 'name' ),
+ linkParams: linkParams,
+ baseHref: baseHref
+ };
+ }
+
+ // The function used to render the suggestions.
+ function renderFunction( text, context ) {
+ if ( !resultRenderCache ) {
+ resultRenderCache = computeResultRenderCache( context );
+ }
+
+ // linkParams object is modified and reused
+ resultRenderCache.linkParams[ resultRenderCache.textParam ] = text;
+
+ // this is the container <div>, jQueryfied
+ this.text( text )
+ .wrap(
+ $( '<a>' )
+ .attr( 'href', resultRenderCache.baseHref + $.param( resultRenderCache.linkParams ) )
+ .attr( 'title', text )
+ .addClass( 'mw-searchSuggest-link' )
+ );
+ }
+
+ function specialRenderFunction( query, context ) {
+ var $el = this;
+
+ if ( !resultRenderCache ) {
+ resultRenderCache = computeResultRenderCache( context );
+ }
+
+ // linkParams object is modified and reused
+ resultRenderCache.linkParams[ resultRenderCache.textParam ] = query;
+
+ if ( $el.children().length === 0 ) {
+ $el
+ .append(
+ $( '<div>' )
+ .addClass( 'special-label' )
+ .text( mw.msg( 'searchsuggest-containing' ) ),
+ $( '<div>' )
+ .addClass( 'special-query' )
+ .text( query )
+ )
+ .show();
+ } else {
+ $el.find( '.special-query' )
+ .text( query );
+ }
+
+ if ( $el.parent().hasClass( 'mw-searchSuggest-link' ) ) {
+ $el.parent().attr( 'href', resultRenderCache.baseHref + $.param( resultRenderCache.linkParams ) + '&fulltext=1' );
+ } else {
+ $el.wrap(
+ $( '<a>' )
+ .attr( 'href', resultRenderCache.baseHref + $.param( resultRenderCache.linkParams ) + '&fulltext=1' )
+ .addClass( 'mw-searchSuggest-link' )
+ );
+ }
+ }
+
+ // Generic suggestions functionality for all search boxes
+ searchboxesSelectors = [
+ // Primary searchbox on every page in standard skins
+ '#searchInput',
+ // Special:Search
+ '#powerSearchText',
+ '#searchText',
+ // Generic selector for skins with multiple searchboxes (used by CologneBlue)
+ // and for MediaWiki itself (special pages with page title inputs)
+ '.mw-searchInput'
+ ];
+ $( searchboxesSelectors.join( ', ' ) )
+ .suggestions( {
+ fetch: function ( query, response ) {
+ var node = this[0];
+
+ api = api || new mw.Api();
+
+ $.data( node, 'request', api.get( {
+ action: 'opensearch',
+ search: query,
+ namespace: 0,
+ suggest: ''
+ } ).done( function ( data ) {
+ response( data[ 1 ] );
+ } ) );
+ },
+ cancel: function () {
+ var node = this[0],
+ request = $.data( node, 'request' );
+
+ if ( request ) {
+ request.abort();
+ $.removeData( node, 'request' );
+ }
+ },
+ result: {
+ render: renderFunction,
+ select: function () {
+ // allow the form to be submitted
+ return true;
+ }
+ },
+ cache: true,
+ highlightInput: true
+ } )
+ .bind( 'paste cut drop', function () {
+ // make sure paste and cut events from the mouse and drag&drop events
+ // trigger the keypress handler and cause the suggestions to update
+ $( this ).trigger( 'keypress' );
+ } )
+ // In most skins (at least Monobook and Vector), the font-size is messed up in <body>.
+ // (they use 2 elements to get a sane font-height). So, instead of making exceptions for
+ // each skin or adding more stylesheets, just copy it from the active element so auto-fit.
+ .each( function () {
+ var $this = $( this );
+ $this
+ .data( 'suggestions-context' )
+ .data.$container
+ .css( 'fontSize', $this.css( 'fontSize' ) );
+ } );
+
+ // Ensure that the thing is actually present!
+ if ( $searchRegion.length === 0 ) {
+ // Don't try to set anything up if simpleSearch is disabled sitewide.
+ // The loader code loads us if the option is present, even if we're
+ // not actually enabled (anymore).
+ return;
+ }
+
+ // Special suggestions functionality for skin-provided search box
+ $searchInput.suggestions( {
+ special: {
+ render: specialRenderFunction,
+ select: function ( $input ) {
+ $input.closest( 'form' )
+ .append( $( '<input type="hidden" name="fulltext" value="1"/>' ) );
+ return true; // allow the form to be submitted
+ }
+ },
+ $region: $searchRegion
+ } );
+
+ // If the form includes any fallback fulltext search buttons, remove them
+ $searchInput.closest( 'form' ).find( '.mw-fallbackSearchButton' ).remove();
+ } );
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki/mediawiki.toc.js b/resources/src/mediawiki/mediawiki.toc.js
new file mode 100644
index 00000000..45338ea7
--- /dev/null
+++ b/resources/src/mediawiki/mediawiki.toc.js
@@ -0,0 +1,60 @@
+( function ( mw, $ ) {
+ 'use strict';
+
+ // Table of contents toggle
+ mw.hook( 'wikipage.content' ).add( function ( $content ) {
+ var $toc, $tocTitle, $tocToggleLink, $tocList, hideToc;
+ $toc = $content.find( '#toc' );
+ $tocTitle = $content.find( '#toctitle' );
+ $tocToggleLink = $content.find( '#togglelink' );
+ $tocList = $toc.find( 'ul' ).eq( 0 );
+
+ // Hide/show the table of contents element
+ function toggleToc() {
+ if ( $tocList.is( ':hidden' ) ) {
+ $tocList.slideDown( 'fast' );
+ $tocToggleLink.text( mw.msg( 'hidetoc' ) );
+ $toc.removeClass( 'tochidden' );
+ $.cookie( 'mw_hidetoc', null, {
+ expires: 30,
+ path: '/'
+ } );
+ } else {
+ $tocList.slideUp( 'fast' );
+ $tocToggleLink.text( mw.msg( 'showtoc' ) );
+ $toc.addClass( 'tochidden' );
+ $.cookie( 'mw_hidetoc', '1', {
+ expires: 30,
+ path: '/'
+ } );
+ }
+ }
+
+ // Only add it if there is a complete TOC and it doesn't
+ // have a toggle added already
+ if ( $toc.length && $tocTitle.length && $tocList.length && !$tocToggleLink.length ) {
+ hideToc = $.cookie( 'mw_hidetoc' ) === '1';
+
+ $tocToggleLink = $( '<a href="#" id="togglelink"></a>' )
+ .text( hideToc ? mw.msg( 'showtoc' ) : mw.msg( 'hidetoc' ) )
+ .click( function ( e ) {
+ e.preventDefault();
+ toggleToc();
+ } );
+
+ $tocTitle.append(
+ $tocToggleLink
+ .wrap( '<span class="toctoggle"></span>' )
+ .parent()
+ .prepend( '&nbsp;[' )
+ .append( ']&nbsp;' )
+ );
+
+ if ( hideToc ) {
+ $tocList.hide();
+ $toc.addClass( 'tochidden' );
+ }
+ }
+ } );
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki/mediawiki.user.js b/resources/src/mediawiki/mediawiki.user.js
new file mode 100644
index 00000000..e93707ec
--- /dev/null
+++ b/resources/src/mediawiki/mediawiki.user.js
@@ -0,0 +1,258 @@
+/**
+ * @class mw.user
+ * @singleton
+ */
+( function ( mw, $ ) {
+ var user,
+ deferreds = {},
+ // Extend the skeleton mw.user from mediawiki.js
+ // This is kind of ugly but we're stuck with this for b/c reasons
+ options = mw.user.options || new mw.Map(),
+ tokens = mw.user.tokens || new mw.Map();
+
+ /**
+ * Get the current user's groups or rights
+ *
+ * @private
+ * @param {string} info One of 'groups' or 'rights'
+ * @return {jQuery.Promise}
+ */
+ function getUserInfo( info ) {
+ var api;
+ if ( !deferreds[info] ) {
+
+ deferreds.rights = $.Deferred();
+ deferreds.groups = $.Deferred();
+
+ api = new mw.Api();
+ api.get( {
+ action: 'query',
+ meta: 'userinfo',
+ uiprop: 'rights|groups'
+ } ).always( function ( data ) {
+ var rights, groups;
+ if ( data.query && data.query.userinfo ) {
+ rights = data.query.userinfo.rights;
+ groups = data.query.userinfo.groups;
+ }
+ deferreds.rights.resolve( rights || [] );
+ deferreds.groups.resolve( groups || [] );
+ } );
+
+ }
+
+ return deferreds[info].promise();
+ }
+
+ mw.user = user = {
+ options: options,
+ tokens: tokens,
+
+ /**
+ * Generate a random user session ID (32 alpha-numeric characters)
+ *
+ * This information would potentially be stored in a cookie to identify a user during a
+ * session or series of sessions. Its uniqueness should not be depended on.
+ *
+ * @return {string} Random set of 32 alpha-numeric characters
+ */
+ generateRandomSessionId: function () {
+ var i, r,
+ id = '',
+ seed = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
+ for ( i = 0; i < 32; i++ ) {
+ r = Math.floor( Math.random() * seed.length );
+ id += seed.charAt( r );
+ }
+ return id;
+ },
+
+ /**
+ * Get the current user's database id
+ *
+ * Not to be confused with #id.
+ *
+ * @return {number} Current user's id, or 0 if user is anonymous
+ */
+ getId: function () {
+ return mw.config.get( 'wgUserId', 0 );
+ },
+
+ /**
+ * Get the current user's name
+ *
+ * @return {string|null} User name string or null if user is anonymous
+ */
+ getName: function () {
+ return mw.config.get( 'wgUserName' );
+ },
+
+ /**
+ * Get date user registered, if available
+ *
+ * @return {Date|boolean|null} Date user registered, or false for anonymous users, or
+ * null when data is not available
+ */
+ getRegistration: function () {
+ var registration = mw.config.get( 'wgUserRegistration' );
+ if ( user.isAnon() ) {
+ return false;
+ } else if ( registration === null ) {
+ // Information may not be available if they signed up before
+ // MW began storing this.
+ return null;
+ } else {
+ return new Date( registration );
+ }
+ },
+
+ /**
+ * Whether the current user is anonymous
+ *
+ * @return {boolean}
+ */
+ isAnon: function () {
+ return user.getName() === null;
+ },
+
+ /**
+ * Get an automatically generated random ID (stored in a session cookie)
+ *
+ * This ID is ephemeral for everyone, staying in their browser only until they close
+ * their browser.
+ *
+ * @return {string} Random session ID
+ */
+ sessionId: function () {
+ var sessionId = $.cookie( 'mediaWiki.user.sessionId' );
+ if ( sessionId === undefined || sessionId === null ) {
+ sessionId = user.generateRandomSessionId();
+ $.cookie( 'mediaWiki.user.sessionId', sessionId, { expires: null, path: '/' } );
+ }
+ return sessionId;
+ },
+
+ /**
+ * Get the current user's name or the session ID
+ *
+ * Not to be confused with #getId.
+ *
+ * @return {string} User name or random session ID
+ */
+ id: function () {
+ return user.getName() || user.sessionId();
+ },
+
+ /**
+ * Get the user's bucket (place them in one if not done already)
+ *
+ * mw.user.bucket( 'test', {
+ * buckets: { ignored: 50, control: 25, test: 25 },
+ * version: 1,
+ * expires: 7
+ * } );
+ *
+ * @deprecated since 1.23
+ * @param {string} key Name of bucket
+ * @param {Object} options Bucket configuration options
+ * @param {Object} options.buckets List of bucket-name/relative-probability pairs (required,
+ * must have at least one pair)
+ * @param {number} [options.version=0] Version of bucket test, changing this forces
+ * rebucketing
+ * @param {number} [options.expires=30] Length of time (in days) until the user gets
+ * rebucketed
+ * @return {string} Bucket name - the randomly chosen key of the `options.buckets` object
+ */
+ bucket: function ( key, options ) {
+ var cookie, parts, version, bucket,
+ range, k, rand, total;
+
+ options = $.extend( {
+ buckets: {},
+ version: 0,
+ expires: 30
+ }, options || {} );
+
+ cookie = $.cookie( 'mediaWiki.user.bucket:' + key );
+
+ // Bucket information is stored as 2 integers, together as version:bucket like: "1:2"
+ if ( typeof cookie === 'string' && cookie.length > 2 && cookie.indexOf( ':' ) !== -1 ) {
+ parts = cookie.split( ':' );
+ if ( parts.length > 1 && Number( parts[0] ) === options.version ) {
+ version = Number( parts[0] );
+ bucket = String( parts[1] );
+ }
+ }
+
+ if ( bucket === undefined ) {
+ if ( !$.isPlainObject( options.buckets ) ) {
+ throw new Error( 'Invalid bucket. Object expected for options.buckets.' );
+ }
+
+ version = Number( options.version );
+
+ // Find range
+ range = 0;
+ for ( k in options.buckets ) {
+ range += options.buckets[k];
+ }
+
+ // Select random value within range
+ rand = Math.random() * range;
+
+ // Determine which bucket the value landed in
+ total = 0;
+ for ( k in options.buckets ) {
+ bucket = k;
+ total += options.buckets[k];
+ if ( total >= rand ) {
+ break;
+ }
+ }
+
+ $.cookie(
+ 'mediaWiki.user.bucket:' + key,
+ version + ':' + bucket,
+ { path: '/', expires: Number( options.expires ) }
+ );
+ }
+
+ return bucket;
+ },
+
+ /**
+ * Get the current user's groups
+ *
+ * @param {Function} [callback]
+ * @return {jQuery.Promise}
+ */
+ getGroups: function ( callback ) {
+ return getUserInfo( 'groups' ).done( callback );
+ },
+
+ /**
+ * Get the current user's rights
+ *
+ * @param {Function} [callback]
+ * @return {jQuery.Promise}
+ */
+ getRights: function ( callback ) {
+ return getUserInfo( 'rights' ).done( callback );
+ }
+ };
+
+ /**
+ * @method name
+ * @inheritdoc #getName
+ * @deprecated since 1.20 Use #getName instead
+ */
+ mw.log.deprecate( user, 'name', user.getName, 'Use mw.user.getName instead.' );
+
+ /**
+ * @method anonymous
+ * @inheritdoc #isAnon
+ * @deprecated since 1.20 Use #isAnon instead
+ */
+ mw.log.deprecate( user, 'anonymous', user.isAnon, 'Use mw.user.isAnon instead.' );
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/mediawiki/mediawiki.util.js b/resources/src/mediawiki/mediawiki.util.js
new file mode 100644
index 00000000..26629137
--- /dev/null
+++ b/resources/src/mediawiki/mediawiki.util.js
@@ -0,0 +1,531 @@
+( function ( mw, $ ) {
+ 'use strict';
+
+ /**
+ * Utility library
+ * @class mw.util
+ * @singleton
+ */
+ var util = {
+
+ /**
+ * Initialisation
+ * (don't call before document ready)
+ */
+ init: function () {
+ util.$content = ( function () {
+ var i, l, $node, selectors;
+
+ selectors = [
+ // The preferred standard is class "mw-body".
+ // You may also use class "mw-body mw-body-primary" if you use
+ // mw-body in multiple locations. Or class "mw-body-primary" if
+ // you use mw-body deeper in the DOM.
+ '.mw-body-primary',
+ '.mw-body',
+
+ // If the skin has no such class, fall back to the parser output
+ '#mw-content-text',
+
+ // Should never happen... well, it could if someone is not finished writing a
+ // skin and has not yet inserted bodytext yet.
+ 'body'
+ ];
+
+ for ( i = 0, l = selectors.length; i < l; i++ ) {
+ $node = $( selectors[i] );
+ if ( $node.length ) {
+ return $node.first();
+ }
+ }
+
+ // Preserve existing customized value in case it was preset
+ return util.$content;
+ }() );
+ },
+
+ /* Main body */
+
+ /**
+ * Encode the string like PHP's rawurlencode
+ *
+ * @param {string} str String to be encoded.
+ */
+ rawurlencode: function ( str ) {
+ str = String( str );
+ return encodeURIComponent( str )
+ .replace( /!/g, '%21' ).replace( /'/g, '%27' ).replace( /\(/g, '%28' )
+ .replace( /\)/g, '%29' ).replace( /\*/g, '%2A' ).replace( /~/g, '%7E' );
+ },
+
+ /**
+ * Encode page titles for use in a URL
+ *
+ * We want / and : to be included as literal characters in our title URLs
+ * as they otherwise fatally break the title.
+ *
+ * The others are decoded because we can, it's prettier and matches behaviour
+ * of `wfUrlencode` in PHP.
+ *
+ * @param {string} str String to be encoded.
+ */
+ wikiUrlencode: function ( str ) {
+ return util.rawurlencode( str )
+ .replace( /%20/g, '_' )
+ // wfUrlencode replacements
+ .replace( /%3B/g, ';' )
+ .replace( /%40/g, '@' )
+ .replace( /%24/g, '$' )
+ .replace( /%21/g, '!' )
+ .replace( /%2A/g, '*' )
+ .replace( /%28/g, '(' )
+ .replace( /%29/g, ')' )
+ .replace( /%2C/g, ',' )
+ .replace( /%2F/g, '/' )
+ .replace( /%3A/g, ':' );
+ },
+
+ /**
+ * Get the link to a page name (relative to `wgServer`),
+ *
+ * @param {string} str Page name
+ * @param {Object} [params] A mapping of query parameter names to values,
+ * e.g. `{ action: 'edit' }`
+ * @return {string} Url of the page with name of `str`
+ */
+ getUrl: function ( str, params ) {
+ var url = mw.config.get( 'wgArticlePath' ).replace(
+ '$1',
+ util.wikiUrlencode( typeof str === 'string' ? str : mw.config.get( 'wgPageName' ) )
+ );
+
+ if ( params && !$.isEmptyObject( params ) ) {
+ url += ( url.indexOf( '?' ) !== -1 ? '&' : '?' ) + $.param( params );
+ }
+
+ return url;
+ },
+
+ /**
+ * Get address to a script in the wiki root.
+ * For index.php use `mw.config.get( 'wgScript' )`.
+ *
+ * @since 1.18
+ * @param str string Name of script (eg. 'api'), defaults to 'index'
+ * @return string Address to script (eg. '/w/api.php' )
+ */
+ wikiScript: function ( str ) {
+ str = str || 'index';
+ if ( str === 'index' ) {
+ return mw.config.get( 'wgScript' );
+ } else if ( str === 'load' ) {
+ return mw.config.get( 'wgLoadScript' );
+ } else {
+ return mw.config.get( 'wgScriptPath' ) + '/' + str +
+ mw.config.get( 'wgScriptExtension' );
+ }
+ },
+
+ /**
+ * Append a new style block to the head and return the CSSStyleSheet object.
+ * Use .ownerNode to access the `<style>` element, or use mw.loader#addStyleTag.
+ * This function returns the styleSheet object for convience (due to cross-browsers
+ * difference as to where it is located).
+ *
+ * var sheet = mw.util.addCSS( '.foobar { display: none; }' );
+ * $( foo ).click( function () {
+ * // Toggle the sheet on and off
+ * sheet.disabled = !sheet.disabled;
+ * } );
+ *
+ * @param {string} text CSS to be appended
+ * @return {CSSStyleSheet} Use .ownerNode to get to the `<style>` element.
+ */
+ addCSS: function ( text ) {
+ var s = mw.loader.addStyleTag( text );
+ return s.sheet || s.styleSheet || s;
+ },
+
+ /**
+ * Grab the URL parameter value for the given parameter.
+ * Returns null if not found.
+ *
+ * @param {string} param The parameter name.
+ * @param {string} [url=document.location.href] URL to search through, defaulting to the current document's URL.
+ * @return {Mixed} Parameter value or null.
+ */
+ getParamValue: function ( param, url ) {
+ if ( url === undefined ) {
+ url = document.location.href;
+ }
+ // Get last match, stop at hash
+ var re = new RegExp( '^[^#]*[&?]' + $.escapeRE( param ) + '=([^&#]*)' ),
+ m = re.exec( url );
+ if ( m ) {
+ // Beware that decodeURIComponent is not required to understand '+'
+ // by spec, as encodeURIComponent does not produce it.
+ return decodeURIComponent( m[1].replace( /\+/g, '%20' ) );
+ }
+ return null;
+ },
+
+ /**
+ * The content wrapper of the skin (e.g. `.mw-body`).
+ *
+ * Populated on document ready by #init. To use this property,
+ * wait for `$.ready` and be sure to have a module depedendency on
+ * `mediawiki.util` and `mediawiki.page.startup` which will ensure
+ * your document ready handler fires after #init.
+ *
+ * Because of the lazy-initialised nature of this property,
+ * you're discouraged from using it.
+ *
+ * If you need just the wikipage content (not any of the
+ * extra elements output by the skin), use `$( '#mw-content-text' )`
+ * instead. Or listen to mw.hook#wikipage_content which will
+ * allow your code to re-run when the page changes (e.g. live preview
+ * or re-render after ajax save).
+ *
+ * @property {jQuery}
+ */
+ $content: null,
+
+ /**
+ * Add a link to a portlet menu on the page, such as:
+ *
+ * p-cactions (Content actions), p-personal (Personal tools),
+ * p-navigation (Navigation), p-tb (Toolbox)
+ *
+ * The first three paramters are required, the others are optional and
+ * may be null. Though providing an id and tooltip is recommended.
+ *
+ * By default the new link will be added to the end of the list. To
+ * add the link before a given existing item, pass the DOM node
+ * (e.g. `document.getElementById( 'foobar' )`) or a jQuery-selector
+ * (e.g. `'#foobar'`) for that item.
+ *
+ * mw.util.addPortletLink(
+ * 'p-tb', 'http://mediawiki.org/',
+ * 'MediaWiki.org', 't-mworg', 'Go to MediaWiki.org ', 'm', '#t-print'
+ * );
+ *
+ * @param {string} portlet ID of the target portlet ( 'p-cactions' or 'p-personal' etc.)
+ * @param {string} href Link URL
+ * @param {string} text Link text
+ * @param {string} [id] ID of the new item, should be unique and preferably have
+ * the appropriate prefix ( 'ca-', 'pt-', 'n-' or 't-' )
+ * @param {string} [tooltip] Text to show when hovering over the link, without accesskey suffix
+ * @param {string} [accesskey] Access key to activate this link (one character, try
+ * to avoid conflicts. Use `$( '[accesskey=x]' ).get()` in the console to
+ * see if 'x' is already used.
+ * @param {HTMLElement|jQuery|string} [nextnode] Element or jQuery-selector string to the item that
+ * the new item should be added before, should be another item in the same
+ * list, it will be ignored otherwise
+ *
+ * @return {HTMLElement|null} The added element (a ListItem or Anchor element,
+ * depending on the skin) or null if no element was added to the document.
+ */
+ addPortletLink: function ( portlet, href, text, id, tooltip, accesskey, nextnode ) {
+ var $item, $link, $portlet, $ul;
+
+ // Check if there's atleast 3 arguments to prevent a TypeError
+ if ( arguments.length < 3 ) {
+ return null;
+ }
+ // Setup the anchor tag
+ $link = $( '<a>' ).attr( 'href', href ).text( text );
+ if ( tooltip ) {
+ $link.attr( 'title', tooltip );
+ }
+
+ // Select the specified portlet
+ $portlet = $( '#' + portlet );
+ if ( $portlet.length === 0 ) {
+ return null;
+ }
+ // Select the first (most likely only) unordered list inside the portlet
+ $ul = $portlet.find( 'ul' ).eq( 0 );
+
+ // If it didn't have an unordered list yet, create it
+ if ( $ul.length === 0 ) {
+
+ $ul = $( '<ul>' );
+
+ // If there's no <div> inside, append it to the portlet directly
+ if ( $portlet.find( 'div:first' ).length === 0 ) {
+ $portlet.append( $ul );
+ } else {
+ // otherwise if there's a div (such as div.body or div.pBody)
+ // append the <ul> to last (most likely only) div
+ $portlet.find( 'div' ).eq( -1 ).append( $ul );
+ }
+ }
+ // Just in case..
+ if ( $ul.length === 0 ) {
+ return null;
+ }
+
+ // Unhide portlet if it was hidden before
+ $portlet.removeClass( 'emptyPortlet' );
+
+ // Wrap the anchor tag in a list item (and a span if $portlet is a Vector tab)
+ // and back up the selector to the list item
+ if ( $portlet.hasClass( 'vectorTabs' ) ) {
+ $item = $link.wrap( '<li><span></span></li>' ).parent().parent();
+ } else {
+ $item = $link.wrap( '<li></li>' ).parent();
+ }
+
+ // Implement the properties passed to the function
+ if ( id ) {
+ $item.attr( 'id', id );
+ }
+
+ if ( accesskey ) {
+ $link.attr( 'accesskey', accesskey );
+ }
+
+ if ( tooltip ) {
+ $link.attr( 'title', tooltip ).updateTooltipAccessKeys();
+ }
+
+ if ( nextnode ) {
+ if ( nextnode.nodeType || typeof nextnode === 'string' ) {
+ // nextnode is a DOM element (was the only option before MW 1.17, in wikibits.js)
+ // or nextnode is a CSS selector for jQuery
+ nextnode = $ul.find( nextnode );
+ } else if ( !nextnode.jquery || ( nextnode.length && nextnode[0].parentNode !== $ul[0] ) ) {
+ // Fallback
+ $ul.append( $item );
+ return $item[0];
+ }
+ if ( nextnode.length === 1 ) {
+ // nextnode is a jQuery object that represents exactly one element
+ nextnode.before( $item );
+ return $item[0];
+ }
+ }
+
+ // Fallback (this is the default behavior)
+ $ul.append( $item );
+ return $item[0];
+
+ },
+
+ /**
+ * Validate a string as representing a valid e-mail address
+ * according to HTML5 specification. Please note the specification
+ * does not validate a domain with one character.
+ *
+ * FIXME: should be moved to or replaced by a validation module.
+ *
+ * @param {string} mailtxt E-mail address to be validated.
+ * @return {boolean|null} Null if `mailtxt` was an empty string, otherwise true/false
+ * as determined by validation.
+ */
+ validateEmail: function ( mailtxt ) {
+ var rfc5322Atext, rfc1034LdhStr, html5EmailRegexp;
+
+ if ( mailtxt === '' ) {
+ return null;
+ }
+
+ // HTML5 defines a string as valid e-mail address if it matches
+ // the ABNF:
+ // 1 * ( atext / "." ) "@" ldh-str 1*( "." ldh-str )
+ // With:
+ // - atext : defined in RFC 5322 section 3.2.3
+ // - ldh-str : defined in RFC 1034 section 3.5
+ //
+ // (see STD 68 / RFC 5234 http://tools.ietf.org/html/std68)
+ // First, define the RFC 5322 'atext' which is pretty easy:
+ // atext = ALPHA / DIGIT / ; Printable US-ASCII
+ // "!" / "#" / ; characters not including
+ // "$" / "%" / ; specials. Used for atoms.
+ // "&" / "'" /
+ // "*" / "+" /
+ // "-" / "/" /
+ // "=" / "?" /
+ // "^" / "_" /
+ // "`" / "{" /
+ // "|" / "}" /
+ // "~"
+ rfc5322Atext = 'a-z0-9!#$%&\'*+\\-/=?^_`{|}~';
+
+ // Next define the RFC 1034 'ldh-str'
+ // <domain> ::= <subdomain> | " "
+ // <subdomain> ::= <label> | <subdomain> "." <label>
+ // <label> ::= <letter> [ [ <ldh-str> ] <let-dig> ]
+ // <ldh-str> ::= <let-dig-hyp> | <let-dig-hyp> <ldh-str>
+ // <let-dig-hyp> ::= <let-dig> | "-"
+ // <let-dig> ::= <letter> | <digit>
+ rfc1034LdhStr = 'a-z0-9\\-';
+
+ html5EmailRegexp = new RegExp(
+ // start of string
+ '^'
+ +
+ // User part which is liberal :p
+ '[' + rfc5322Atext + '\\.]+'
+ +
+ // 'at'
+ '@'
+ +
+ // Domain first part
+ '[' + rfc1034LdhStr + ']+'
+ +
+ // Optional second part and following are separated by a dot
+ '(?:\\.[' + rfc1034LdhStr + ']+)*'
+ +
+ // End of string
+ '$',
+ // RegExp is case insensitive
+ 'i'
+ );
+ return ( mailtxt.match( html5EmailRegexp ) !== null );
+ },
+
+ /**
+ * Note: borrows from IP::isIPv4
+ *
+ * @param {string} address
+ * @param {boolean} allowBlock
+ * @return {boolean}
+ */
+ isIPv4Address: function ( address, allowBlock ) {
+ if ( typeof address !== 'string' ) {
+ return false;
+ }
+
+ var block = allowBlock ? '(?:\\/(?:3[0-2]|[12]?\\d))?' : '',
+ RE_IP_BYTE = '(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|0?[0-9]?[0-9])',
+ RE_IP_ADD = '(?:' + RE_IP_BYTE + '\\.){3}' + RE_IP_BYTE;
+
+ return address.search( new RegExp( '^' + RE_IP_ADD + block + '$' ) ) !== -1;
+ },
+
+ /**
+ * Note: borrows from IP::isIPv6
+ *
+ * @param {string} address
+ * @param {boolean} allowBlock
+ * @return {boolean}
+ */
+ isIPv6Address: function ( address, allowBlock ) {
+ if ( typeof address !== 'string' ) {
+ return false;
+ }
+
+ var block = allowBlock ? '(?:\\/(?:12[0-8]|1[01][0-9]|[1-9]?\\d))?' : '',
+ RE_IPV6_ADD =
+ '(?:' + // starts with "::" (including "::")
+ ':(?::|(?::' + '[0-9A-Fa-f]{1,4}' + '){1,7})' +
+ '|' + // ends with "::" (except "::")
+ '[0-9A-Fa-f]{1,4}' + '(?::' + '[0-9A-Fa-f]{1,4}' + '){0,6}::' +
+ '|' + // contains no "::"
+ '[0-9A-Fa-f]{1,4}' + '(?::' + '[0-9A-Fa-f]{1,4}' + '){7}' +
+ ')';
+
+ if ( address.search( new RegExp( '^' + RE_IPV6_ADD + block + '$' ) ) !== -1 ) {
+ return true;
+ }
+
+ RE_IPV6_ADD = // contains one "::" in the middle (single '::' check below)
+ '[0-9A-Fa-f]{1,4}' + '(?:::?' + '[0-9A-Fa-f]{1,4}' + '){1,6}';
+
+ return address.search( new RegExp( '^' + RE_IPV6_ADD + block + '$' ) ) !== -1
+ && address.search( /::/ ) !== -1 && address.search( /::.*::/ ) === -1;
+ }
+ };
+
+ /**
+ * @method wikiGetlink
+ * @inheritdoc #getUrl
+ * @deprecated since 1.23 Use #getUrl instead.
+ */
+ mw.log.deprecate( util, 'wikiGetlink', util.getUrl, 'Use mw.util.getUrl instead.' );
+
+ /**
+ * Access key prefix. Might be wrong for browsers implementing the accessKeyLabel property.
+ * @property {string} tooltipAccessKeyPrefix
+ * @deprecated since 1.24 Use the module jquery.accessKeyLabel instead.
+ */
+ mw.log.deprecate( util, 'tooltipAccessKeyPrefix', $.fn.updateTooltipAccessKeys.getAccessKeyPrefix(), 'Use jquery.accessKeyLabel instead.' );
+
+ /**
+ * Regex to match accesskey tooltips.
+ *
+ * Should match:
+ *
+ * - "[ctrl-option-x]"
+ * - "[alt-shift-x]"
+ * - "[ctrl-alt-x]"
+ * - "[ctrl-x]"
+ *
+ * The accesskey is matched in group $6.
+ *
+ * Will probably not work for browsers implementing the accessKeyLabel property.
+ *
+ * @property {RegExp} tooltipAccessKeyRegexp
+ * @deprecated since 1.24 Use the module jquery.accessKeyLabel instead.
+ */
+ mw.log.deprecate( util, 'tooltipAccessKeyRegexp', /\[(ctrl-)?(option-)?(alt-)?(shift-)?(esc-)?(.)\]$/, 'Use jquery.accessKeyLabel instead.' );
+
+ /**
+ * Add the appropriate prefix to the accesskey shown in the tooltip.
+ *
+ * If the `$nodes` parameter is given, only those nodes are updated;
+ * otherwise, depending on browser support, we update either all elements
+ * with accesskeys on the page or a bunch of elements which are likely to
+ * have them on core skins.
+ *
+ * @method updateTooltipAccessKeys
+ * @param {Array|jQuery} [$nodes] A jQuery object, or array of nodes to update.
+ * @deprecated since 1.24 Use the module jquery.accessKeyLabel instead.
+ */
+ mw.log.deprecate( util, 'updateTooltipAccessKeys', function ( $nodes ) {
+ if ( !$nodes ) {
+ if ( document.querySelectorAll ) {
+ // If we're running on a browser where we can do this efficiently,
+ // just find all elements that have accesskeys. We can't use jQuery's
+ // polyfill for the selector since looping over all elements on page
+ // load might be too slow.
+ $nodes = $( document.querySelectorAll( '[accesskey]' ) );
+ } else {
+ // Otherwise go through some elements likely to have accesskeys rather
+ // than looping over all of them. Unfortunately this will not fully
+ // work for custom skins with different HTML structures. Input, label
+ // and button should be rare enough that no optimizations are needed.
+ $nodes = $( '#column-one a, #mw-head a, #mw-panel a, #p-logo a, input, label, button' );
+ }
+ } else if ( !( $nodes instanceof $ ) ) {
+ $nodes = $( $nodes );
+ }
+
+ $nodes.updateTooltipAccessKeys();
+ }, 'Use jquery.accessKeyLabel instead.' );
+
+ /**
+ * Add a little box at the top of the screen to inform the user of
+ * something, replacing any previous message.
+ * Calling with no arguments, with an empty string or null will hide the message
+ *
+ * @method jsMessage
+ * @deprecated since 1.20 Use mw#notify
+ * @param {Mixed} message The DOM-element, jQuery object or HTML-string to be put inside the message box.
+ * to allow CSS/JS to hide different boxes. null = no class used.
+ */
+ mw.log.deprecate( util, 'jsMessage', function ( message ) {
+ if ( !arguments.length || message === '' || message === null ) {
+ return true;
+ }
+ if ( typeof message !== 'object' ) {
+ message = $.parseHTML( message );
+ }
+ mw.notify( message, { autoHide: true, tag: 'legacy' } );
+ return true;
+ }, 'Use mw.notify instead.' );
+
+ mw.util = util;
+
+}( mediaWiki, jQuery ) );
diff --git a/resources/src/polyfill-object-create.js b/resources/src/polyfill-object-create.js
new file mode 100644
index 00000000..607faf64
--- /dev/null
+++ b/resources/src/polyfill-object-create.js
@@ -0,0 +1,62 @@
+/**
+ * Simplified version of es5-sham#Object-create that also works around a bug
+ * in the actual es5-sham: https://github.com/es-shims/es5-shim/issues/252
+ *
+ * Does not:
+ * - Support empty inheritance via `Object.create(null)`.
+ * - Support getter and setter accessors via `Object.create( .., properties )`.
+ * - Support custom property descriptor (e.g. writable, configurtable, enumerable).
+ * - Leave behind an enumerable "__proto__" all over the place.
+ *
+ * @author Timo Tijhof, 2014
+ */
+
+// ES5 15.2.3.5
+// http://es5.github.com/#x15.2.3.5
+if ( !Object.create ) {
+ ( function () {
+ var hasOwn = Object.hasOwnProperty,
+ // https://developer.mozilla.org/en-US/docs/ECMAScript_DontEnum_attribute#JScript_DontEnum_Bug
+ // http://whattheheadsaid.com/2010/10/a-safer-object-keys-compatibility-implementation
+ isEnumBug = !{ valueOf: 0 }.propertyIsEnumerable( 'valueOf' );
+
+ // Reusable constructor function for Object.create
+ function Empty() {}
+
+ function defineProperty( object, key, property ) {
+ if ( hasOwn.call( property, 'value' ) ) {
+ object[ key ] = property.value;
+ } else {
+ object[ key ] = property;
+ }
+ }
+
+ Object.create = function create( prototype, properties ) {
+ var object, key;
+
+ if ( prototype !== Object( prototype ) ) {
+ throw new TypeError( 'Called on non-object' );
+ }
+
+ Empty.prototype = prototype;
+ object = new Empty();
+
+ if ( properties !== undefined ) {
+ if ( !isEnumBug ) {
+ for ( key in properties ) {
+ if ( hasOwn.call( properties, key ) ) {
+ defineProperty( object, key, properties[ key ] );
+ }
+ }
+ } else {
+ Object.keys( properties ).forEach( function ( key ) {
+ defineProperty( object, key, properties[ key ] );
+ } );
+ }
+ }
+
+ return object;
+ };
+
+ }() );
+}
diff --git a/resources/src/startup.js b/resources/src/startup.js
new file mode 100644
index 00000000..a62cc9d6
--- /dev/null
+++ b/resources/src/startup.js
@@ -0,0 +1,62 @@
+/**
+ * This script provides a function which is run to evaluate whether or not to
+ * continue loading jQuery and the MediaWiki modules. This code should work on
+ * even the most ancient of browsers, so be very careful when editing.
+ */
+
+var mediaWikiLoadStart = ( new Date() ).getTime();
+
+/**
+ * Returns false for Grade C supported browsers.
+ *
+ * This function should only be used by the Startup module, do not expand it to
+ * be generally useful beyond startup.
+ *
+ * See also:
+ * - https://www.mediawiki.org/wiki/Compatibility#Browsers
+ * - https://jquery.com/browser-support/
+ */
+
+/*jshint unused: false */
+function isCompatible( ua ) {
+ if ( ua === undefined ) {
+ ua = navigator.userAgent;
+ }
+
+ // Browsers with outdated or limited JavaScript engines get the no-JS experience
+ return !(
+ // Internet Explorer < 8
+ ( ua.indexOf( 'MSIE' ) !== -1 && parseFloat( ua.split( 'MSIE' )[1] ) < 8 ) ||
+ // Firefox < 3
+ ( ua.indexOf( 'Firefox/' ) !== -1 && parseFloat( ua.split( 'Firefox/' )[1] ) < 3 ) ||
+ // Opera < 12
+ ( ua.indexOf( 'Opera/' ) !== -1 && ( ua.indexOf( 'Version/' ) === -1 ?
+ // "Opera/x.y"
+ parseFloat( ua.split( 'Opera/' )[1] ) < 10 :
+ // "Opera/9.80 ... Version/x.y"
+ parseFloat( ua.split( 'Version/' )[1] ) < 12
+ ) ) ||
+ // "Mozilla/0.0 ... Opera x.y"
+ ( ua.indexOf( 'Opera ' ) !== -1 && parseFloat( ua.split( ' Opera ' )[1] ) < 10 ) ||
+ // BlackBerry < 6
+ ua.match( /BlackBerry[^\/]*\/[1-5]\./ ) ||
+ // Open WebOS < 1.5
+ ua.match( /webOS\/1\.[0-4]/ ) ||
+ // Anything PlayStation based.
+ ua.match( /PlayStation/i ) ||
+ // Any Symbian based browsers
+ ua.match( /SymbianOS|Series60/ ) ||
+ // Any NetFront based browser
+ ua.match( /NetFront/ ) ||
+ // Opera Mini, all versions
+ ua.match( /Opera Mini/ ) ||
+ // Nokia's Ovi Browser
+ ua.match( /S40OviBrowser/ ) ||
+ // Google Glass browser groks JS but UI is too limited
+ ( ua.match( /Glass/ ) && ua.match( /Android/ ) )
+ );
+}
+
+/**
+ * The startUp() function will be auto-generated and added below.
+ */